@fitzzero/quickdraw-core 5.1.1 → 5.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +533 -180
  2. package/dist/{admin-DKwkIh5i.d.ts → admin-DWWrT1h6.d.ts} +2 -2
  3. package/dist/{adminSchemas-2DUA_hZ8.d.ts → adminSchemas-D4bFKsrb.d.ts} +6 -4
  4. package/dist/{auth-BhWqdyb-.d.ts → auth-DXiSJZaf.d.ts} +42 -16
  5. package/dist/{caller-CXTFswBO.d.ts → caller-BT-Mv4bD.d.ts} +117 -22
  6. package/dist/{chunk-RT6OILOI.js → chunk-4D2DOGAW.js} +2 -14
  7. package/dist/chunk-4D2DOGAW.js.map +1 -0
  8. package/dist/chunk-5CGO426C.js +15 -0
  9. package/dist/chunk-5CGO426C.js.map +1 -0
  10. package/dist/{chunk-RZTP4IEW.js → chunk-6K5BY47X.js} +4594 -3481
  11. package/dist/chunk-6K5BY47X.js.map +1 -0
  12. package/dist/{chunk-VSQBHPWH.js → chunk-753VJIUP.js} +13 -15
  13. package/dist/chunk-753VJIUP.js.map +1 -0
  14. package/dist/{chunk-ZIVPHYRG.js → chunk-7QCUX4TK.js} +4 -4
  15. package/dist/chunk-7QCUX4TK.js.map +1 -0
  16. package/dist/chunk-AK23XSWD.js +16 -0
  17. package/dist/chunk-AK23XSWD.js.map +1 -0
  18. package/dist/{chunk-SHEGLZEP.js → chunk-AONAFOYU.js} +14 -5
  19. package/dist/{chunk-SHEGLZEP.js.map → chunk-AONAFOYU.js.map} +1 -1
  20. package/dist/chunk-DKE6QO26.js +136 -0
  21. package/dist/chunk-DKE6QO26.js.map +1 -0
  22. package/dist/{chunk-H23KEFBA.js → chunk-DUBU3BB3.js} +30 -1
  23. package/dist/chunk-DUBU3BB3.js.map +1 -0
  24. package/dist/{chunk-CDKG4TFA.js → chunk-GKH3LJ7N.js} +1 -1
  25. package/dist/chunk-GKH3LJ7N.js.map +1 -0
  26. package/dist/{chunk-OECZPRPW.js → chunk-GSIPUNJL.js} +49 -9
  27. package/dist/chunk-GSIPUNJL.js.map +1 -0
  28. package/dist/{chunk-DD4SKCNO.js → chunk-LCC7HNFP.js} +12 -1
  29. package/dist/chunk-LCC7HNFP.js.map +1 -0
  30. package/dist/{chunk-2PWMUVC7.js → chunk-MBH6IDSM.js} +480 -12
  31. package/dist/chunk-MBH6IDSM.js.map +1 -0
  32. package/dist/{chunk-QCHKWIRS.js → chunk-NMXYENH4.js} +10 -10
  33. package/dist/{chunk-QCHKWIRS.js.map → chunk-NMXYENH4.js.map} +1 -1
  34. package/dist/chunk-NO4M6PO5.js +101 -0
  35. package/dist/chunk-NO4M6PO5.js.map +1 -0
  36. package/dist/{chunk-TBMKJ26R.js → chunk-NW6U45HX.js} +3 -1
  37. package/dist/chunk-NW6U45HX.js.map +1 -0
  38. package/dist/{chunk-REX5YPHX.js → chunk-RJ5I2VO3.js} +74 -19
  39. package/dist/chunk-RJ5I2VO3.js.map +1 -0
  40. package/dist/{chunk-QEKVYG5F.js → chunk-RL4VKLBX.js} +1 -1
  41. package/dist/{chunk-QEKVYG5F.js.map → chunk-RL4VKLBX.js.map} +1 -1
  42. package/dist/chunk-XH2IZVLT.js +62 -0
  43. package/dist/chunk-XH2IZVLT.js.map +1 -0
  44. package/dist/{chunk-4DIQHASK.js → chunk-Z5ILULF2.js} +26 -7
  45. package/dist/chunk-Z5ILULF2.js.map +1 -0
  46. package/dist/cli/quickdraw-docs.js +5 -3
  47. package/dist/cli/quickdraw-docs.js.map +1 -1
  48. package/dist/client/index.d.ts +30 -18
  49. package/dist/client/index.js +8 -6
  50. package/dist/client/index.js.map +1 -1
  51. package/dist/{clientTypes-Bc-G8n-U.d.ts → clientTypes-hWcPUVS9.d.ts} +3 -3
  52. package/dist/{connection-Cr1BU07W.d.ts → connection-CgijdGpT.d.ts} +8 -4
  53. package/dist/{createServer-CP9eDHZZ.d.ts → createServer-Cp-Wk500.d.ts} +36 -14
  54. package/dist/{createTestApp-UKVGEmOf.d.ts → createTestApp-BB_PkqQ7.d.ts} +16 -10
  55. package/dist/{envelope-Cp3YcVxC.d.ts → envelope-DzWyIXjj.d.ts} +18 -4
  56. package/dist/{http-Djj2Inuu.d.ts → http-DotQ5bj8.d.ts} +68 -17
  57. package/dist/index.d.ts +7 -7
  58. package/dist/index.js +37 -13
  59. package/dist/index.js.map +1 -1
  60. package/dist/{infer-Teo1xpsd.d.ts → infer-BmoZsL9a.d.ts} +43 -10
  61. package/dist/logger-Yz93KzdV.d.ts +30 -0
  62. package/dist/{metrics-BdBu5o8G.d.ts → metrics-DW80Exve.d.ts} +2 -2
  63. package/dist/origins-kvpxnVcW.d.ts +8 -0
  64. package/dist/prisma/index.d.ts +5 -5
  65. package/dist/prisma/index.js +130 -67
  66. package/dist/prisma/index.js.map +1 -1
  67. package/dist/{rateLimit-ZMIZJUZF.js → rateLimit-5RKBFLUR.js} +2 -2
  68. package/dist/server/auth/index.d.ts +82 -33
  69. package/dist/server/auth/index.js +273 -268
  70. package/dist/server/auth/index.js.map +1 -1
  71. package/dist/server/express/index.d.ts +9 -2
  72. package/dist/server/express/index.js +1 -1
  73. package/dist/server/index.d.ts +147 -67
  74. package/dist/server/index.js +520 -381
  75. package/dist/server/index.js.map +1 -1
  76. package/dist/server/mcp/index.d.ts +63 -17
  77. package/dist/server/mcp/index.js +256 -54
  78. package/dist/server/mcp/index.js.map +1 -1
  79. package/dist/server/otel.d.ts +3 -3
  80. package/dist/{sharing-DVa8W-yR.d.ts → sharing-UDLn1049.d.ts} +3 -3
  81. package/dist/testing/client.d.ts +14 -14
  82. package/dist/testing/client.js +9 -7
  83. package/dist/testing/client.js.map +1 -1
  84. package/dist/testing/index.d.ts +12 -12
  85. package/dist/testing/index.js +21 -12
  86. package/dist/testing/index.js.map +1 -1
  87. package/dist/testing/mock.d.ts +5 -5
  88. package/dist/testing/mock.js +8 -6
  89. package/dist/{types-Cu0vmx7c.d.ts → types-CYliTP65.d.ts} +1 -1
  90. package/dist/utils/index.d.ts +1 -1
  91. package/package.json +1 -1
  92. package/dist/chunk-2PWMUVC7.js.map +0 -1
  93. package/dist/chunk-4DIQHASK.js.map +0 -1
  94. package/dist/chunk-CDKG4TFA.js.map +0 -1
  95. package/dist/chunk-DD4SKCNO.js.map +0 -1
  96. package/dist/chunk-H23KEFBA.js.map +0 -1
  97. package/dist/chunk-OECZPRPW.js.map +0 -1
  98. package/dist/chunk-REX5YPHX.js.map +0 -1
  99. package/dist/chunk-RT6OILOI.js.map +0 -1
  100. package/dist/chunk-RZTP4IEW.js.map +0 -1
  101. package/dist/chunk-TBMKJ26R.js.map +0 -1
  102. package/dist/chunk-VSQBHPWH.js.map +0 -1
  103. package/dist/chunk-YSWH776Y.js +0 -13
  104. package/dist/chunk-YSWH776Y.js.map +0 -1
  105. package/dist/chunk-ZIVPHYRG.js.map +0 -1
  106. package/dist/logger-BY28acW-.d.ts +0 -16
  107. /package/dist/{rateLimit-ZMIZJUZF.js.map → rateLimit-5RKBFLUR.js.map} +0 -0
@@ -1,14 +1,15 @@
1
- import { A as AnyService, d as Caller } from '../../caller-CXTFswBO.js';
1
+ import { A as AnyService, d as Caller } from '../../caller-BT-Mv4bD.js';
2
2
  import { IncomingHttpHeaders, IncomingMessage } from 'node:http';
3
- import { ap as MethodName, d as StandardSchemaV1, c as InferOutput } from '../../infer-Teo1xpsd.js';
3
+ import { ap as MethodName, d as StandardSchemaV1, c as InferOutput } from '../../infer-BmoZsL9a.js';
4
4
  import { Q as QuickdrawError, a as ErrorCode } from '../../errors-S-bxEIBC.js';
5
- import { P as PrincipalOfServices, C as ContractOfServices, D as Dispatcher, H as HttpRouter } from '../../http-Djj2Inuu.js';
6
- import { a as McpContext, b as McpContextOf, M as MaybePromise } from '../../types-Cu0vmx7c.js';
7
- import { L as Logger } from '../../logger-BY28acW-.js';
8
- import { A as AuthenticateResult } from '../../auth-BhWqdyb-.js';
5
+ import { P as PrincipalOfServices, C as ContractOfServices, D as Dispatcher, H as HttpRouter } from '../../http-DotQ5bj8.js';
6
+ import { a as McpContext, b as McpContextOf, M as MaybePromise } from '../../types-CYliTP65.js';
7
+ import { L as Logger } from '../../logger-Yz93KzdV.js';
8
+ import { A as AuthenticateResult } from '../../auth-DXiSJZaf.js';
9
9
  import { Readable, Writable } from 'node:stream';
10
- import '../../envelope-Cp3YcVxC.js';
11
- import '../../metrics-BdBu5o8G.js';
10
+ import { A as AllowedOrigin } from '../../origins-kvpxnVcW.js';
11
+ import '../../envelope-DzWyIXjj.js';
12
+ import '../../metrics-DW80Exve.js';
12
13
  import 'socket.io';
13
14
 
14
15
  /** The JSON Schema of a tool's arguments. MCP requires an object schema. */
@@ -55,7 +56,12 @@ interface McpStdioRequest {
55
56
  /** The session's id. Every call of the server carries the same one. */
56
57
  readonly sessionId: string;
57
58
  }
58
- /** What one request to the MCP HTTP routes stands for. Each request is a session of its own. */
59
+ /**
60
+ * What one request to the MCP HTTP routes stands for. Each request is a
61
+ * session of its own, and its queries share its caller's concurrency lane:
62
+ * its bearer token's, its user's without a token, or its address's when
63
+ * anonymous.
64
+ */
59
65
  interface McpHttpRequest {
60
66
  readonly transport: "http";
61
67
  readonly sessionId: string;
@@ -176,6 +182,14 @@ interface McpCustomTool<S extends readonly AnyService[] = readonly AnyService[],
176
182
  * `"public"` lets anonymous callers in, with `principal: null`.
177
183
  */
178
184
  readonly access?: McpToolAccess;
185
+ /**
186
+ * The kinds of principal (`principal.kind`) that may call the tool, within
187
+ * the ones the app's `initQuickdraw({ kinds })` admits, which it admits by
188
+ * default: a caller of another kind gets `FORBIDDEN` before the arguments
189
+ * are read or the handler runs. A `"public"` tool takes none of its own (a
190
+ * caller of a refused kind could call it signed out).
191
+ */
192
+ readonly kinds?: readonly NonNullable<PrincipalOfServices<S>["kind"]>[];
179
193
  /**
180
194
  * The tool's arguments. A Standard Schema that can describe itself as JSON
181
195
  * Schema (Zod 4.2 or later) validates them before the handler runs; a JSON
@@ -219,7 +233,13 @@ interface McpRegistryOptions<S extends readonly AnyService[], Tools extends read
219
233
  * anonymous caller, who may call `"public"` methods and `"public"` custom
220
234
  * tools only. A stdio server usually reads a token from its environment;
221
235
  * an HTTP request carries one as a bearer token. Throwing fails the call
222
- * with `UNAUTHENTICATED`. Without it every call is anonymous.
236
+ * with `UNAUTHENTICATED`: throw `QuickdrawError("UNAUTHENTICATED")` (or
237
+ * `FORBIDDEN`) to refuse a token, which is logged at debug, and never put
238
+ * the token in a message; anything else thrown is logged as an error.
239
+ * Without it every call is anonymous. An HTTP request's queries share the
240
+ * lane of its bearer token: a hook that authenticates by anything else (a
241
+ * cookie, an API key header) must refuse a request whose bearer token it
242
+ * does not verify, or a caller makes a lane per made-up token.
223
243
  */
224
244
  readonly principal?: (request: McpRequest) => MaybePromise<AuthenticateResult<PrincipalOfServices<S>>>;
225
245
  /**
@@ -237,10 +257,15 @@ interface McpRegistryOptions<S extends readonly AnyService[], Tools extends read
237
257
  * before the method runs when the caller is anonymous (`UNAUTHENTICATED`),
238
258
  * or when the binder returns `undefined` or `null` or the caller sent the
239
259
  * argument with another value (`FORBIDDEN`); a binder that throws fails it
240
- * with `INTERNAL`, or with the code of a `QuickdrawError`. Custom tools are
241
- * never bound. Binding is not access control: the same principal reaches
242
- * the method over a socket or HTTP with nothing bound, so the method's
243
- * access must still confine it.
260
+ * with `INTERNAL`, or with the code of a `QuickdrawError`. A call of any
261
+ * other method's tool that sends a bound argument is refused (`FORBIDDEN`).
262
+ * An input that is a `$ref` to an object counts as that object, and a
263
+ * bound key anywhere else in an input (in a union's or an intersection's
264
+ * branch, or an input that is no object) fails when the registry is built.
265
+ * Only top-level arguments are bound. Custom tools are never bound.
266
+ * Binding is not access control: the same principal reaches the method
267
+ * over a socket or HTTP with nothing bound, so the method's access must
268
+ * still confine it.
244
269
  */
245
270
  readonly bind?: Readonly<Record<string, McpBinder<S>>>;
246
271
  /** The app's own tools, listed after the generated ones. */
@@ -282,9 +307,14 @@ interface McpRegistry {
282
307
  * `customTools`, served by `dispatcher`. Throws at once when a method's
283
308
  * input schema cannot describe itself as JSON Schema (use Zod 4.2 or later
284
309
  * for it, or `exclude` the method), when two tools share a name, when the
285
- * dispatcher does not serve one of the services, or when an argument of
286
- * `bind` is in no tool's object input or is in an input that is not an
287
- * object.
310
+ * dispatcher does not serve one of the services, when an argument of
311
+ * `bind` is in no tool's object input or may be anywhere in an input where
312
+ * it cannot be filled (an input that is not an object schema, or a branch
313
+ * of a union or an intersection, behind a `$ref` too), or when custom tools
314
+ * are served beside the services of apps that admit no kind in common.
315
+ * Every call runs in its caller's concurrency lane: a stdio session, or on
316
+ * HTTP its bearer token, its user without a token, or its address when
317
+ * anonymous.
288
318
  *
289
319
  * @example
290
320
  * const registry = createMcpRegistry({
@@ -381,6 +411,22 @@ interface McpHttpRouterOptions {
381
411
  readonly maxBodyBytes?: number;
382
412
  /** Default: the console. */
383
413
  readonly logger?: Logger;
414
+ /**
415
+ * The web pages that may call the routes, by origin: exact origins
416
+ * (`"https://app.example.com"`) or patterns anchored with `^` and `$`, as
417
+ * `socketAuth` takes them. A request whose `Origin` is not listed gets 403;
418
+ * a request without one (an MCP client, a script) is not a page's and
419
+ * passes. Default: none, so no page may call them.
420
+ */
421
+ readonly allowedOrigins?: readonly AllowedOrigin[];
422
+ /**
423
+ * The `Host` names the routes answer for (`"localhost"`, `"127.0.0.1"`,
424
+ * `"mcp.example.com:8443"`; an entry without a port matches any port): a
425
+ * request for another gets 403. Set it for a server on a developer's
426
+ * machine or a LAN, where a page could reach it through DNS rebinding.
427
+ * Default: any host.
428
+ */
429
+ readonly allowedHosts?: readonly string[];
384
430
  }
385
431
  /** What `invoke` answers: 4.1's `{ success, data }`, or a failure's message, code and data. */
386
432
  type McpHttpReply = {
@@ -3,20 +3,32 @@ import {
3
3
  } from "../../chunk-XNLWBZIG.js";
4
4
  import {
5
5
  NEVER_ABORTED,
6
+ checkKind,
7
+ inputKeys,
6
8
  lazyMembers,
9
+ narrowKinds,
7
10
  parseInput,
11
+ pointerToken,
12
+ referenced,
13
+ runtimeOf,
8
14
  toQuickdrawError
9
- } from "../../chunk-2PWMUVC7.js";
15
+ } from "../../chunk-MBH6IDSM.js";
10
16
  import {
11
17
  describeError
12
- } from "../../chunk-DD4SKCNO.js";
18
+ } from "../../chunk-LCC7HNFP.js";
13
19
  import {
20
+ originAllowlist
21
+ } from "../../chunk-NO4M6PO5.js";
22
+ import {
23
+ isForbiddenCredential,
14
24
  isJsonRequest,
25
+ isRefusal,
15
26
  readJsonInput,
16
27
  toPrincipal,
17
28
  unreadable
18
- } from "../../chunk-REX5YPHX.js";
19
- import "../../chunk-H23KEFBA.js";
29
+ } from "../../chunk-RJ5I2VO3.js";
30
+ import "../../chunk-DUBU3BB3.js";
31
+ import "../../chunk-AK23XSWD.js";
20
32
  import {
21
33
  hasJsonSchema,
22
34
  isStandardSchema
@@ -24,7 +36,7 @@ import {
24
36
  import "../../chunk-Z2LZ7IZ7.js";
25
37
  import {
26
38
  consoleLogger
27
- } from "../../chunk-YSWH776Y.js";
39
+ } from "../../chunk-DKE6QO26.js";
28
40
  import {
29
41
  INTERNAL_MESSAGE,
30
42
  QuickdrawError,
@@ -72,20 +84,72 @@ function toolInputOf(label, schema, fail, instead = `leave ${label} out with exc
72
84
  `the input schema of ${label} cannot be written as JSON Schema (${json.message}): change that schema, or ${instead}`
73
85
  );
74
86
  }
75
- if (json.type === "object") {
76
- return { schema: json, mode: "object" };
87
+ const described = referenced(json, json);
88
+ if (described?.type === "object") {
89
+ return { schema: inlined(json, described), mode: "object" };
77
90
  }
78
- const { $schema: dialect, ...input } = json;
91
+ const { $schema: dialect, definitions, $defs, ...input } = json;
79
92
  return {
80
93
  schema: {
81
94
  ...dialect === void 0 ? {} : { $schema: dialect },
82
95
  type: "object",
83
- properties: { input },
84
- ...acceptsUndefined(schema) ? {} : { required: ["input"] }
96
+ properties: { input: rerooted(input) },
97
+ ...acceptsUndefined(schema) ? {} : { required: ["input"] },
98
+ ...definitions === void 0 ? {} : { definitions: rerooted(definitions) },
99
+ ...$defs === void 0 ? {} : { $defs: rerooted($defs) }
85
100
  },
86
101
  mode: "wrapped"
87
102
  };
88
103
  }
104
+ var WRAPPED_INPUT = "#/properties/input";
105
+ function rerooted(value) {
106
+ if (Array.isArray(value)) {
107
+ return value.map(rerooted);
108
+ }
109
+ if (!isRecord(value)) {
110
+ return value;
111
+ }
112
+ return Object.fromEntries(
113
+ Object.entries(value).map(([key, item]) => {
114
+ const intoRoot = key === "$ref" && typeof item === "string" && (item === "#" || item.startsWith("#/") && !/^#\/(?:definitions|\$defs)\//u.test(item));
115
+ return [key, intoRoot ? `${WRAPPED_INPUT}${item.slice(1)}` : rerooted(item)];
116
+ })
117
+ );
118
+ }
119
+ function refersTo(value, ref) {
120
+ if (Array.isArray(value)) {
121
+ return value.some((item) => refersTo(item, ref));
122
+ }
123
+ if (!isRecord(value)) {
124
+ return false;
125
+ }
126
+ return value.$ref === ref || Object.values(value).some((item) => refersTo(item, ref));
127
+ }
128
+ function inlined(json, target) {
129
+ if (target === json) {
130
+ return json;
131
+ }
132
+ const ref = String(json.$ref);
133
+ const { $schema: dialect } = json;
134
+ const schema = {
135
+ ...dialect === void 0 ? {} : { $schema: dialect },
136
+ ...target
137
+ };
138
+ for (const keyword of ["definitions", "$defs"]) {
139
+ const defs = json[keyword];
140
+ if (!isRecord(defs)) {
141
+ continue;
142
+ }
143
+ const others = Object.fromEntries(
144
+ Object.entries(defs).filter(([name]) => `#/${keyword}/${pointerToken(name)}` !== ref)
145
+ );
146
+ const kept = refersTo({ ...schema, [keyword]: others }, ref) ? defs : others;
147
+ if (Object.keys(kept).length > 0) {
148
+ schema[keyword] = kept;
149
+ }
150
+ }
151
+ return schema;
152
+ }
89
153
  function inputOf(mode, args) {
90
154
  if (mode === "object") {
91
155
  return args ?? {};
@@ -139,35 +203,19 @@ function describeMethod(service, method, kind, text) {
139
203
  function propertiesOf(schema) {
140
204
  return isRecord(schema) && isRecord(schema.properties) ? Object.keys(schema.properties) : [];
141
205
  }
142
- function branchProperties(schema, found = /* @__PURE__ */ new Set()) {
143
- if (!isRecord(schema)) {
144
- return found;
145
- }
146
- for (const keyword of ["anyOf", "oneOf", "allOf"]) {
147
- const branches = schema[keyword];
148
- for (const branch of Array.isArray(branches) ? branches : []) {
149
- for (const key of propertiesOf(branch)) {
150
- found.add(key);
151
- }
152
- branchProperties(branch, found);
153
- }
154
- }
155
- return found;
156
- }
157
- function boundArguments(label, input, bind, fail) {
158
- if (input.mode === "object") {
159
- const properties = propertiesOf(input.schema);
160
- return bind.filter((key) => properties.includes(key));
206
+ function boundArguments(label, input, schema, bind, fail) {
207
+ if (bind.length === 0) {
208
+ return [];
161
209
  }
162
- const wrapped = input.schema.properties?.input;
163
- const unbindable = branchProperties(wrapped, new Set(propertiesOf(wrapped)));
164
- const misplaced = bind.find((key) => unbindable.has(key));
210
+ const bindable = input.mode === "object" ? propertiesOf(input.schema) : [];
211
+ const keys = inputKeys(schema) ?? [];
212
+ const misplaced = bind.find((key) => keys.includes(key) && !bindable.includes(key));
165
213
  if (misplaced !== void 0) {
166
214
  fail(
167
- `bind.${misplaced} names a property of the input of ${label}, which is not an object: bind fills properties of object inputs only; leave ${label} out with exclude, or make its input an object`
215
+ `bind.${misplaced} names a property of the input of ${label}, which is not an object: bind fills properties of object inputs only (an object schema, or one with .meta({ id }); not a union, an intersection, or a schema made from an object, as .optional() or .refine() make); leave ${label} out with exclude, or make its input an object`
168
216
  );
169
217
  }
170
- return [];
218
+ return bind.filter((key) => bindable.includes(key));
171
219
  }
172
220
  function withoutArguments(schema, bound) {
173
221
  const kept = (key) => !bound.includes(key);
@@ -194,7 +242,7 @@ function planMethodTools(services, options, fail, bind = []) {
194
242
  }
195
243
  const label = `${service.name}.${method}`;
196
244
  const input = toolInputOf(label, def.input, fail);
197
- const bound = boundArguments(label, input, bind, fail);
245
+ const bound = boundArguments(label, input, def.input, bind, fail);
198
246
  const tool = {
199
247
  name: toolName(service.name, method),
200
248
  description: describeMethod(service.name, method, def.kind, def.describe),
@@ -228,6 +276,46 @@ function describeTools(services, options = {}) {
228
276
  return planned.map((entry2) => entry2.tool);
229
277
  }
230
278
 
279
+ // src/server/mcp/lanes.ts
280
+ import { createHash } from "crypto";
281
+ import { isIPv6 } from "net";
282
+ function groupsOf(address) {
283
+ const written = new URL(`http://[${address}]/`).hostname.slice(1, -1);
284
+ const [head = "", tail = ""] = written.split("::");
285
+ const parse2 = (part) => part === "" ? [] : part.split(":").map((group) => Number.parseInt(group, 16));
286
+ const before = parse2(head);
287
+ const after = parse2(tail);
288
+ return [...before, ...new Array(8 - before.length - after.length).fill(0), ...after];
289
+ }
290
+ function addressLane(ip) {
291
+ const address = ip.split("%", 1)[0] ?? ip;
292
+ if (!isIPv6(address)) {
293
+ return ip;
294
+ }
295
+ const [a = 0, b = 0, c = 0, d = 0, e = 0, f = 0, g = 0, h = 0] = groupsOf(address);
296
+ if (a === 0 && b === 0 && c === 0 && d === 0 && e === 0 && f === 65535) {
297
+ return [g >> 8, g & 255, h >> 8, h & 255].join(".");
298
+ }
299
+ return `${[a, b, c, d & 65280].map((group) => group.toString(16)).join(":")}::/56`;
300
+ }
301
+ function addressOf(request) {
302
+ const { ip } = request.req;
303
+ return typeof ip === "string" && ip !== "" ? ip : request.req.socket?.remoteAddress ?? "unknown";
304
+ }
305
+ function laneOf(request, principal) {
306
+ if (request.transport === "stdio") {
307
+ return `mcp:${request.sessionId}`;
308
+ }
309
+ if (principal === null) {
310
+ return `mcp:http:address:${addressLane(addressOf(request))}`;
311
+ }
312
+ if (request.token === null) {
313
+ return `mcp:http:user:${principal.userId}`;
314
+ }
315
+ const hash = createHash("sha256").update(request.token).digest("base64url").slice(0, 22);
316
+ return `mcp:http:token:${hash}`;
317
+ }
318
+
231
319
  // src/server/mcp/session.ts
232
320
  var CATEGORY = "quickdraw.mcp";
233
321
  async function principalOf(settings, request) {
@@ -237,7 +325,8 @@ async function principalOf(settings, request) {
237
325
  try {
238
326
  return toPrincipal(await settings.principal(request));
239
327
  } catch (error) {
240
- settings.logger.error("MCP authentication failed", {
328
+ const refused = isRefusal(error) || isForbiddenCredential(error);
329
+ settings.logger[refused ? "debug" : "error"]("MCP authentication failed", {
241
330
  category: CATEGORY,
242
331
  transport: request.transport,
243
332
  error: describeError(error)
@@ -261,7 +350,7 @@ async function contextOf(settings, request, principal) {
261
350
  async function openSession(settings, request) {
262
351
  const principal = await principalOf(settings, request);
263
352
  const mcp = await contextOf(settings, request, principal);
264
- return { principal, mcp, connectionId: `mcp:${request.sessionId}` };
353
+ return { principal, mcp, connectionId: laneOf(request, principal) };
265
354
  }
266
355
  function failure(settings, error, tool) {
267
356
  const converted = toQuickdrawError(error);
@@ -342,7 +431,26 @@ function accessOf(owner, access, fail) {
342
431
  }
343
432
  return access;
344
433
  }
345
- function checkCustomTool(value, index, fail) {
434
+ function appKindsOf(services) {
435
+ let kinds;
436
+ for (const service of services) {
437
+ const own = runtimeOf(service)?.kinds;
438
+ if (own !== void 0) {
439
+ kinds = kinds === void 0 ? own : kinds.filter((kind) => own.includes(kind));
440
+ }
441
+ }
442
+ return kinds;
443
+ }
444
+ function kindsOf(owner, value, access, appKinds, fail) {
445
+ if (value.kinds !== void 0 && access === "public") {
446
+ fail(
447
+ `${owner}: a "public" tool takes no kinds of its own, since a caller of a refused kind could call it signed out; give it access "authenticated"`
448
+ );
449
+ }
450
+ const above = appKinds === void 0 ? void 0 : { kinds: appKinds, by: "initQuickdraw" };
451
+ return narrowKinds(value.kinds, above, (message) => fail(`${owner}: ${message}`));
452
+ }
453
+ function checkCustomTool(value, index, appKinds, fail) {
346
454
  if (!isRecord2(value)) {
347
455
  fail(`customTools[${index}] must be { name, description, inputSchema, handler }`);
348
456
  }
@@ -361,6 +469,7 @@ function checkCustomTool(value, index, fail) {
361
469
  fail(`${owner}: annotations must be an object of hints`);
362
470
  }
363
471
  const access = accessOf(owner, value.access, fail);
472
+ const kinds = kindsOf(owner, value, access, appKinds, fail);
364
473
  const input = argumentsOf(owner, inputSchema, fail);
365
474
  const tool = {
366
475
  name,
@@ -371,19 +480,26 @@ function checkCustomTool(value, index, fail) {
371
480
  return {
372
481
  tool: Object.freeze(tool),
373
482
  access,
483
+ kinds,
374
484
  mode: input.mode,
375
485
  schema: isStandardSchema(inputSchema) ? inputSchema : void 0,
376
486
  handler
377
487
  };
378
488
  }
379
- function checkCustomTools(value, fail) {
489
+ function checkCustomTools(value, fail, services = []) {
380
490
  if (value === void 0) {
381
491
  return [];
382
492
  }
383
493
  if (!Array.isArray(value)) {
384
494
  fail("customTools must be an array of { name, description, inputSchema, handler }");
385
495
  }
386
- return value.map((tool, index) => checkCustomTool(tool, index, fail));
496
+ const appKinds = appKindsOf(services);
497
+ if (appKinds?.length === 0 && value.length > 0) {
498
+ fail(
499
+ "customTools: the served services' apps admit no kind in common (initQuickdraw({ kinds })), so no principal could call a custom tool: serve custom tools beside the services of one app, or of apps that admit a kind in common"
500
+ );
501
+ }
502
+ return value.map((tool, index) => checkCustomTool(tool, index, appKinds, fail));
387
503
  }
388
504
  async function runCustomTool(settings, custom, args, session, options) {
389
505
  const { name } = custom.tool;
@@ -392,6 +508,7 @@ async function runCustomTool(settings, custom, args, session, options) {
392
508
  if (custom.access === "authenticated" && session.principal === null) {
393
509
  throw new QuickdrawError("UNAUTHENTICATED", "Authentication required");
394
510
  }
511
+ checkKind(custom.kinds, session.principal, `The tool "${name}"`);
395
512
  const input = inputOf(custom.mode, args);
396
513
  const parsed = custom.schema === void 0 ? input : await parseInput(custom.schema, input, `the tool "${name}"`);
397
514
  const data = await custom.handler({
@@ -475,7 +592,7 @@ function resolveRegistry(options) {
475
592
  const binders = checkBinders(options.bind, fail);
476
593
  const methodTools = planMethodTools(options.services, options, fail, [...binders.keys()]);
477
594
  checkBound(binders, methodTools, fail);
478
- const customTools = checkCustomTools(options.customTools, fail);
595
+ const customTools = checkCustomTools(options.customTools, fail, options.services);
479
596
  const entries = [
480
597
  ...methodTools.map((tool) => ({ kind: "method", ...tool })),
481
598
  ...customTools.map((tool) => ({ kind: "custom", ...tool }))
@@ -545,6 +662,16 @@ async function bindInput(settings, tool, input, session, request) {
545
662
  return { ...given, ...Object.fromEntries(filled) };
546
663
  }
547
664
  async function dispatchMethod(settings, tool, input, session, options) {
665
+ const stray = settings.binders.size === 0 || !isRecord3(input) || Array.isArray(input) ? void 0 : [...settings.binders.keys()].find(
666
+ (key) => !tool.bound.includes(key) && Object.hasOwn(input, key)
667
+ );
668
+ if (stray !== void 0) {
669
+ const error = new QuickdrawError(
670
+ "FORBIDDEN",
671
+ `"${stray}" is bound to the caller; ${tool.service}.${tool.method} does not take it from the agent`
672
+ );
673
+ return settle(settings, options, failure(settings, error, tool.tool.name));
674
+ }
548
675
  let bound = input;
549
676
  if (tool.bound.length > 0) {
550
677
  try {
@@ -894,8 +1021,81 @@ async function bootstrapMcpServer(serverModule) {
894
1021
  }
895
1022
  }
896
1023
 
897
- // src/server/mcp/http.ts
1024
+ // src/server/mcp/httpRequest.ts
898
1025
  import { randomUUID as randomUUID2 } from "crypto";
1026
+ function requestOf(req) {
1027
+ const header = req.headers.authorization;
1028
+ const token = typeof header === "string" && header.startsWith("Bearer ") ? header.slice(7) : "";
1029
+ return {
1030
+ transport: "http",
1031
+ sessionId: randomUUID2(),
1032
+ token: token.trim() === "" ? null : token.trim(),
1033
+ headers: req.headers,
1034
+ req
1035
+ };
1036
+ }
1037
+ function hostOf(value) {
1038
+ if (/\s/u.test(value)) {
1039
+ return void 0;
1040
+ }
1041
+ const host = !value.startsWith("[") && value.split(":").length > 2 ? `[${value}]` : value;
1042
+ let url;
1043
+ try {
1044
+ url = new URL(`http://${host}`);
1045
+ } catch {
1046
+ return void 0;
1047
+ }
1048
+ const extra = url.username + url.password + url.search + url.hash;
1049
+ if (url.hostname === "" || extra !== "" || url.pathname !== "/") {
1050
+ return void 0;
1051
+ }
1052
+ const written = !host.endsWith("]") && /:\d+$/u.test(host);
1053
+ return { hostname: url.hostname, port: written ? url.port || "80" : void 0 };
1054
+ }
1055
+ function checkHosts(hosts) {
1056
+ if (hosts === void 0) {
1057
+ return void 0;
1058
+ }
1059
+ const parsed = Array.isArray(hosts) ? hosts.map((host) => typeof host === "string" ? hostOf(host) : void 0) : [];
1060
+ if (parsed.length === 0 || parsed.includes(void 0)) {
1061
+ throw new TypeError(
1062
+ 'createMcpHttpRouter: allowedHosts must list host names, such as "localhost" or "mcp.example.com:8443"'
1063
+ );
1064
+ }
1065
+ return Object.freeze(parsed);
1066
+ }
1067
+ function hostAllowed(hosts, header) {
1068
+ const host = header === void 0 ? void 0 : hostOf(header);
1069
+ if (host === void 0) {
1070
+ return false;
1071
+ }
1072
+ const portMatches = (port) => host.port === void 0 ? port === "80" || port === "443" : port === host.port;
1073
+ return hosts.some(
1074
+ (entry2) => entry2.hostname === host.hostname && (entry2.port === void 0 || portMatches(entry2.port))
1075
+ );
1076
+ }
1077
+ function requestGate(allowedOrigins, allowedHosts) {
1078
+ const origins = originAllowlist(
1079
+ allowedOrigins ?? [],
1080
+ "createMcpHttpRouter",
1081
+ true
1082
+ );
1083
+ const hosts = checkHosts(allowedHosts);
1084
+ return Object.freeze({
1085
+ refusal(req) {
1086
+ const { origin, host } = req.headers;
1087
+ if (origin !== void 0 && origins.allowed(origin) === null) {
1088
+ return "The MCP routes refuse requests from web pages unless allowedOrigins lists their origin";
1089
+ }
1090
+ if (hosts !== void 0 && !hostAllowed(hosts, host)) {
1091
+ return "The MCP routes do not answer for this host";
1092
+ }
1093
+ return void 0;
1094
+ }
1095
+ });
1096
+ }
1097
+
1098
+ // src/server/mcp/http.ts
899
1099
  var DEFAULT_MAX_BODY_BYTES = 1048576;
900
1100
  var CATEGORY3 = "quickdraw.mcp";
901
1101
  var NOT_JSON = "Send the call as a JSON body with Content-Type: application/json";
@@ -922,6 +1122,8 @@ function send(res, status, body) {
922
1122
  res.statusCode = status;
923
1123
  res.setHeader("content-type", "application/json; charset=utf-8");
924
1124
  res.setHeader("content-length", bytes);
1125
+ res.setHeader("cache-control", "no-store");
1126
+ res.setHeader("x-content-type-options", "nosniff");
925
1127
  res.end(body);
926
1128
  return bytes;
927
1129
  }
@@ -962,17 +1164,6 @@ function targetOf(body) {
962
1164
  }
963
1165
  return void 0;
964
1166
  }
965
- function requestOf(req) {
966
- const header = req.headers.authorization;
967
- const token = typeof header === "string" && header.startsWith("Bearer ") ? header.slice(7) : "";
968
- return {
969
- transport: "http",
970
- sessionId: randomUUID2(),
971
- token: token.trim() === "" ? null : token.trim(),
972
- headers: req.headers,
973
- req
974
- };
975
- }
976
1167
  async function invoke(settings, req, res) {
977
1168
  const controller = new AbortController();
978
1169
  res.once("close", () => {
@@ -1022,7 +1213,8 @@ function resolveSettings(options) {
1022
1213
  registry: options.registry,
1023
1214
  prefix: normalizePath(options.path ?? "/mcp"),
1024
1215
  maxBodyBytes,
1025
- logger: options.logger ?? consoleLogger
1216
+ logger: options.logger ?? consoleLogger,
1217
+ gate: requestGate(options.allowedOrigins, options.allowedHosts)
1026
1218
  };
1027
1219
  }
1028
1220
  function createMcpHttpRouter(options) {
@@ -1037,6 +1229,16 @@ function createMcpHttpRouter(options) {
1037
1229
  }
1038
1230
  return;
1039
1231
  }
1232
+ const refusal = settings.gate.refusal(req);
1233
+ if (refusal !== void 0) {
1234
+ settings.logger.debug("An MCP HTTP request was refused", {
1235
+ category: CATEGORY3,
1236
+ origin: req.headers.origin,
1237
+ host: req.headers.host
1238
+ });
1239
+ sendResult(settings, res, failure2(new QuickdrawError("FORBIDDEN", refusal)));
1240
+ return;
1241
+ }
1040
1242
  if (route === "tools") {
1041
1243
  send(res, 200, JSON.stringify({ tools: settings.registry.tools }));
1042
1244
  return;