@daloyjs/core 1.0.0-beta.5 → 1.0.0-beta.7

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 (100) hide show
  1. package/README.md +2 -1
  2. package/dist/adapters/bun.d.ts +13 -1
  3. package/dist/adapters/bun.js +8 -1
  4. package/dist/adapters/cloudflare.d.ts +7 -1
  5. package/dist/adapters/cloudflare.js +6 -1
  6. package/dist/adapters/deno.d.ts +11 -1
  7. package/dist/adapters/deno.js +8 -1
  8. package/dist/adapters/fastly.d.ts +12 -2
  9. package/dist/adapters/fastly.js +12 -2
  10. package/dist/adapters/lambda.d.ts +37 -1
  11. package/dist/adapters/lambda.js +6 -1
  12. package/dist/adapters/node.d.ts +12 -1
  13. package/dist/adapters/node.js +7 -1
  14. package/dist/adapters/vercel.d.ts +13 -1
  15. package/dist/adapters/vercel.js +12 -1
  16. package/dist/app.d.ts +67 -17
  17. package/dist/app.js +97 -31
  18. package/dist/banner.d.ts +6 -0
  19. package/dist/banner.js +6 -0
  20. package/dist/cli.d.ts +35 -0
  21. package/dist/cli.js +23 -1
  22. package/dist/combine.d.ts +8 -0
  23. package/dist/combine.js +8 -0
  24. package/dist/compression.d.ts +3 -0
  25. package/dist/compression.js +3 -0
  26. package/dist/config.d.ts +4 -0
  27. package/dist/config.js +4 -0
  28. package/dist/conn-info.d.ts +35 -2
  29. package/dist/conn-info.js +35 -2
  30. package/dist/contract.d.ts +2 -0
  31. package/dist/contract.js +2 -0
  32. package/dist/cookie.d.ts +12 -0
  33. package/dist/cookie.js +12 -0
  34. package/dist/dependency.d.ts +4 -0
  35. package/dist/dependency.js +3 -0
  36. package/dist/discriminator.d.ts +13 -0
  37. package/dist/discriminator.js +23 -1
  38. package/dist/docs.d.ts +78 -0
  39. package/dist/docs.js +21 -0
  40. package/dist/errors.d.ts +16 -0
  41. package/dist/errors.js +14 -0
  42. package/dist/etag.d.ts +2 -0
  43. package/dist/etag.js +2 -0
  44. package/dist/fetch-guard.d.ts +7 -0
  45. package/dist/fetch-guard.js +7 -0
  46. package/dist/fetch-resilience.d.ts +4 -0
  47. package/dist/fetch-resilience.js +4 -0
  48. package/dist/http-signatures.d.ts +32 -0
  49. package/dist/http-signatures.js +30 -0
  50. package/dist/index.d.ts +2 -0
  51. package/dist/index.js +1 -0
  52. package/dist/ip-restriction.d.ts +40 -3
  53. package/dist/ip-restriction.js +35 -3
  54. package/dist/jwk.d.ts +12 -1
  55. package/dist/jwk.js +6 -0
  56. package/dist/jwt.d.ts +14 -0
  57. package/dist/jwt.js +10 -0
  58. package/dist/load-shedding.d.ts +3 -0
  59. package/dist/load-shedding.js +3 -0
  60. package/dist/logger.d.ts +13 -0
  61. package/dist/logger.js +3 -0
  62. package/dist/mcp.d.ts +574 -0
  63. package/dist/mcp.js +623 -0
  64. package/dist/middleware.d.ts +68 -0
  65. package/dist/middleware.js +17 -0
  66. package/dist/mtls.d.ts +19 -2
  67. package/dist/mtls.js +12 -2
  68. package/dist/multipart.d.ts +42 -5
  69. package/dist/multipart.js +41 -5
  70. package/dist/openapi.d.ts +15 -9
  71. package/dist/openapi.js +6 -9
  72. package/dist/rate-limit-redis.d.ts +21 -2
  73. package/dist/rate-limit-redis.js +17 -2
  74. package/dist/safe-redirect.d.ts +6 -0
  75. package/dist/safe-redirect.js +6 -0
  76. package/dist/sbom.cdx.json +9 -9
  77. package/dist/sbom.spdx.json +5 -5
  78. package/dist/scheduler.d.ts +4 -0
  79. package/dist/schema.d.ts +25 -0
  80. package/dist/security-schemes.d.ts +50 -0
  81. package/dist/security-schemes.js +6 -0
  82. package/dist/security.d.ts +33 -0
  83. package/dist/security.js +28 -10
  84. package/dist/session.d.ts +34 -5
  85. package/dist/session.js +31 -5
  86. package/dist/streaming.d.ts +19 -0
  87. package/dist/streaming.js +16 -0
  88. package/dist/subdomains.d.ts +4 -0
  89. package/dist/subdomains.js +4 -0
  90. package/dist/time-claims.d.ts +22 -2
  91. package/dist/time-claims.js +6 -0
  92. package/dist/tracing.d.ts +12 -0
  93. package/dist/tracing.js +6 -0
  94. package/dist/types.d.ts +70 -1
  95. package/dist/waf.js +21 -1
  96. package/dist/webhook-delivery.d.ts +6 -0
  97. package/dist/webhook-delivery.js +5 -0
  98. package/dist/websocket.d.ts +137 -4
  99. package/dist/websocket.js +105 -4
  100. package/package.json +6 -1
package/dist/mcp.js ADDED
@@ -0,0 +1,623 @@
1
+ /**
2
+ * Latest MCP protocol version DaloyJS negotiates by default.
3
+ *
4
+ * @see https://modelcontextprotocol.io/specification/2025-11-25
5
+ * @since 1.0.0
6
+ */
7
+ export const MCP_PROTOCOL_VERSION = "2025-11-25";
8
+ /**
9
+ * Protocol revisions accepted by {@link createMcpHandler} unless the caller
10
+ * provides an explicit `protocolVersions` list.
11
+ *
12
+ * @since 1.0.0
13
+ */
14
+ export const MCP_PROTOCOL_VERSIONS = Object.freeze([
15
+ "2024-11-05",
16
+ "2025-03-26",
17
+ "2025-06-18",
18
+ "2025-11-25",
19
+ ]);
20
+ /**
21
+ * Default maximum accepted JSON-RPC request body for a DaloyJS MCP endpoint.
22
+ * The cap is intentionally small because MCP calls should carry parameters,
23
+ * not bulk uploads. Raise it per endpoint when a real tool needs larger input.
24
+ *
25
+ * @since 1.0.0
26
+ */
27
+ export const MCP_DEFAULT_MAX_BODY_BYTES = 1 << 18;
28
+ /**
29
+ * Protocol revision assumed when an HTTP request carries no
30
+ * `MCP-Protocol-Version` header, as required by the Streamable HTTP spec for
31
+ * backwards compatibility with pre-2025-06-18 clients.
32
+ */
33
+ const LEGACY_ASSUMED_PROTOCOL_VERSION = "2025-03-26";
34
+ const PARSE_ERROR = -32700;
35
+ const INVALID_REQUEST = -32600;
36
+ const METHOD_NOT_FOUND = -32601;
37
+ const INVALID_PARAMS = -32602;
38
+ const INTERNAL_ERROR = -32603;
39
+ /**
40
+ * JSON Schema for the JSON-RPC 2.0 envelope every MCP response uses. Exposed
41
+ * through `toJSONSchema()` so the generated OpenAPI document describes the
42
+ * `/mcp` route honestly instead of leaving it an undocumented blind spot.
43
+ */
44
+ const MCP_JSONRPC_ENVELOPE_JSON_SCHEMA = {
45
+ type: "object",
46
+ description: "JSON-RPC 2.0 envelope produced by the MCP Streamable HTTP endpoint.",
47
+ properties: {
48
+ jsonrpc: { type: "string", const: "2.0" },
49
+ id: { oneOf: [{ type: "string" }, { type: "number" }, { type: "null" }] },
50
+ result: { description: "Method result. Present on success; shape varies by MCP method." },
51
+ error: {
52
+ type: "object",
53
+ properties: {
54
+ code: { type: "integer" },
55
+ message: { type: "string" },
56
+ data: {},
57
+ },
58
+ required: ["code", "message"],
59
+ additionalProperties: false,
60
+ },
61
+ },
62
+ required: ["jsonrpc"],
63
+ };
64
+ const MCP_JSON_RESPONSE_SCHEMA = {
65
+ "~standard": {
66
+ version: 1,
67
+ vendor: "daloyjs",
68
+ // Pass-through: the MCP handler fully controls the envelope it builds, so
69
+ // re-validating (or field-stripping) it here would only burn cycles.
70
+ validate: (value) => ({ value }),
71
+ },
72
+ toJSONSchema: () => MCP_JSONRPC_ENVELOPE_JSON_SCHEMA,
73
+ };
74
+ /**
75
+ * Caller-correctable MCP tool/resource/prompt error.
76
+ *
77
+ * Throw this when the model supplied bad arguments, referenced a missing
78
+ * domain object, or otherwise made a recoverable call. Tool errors become
79
+ * `{ isError: true }` tool results; resource and prompt errors become
80
+ * JSON-RPC invalid-params errors. Unexpected errors are treated as internal
81
+ * server failures and are redacted in production.
82
+ *
83
+ * @since 1.0.0
84
+ */
85
+ export class McpToolError extends Error {
86
+ /**
87
+ * Create a recoverable MCP handler error.
88
+ *
89
+ * @param message - Safe, caller-visible explanation.
90
+ */
91
+ constructor(message) {
92
+ super(message);
93
+ this.name = "McpToolError";
94
+ }
95
+ }
96
+ function isJsonRpcId(value) {
97
+ return value === null || typeof value === "string" || typeof value === "number";
98
+ }
99
+ function jsonResponse(body, status, extraHeaders) {
100
+ return new Response(JSON.stringify(body), {
101
+ status,
102
+ headers: {
103
+ "content-type": "application/json; charset=utf-8",
104
+ "cache-control": "no-store",
105
+ ...(extraHeaders ?? {}),
106
+ },
107
+ });
108
+ }
109
+ function rpcResult(id, result, extraHeaders) {
110
+ return jsonResponse({ jsonrpc: "2.0", id, result }, 200, extraHeaders);
111
+ }
112
+ function rpcError(id, code, message, data, status, extraHeaders) {
113
+ const error = { code, message };
114
+ if (data !== undefined)
115
+ error.data = data;
116
+ return jsonResponse({ jsonrpc: "2.0", id, error }, status, extraHeaders);
117
+ }
118
+ function safeInternalErrorData(error, expose) {
119
+ if (!expose)
120
+ return undefined;
121
+ return { detail: error instanceof Error ? error.message : String(error) };
122
+ }
123
+ function asRecord(value) {
124
+ return value && typeof value === "object" && !Array.isArray(value)
125
+ ? value
126
+ : {};
127
+ }
128
+ function publicTool(tool) {
129
+ const { handler: _handler, ...rest } = tool;
130
+ return rest;
131
+ }
132
+ function publicResource(resource) {
133
+ const { read: _read, ...rest } = resource;
134
+ return rest;
135
+ }
136
+ function publicPrompt(prompt) {
137
+ const { get: _get, ...rest } = prompt;
138
+ return rest;
139
+ }
140
+ function publicResourceTemplate(template) {
141
+ const { read: _read, ...rest } = template;
142
+ return rest;
143
+ }
144
+ function normalizeToolResult(value) {
145
+ if (typeof value === "string")
146
+ return { content: [{ type: "text", text: value }] };
147
+ if (value.content && value.content.length > 0)
148
+ return value;
149
+ // Backwards compatibility: clients that predate `structuredContent` only
150
+ // read `content`, so mirror the structured payload into a text block.
151
+ const content = value.structuredContent !== undefined
152
+ ? [{ type: "text", text: JSON.stringify(value.structuredContent) }]
153
+ : [];
154
+ return { ...value, content };
155
+ }
156
+ function selectedProtocolVersion(requested, supported, preferred) {
157
+ return supported.has(requested) ? requested : preferred;
158
+ }
159
+ function escapeRegExp(literal) {
160
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
161
+ }
162
+ /**
163
+ * Compile a simple RFC 6570 level-1 URI template into a matcher. Each
164
+ * `{name}` variable matches exactly one URI segment (`[^/]+`). Operators such
165
+ * as `{+path}` or `{?query}` are rejected so the handler never advertises a
166
+ * template it cannot match.
167
+ */
168
+ function compileUriTemplate(template) {
169
+ const { uriTemplate } = template;
170
+ const variables = [];
171
+ let pattern = "";
172
+ let index = 0;
173
+ while (index < uriTemplate.length) {
174
+ const open = uriTemplate.indexOf("{", index);
175
+ if (open === -1) {
176
+ pattern += escapeRegExp(uriTemplate.slice(index));
177
+ break;
178
+ }
179
+ pattern += escapeRegExp(uriTemplate.slice(index, open));
180
+ const close = uriTemplate.indexOf("}", open);
181
+ if (close === -1) {
182
+ throw new TypeError(`MCP resource template "${uriTemplate}" has an unterminated "{".`);
183
+ }
184
+ const name = uriTemplate.slice(open + 1, close);
185
+ if (!/^[A-Za-z0-9_]+$/.test(name)) {
186
+ throw new TypeError(`MCP resource template "${uriTemplate}" uses an unsupported expression "{${name}}"; ` +
187
+ "only simple {name} variables are supported.");
188
+ }
189
+ variables.push(name);
190
+ pattern += "([^/]+)";
191
+ index = close + 1;
192
+ }
193
+ return { template, regex: new RegExp(`^${pattern}$`), variables };
194
+ }
195
+ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
196
+ /**
197
+ * Streamable HTTP DNS-rebinding defense: decide whether a browser `Origin`
198
+ * may talk to this MCP endpoint. Same-origin and loopback origins are always
199
+ * allowed; anything else must be explicitly allowlisted.
200
+ */
201
+ function isAllowedOrigin(origin, request, allowlist) {
202
+ const normalized = origin.toLowerCase();
203
+ if (allowlist.has(normalized))
204
+ return true;
205
+ if (normalized === "null")
206
+ return false;
207
+ let parsed;
208
+ try {
209
+ parsed = new URL(normalized);
210
+ }
211
+ catch {
212
+ return false;
213
+ }
214
+ const hostname = parsed.hostname;
215
+ if (LOOPBACK_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost"))
216
+ return true;
217
+ try {
218
+ return parsed.host === new URL(request.url).host;
219
+ }
220
+ catch {
221
+ return false;
222
+ }
223
+ }
224
+ /**
225
+ * Create a dependency-free MCP Streamable HTTP endpoint handler.
226
+ *
227
+ * The handler implements the server side of MCP over one HTTP endpoint:
228
+ * `initialize`, `ping`, `tools/list`, `tools/call`, `resources/list`,
229
+ * `resources/templates/list`, `resources/read`, `prompts/list`, and
230
+ * `prompts/get`. It accepts JSON-RPC requests over `POST`, acknowledges
231
+ * notifications with `202`, validates the `MCP-Protocol-Version` header,
232
+ * bounds request bodies, enforces required prompt arguments, and returns
233
+ * JSON-RPC errors for malformed input.
234
+ *
235
+ * Security: per the Streamable HTTP spec's DNS-rebinding guidance, every
236
+ * request bearing an `Origin` header is validated. Same-origin and loopback
237
+ * origins pass; anything else is rejected with `403` unless listed in
238
+ * {@link McpHandlerOptions.allowedOrigins}.
239
+ *
240
+ * It intentionally does not spawn stdio servers, manage OAuth metadata, keep
241
+ * durable sessions, or open server-initiated SSE streams. Use DaloyJS
242
+ * middleware for authentication and authorization, and run this on a dedicated
243
+ * Daloy app when your MCP server has a different trust boundary than your REST
244
+ * API.
245
+ *
246
+ * @param options - Server identity, capabilities, limits, and response headers.
247
+ * @returns A Fetch-compatible request handler suitable for {@link mcpRoutes}
248
+ * or for direct use in any web-standard runtime.
249
+ * @throws {TypeError} at construction for invalid serverInfo, protocol
250
+ * versions, body limits, duplicate names/URIs, malformed `allowedOrigins`
251
+ * entries, or unsupported URI template expressions.
252
+ *
253
+ * @example
254
+ * ```ts
255
+ * const mcp = createMcpHandler({
256
+ * serverInfo: { name: "inventory-mcp", version: "1.0.0" },
257
+ * tools: [
258
+ * {
259
+ * name: "inventory_lookup",
260
+ * description: "Look up inventory by SKU.",
261
+ * inputSchema: {
262
+ * type: "object",
263
+ * properties: { sku: { type: "string" } },
264
+ * required: ["sku"],
265
+ * additionalProperties: false,
266
+ * },
267
+ * handler: async ({ sku }) => `SKU ${sku} has 42 units.`,
268
+ * },
269
+ * ],
270
+ * });
271
+ * ```
272
+ *
273
+ * @since 1.0.0
274
+ */
275
+ export function createMcpHandler(options) {
276
+ if (options.serverInfo.name.trim().length === 0) {
277
+ throw new TypeError("MCP serverInfo.name is required.");
278
+ }
279
+ if (options.serverInfo.version.trim().length === 0) {
280
+ throw new TypeError("MCP serverInfo.version is required.");
281
+ }
282
+ const protocolVersions = options.protocolVersions ?? MCP_PROTOCOL_VERSIONS;
283
+ if (protocolVersions.length === 0) {
284
+ throw new TypeError("MCP protocolVersions must contain at least one version.");
285
+ }
286
+ const preferred = options.preferredProtocolVersion ?? MCP_PROTOCOL_VERSION;
287
+ const supported = new Set(protocolVersions);
288
+ if (!supported.has(preferred)) {
289
+ throw new TypeError("MCP preferredProtocolVersion must be listed in protocolVersions.");
290
+ }
291
+ const maxBodyBytes = options.maxBodyBytes ?? MCP_DEFAULT_MAX_BODY_BYTES;
292
+ if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 1) {
293
+ throw new TypeError("MCP maxBodyBytes must be a positive safe integer.");
294
+ }
295
+ const tools = options.tools ?? [];
296
+ const resources = options.resources ?? [];
297
+ const resourceTemplates = options.resourceTemplates ?? [];
298
+ const prompts = options.prompts ?? [];
299
+ const toolMap = new Map(tools.map((tool) => [tool.name, tool]));
300
+ const resourceMap = new Map(resources.map((resource) => [resource.uri, resource]));
301
+ const promptMap = new Map(prompts.map((prompt) => [prompt.name, prompt]));
302
+ if (toolMap.size !== tools.length)
303
+ throw new TypeError("MCP tool names must be unique.");
304
+ if (resourceMap.size !== resources.length)
305
+ throw new TypeError("MCP resource URIs must be unique.");
306
+ if (promptMap.size !== prompts.length)
307
+ throw new TypeError("MCP prompt names must be unique.");
308
+ if (new Set(resourceTemplates.map((template) => template.uriTemplate)).size !==
309
+ resourceTemplates.length) {
310
+ throw new TypeError("MCP resource template URIs must be unique.");
311
+ }
312
+ const compiledTemplates = resourceTemplates.map(compileUriTemplate);
313
+ const allowedOrigins = new Set();
314
+ for (const entry of options.allowedOrigins ?? []) {
315
+ const normalized = entry.toLowerCase();
316
+ if (normalized === "null") {
317
+ allowedOrigins.add(normalized);
318
+ continue;
319
+ }
320
+ let parsed;
321
+ try {
322
+ parsed = new URL(normalized);
323
+ }
324
+ catch {
325
+ parsed = undefined;
326
+ }
327
+ if (!parsed || parsed.origin !== normalized) {
328
+ throw new TypeError(`MCP allowedOrigins entry "${entry}" must be a bare origin such as "https://app.example.com".`);
329
+ }
330
+ allowedOrigins.add(normalized);
331
+ }
332
+ const exposeInternalErrors = options.exposeInternalErrors ??
333
+ (typeof process === "object" && process.env?.NODE_ENV !== "production");
334
+ const headers = options.headers;
335
+ const legacyAssumed = supported.has(LEGACY_ASSUMED_PROTOCOL_VERSION)
336
+ ? LEGACY_ASSUMED_PROTOCOL_VERSION
337
+ : preferred;
338
+ async function handleRpcRequest(message, request) {
339
+ const id = (message.id ?? null);
340
+ const method = message.method;
341
+ const params = asRecord(message.params);
342
+ const headerVersion = request.headers.get("mcp-protocol-version");
343
+ // Per the Streamable HTTP spec, a request without the header is assumed
344
+ // to speak 2025-03-26; `initialize` negotiates via params instead.
345
+ const protocolVersion = method === "initialize"
346
+ ? selectedProtocolVersion(typeof params.protocolVersion === "string"
347
+ ? params.protocolVersion
348
+ : (headerVersion ?? ""), supported, preferred)
349
+ : headerVersion !== null
350
+ ? selectedProtocolVersion(headerVersion, supported, preferred)
351
+ : legacyAssumed;
352
+ const ctx = { request, protocolVersion, id, method };
353
+ const cursor = params.cursor;
354
+ if (cursor !== undefined &&
355
+ (method === "tools/list" ||
356
+ method === "resources/list" ||
357
+ method === "resources/templates/list" ||
358
+ method === "prompts/list")) {
359
+ // This handler returns complete lists and never issues cursors, so any
360
+ // client-supplied cursor is unknown by definition.
361
+ return rpcError(id, INVALID_PARAMS, "Unknown pagination cursor.", undefined, 200, headers);
362
+ }
363
+ switch (method) {
364
+ case "initialize":
365
+ return rpcResult(id, {
366
+ protocolVersion,
367
+ capabilities: {
368
+ ...(tools.length > 0 ? { tools: {} } : {}),
369
+ ...(resources.length > 0 || resourceTemplates.length > 0 ? { resources: {} } : {}),
370
+ ...(prompts.length > 0 ? { prompts: {} } : {}),
371
+ },
372
+ serverInfo: options.serverInfo,
373
+ ...(options.instructions ? { instructions: options.instructions } : {}),
374
+ }, headers);
375
+ case "ping":
376
+ return rpcResult(id, {}, headers);
377
+ case "tools/list":
378
+ return rpcResult(id, { tools: tools.map(publicTool) }, headers);
379
+ case "tools/call": {
380
+ const name = typeof params.name === "string" ? params.name : "";
381
+ const tool = toolMap.get(name);
382
+ if (!tool) {
383
+ return rpcError(id, INVALID_PARAMS, `Unknown tool: ${name || "<missing>"}`, undefined, 200, headers);
384
+ }
385
+ try {
386
+ const result = await tool.handler(asRecord(params.arguments), ctx);
387
+ return rpcResult(id, normalizeToolResult(result), headers);
388
+ }
389
+ catch (error) {
390
+ if (error instanceof McpToolError) {
391
+ return rpcResult(id, { content: [{ type: "text", text: error.message }], isError: true }, headers);
392
+ }
393
+ return rpcError(id, INTERNAL_ERROR, "Tool execution failed.", safeInternalErrorData(error, exposeInternalErrors), 200, headers);
394
+ }
395
+ }
396
+ case "resources/list":
397
+ return rpcResult(id, { resources: resources.map(publicResource) }, headers);
398
+ case "resources/templates/list":
399
+ return rpcResult(id, { resourceTemplates: resourceTemplates.map(publicResourceTemplate) }, headers);
400
+ case "resources/read": {
401
+ const uri = typeof params.uri === "string" ? params.uri : "";
402
+ const readError = (error) => {
403
+ const message = error instanceof McpToolError ? error.message : "Resource read failed.";
404
+ const data = error instanceof McpToolError
405
+ ? undefined
406
+ : safeInternalErrorData(error, exposeInternalErrors);
407
+ return rpcError(id, error instanceof McpToolError ? INVALID_PARAMS : INTERNAL_ERROR, message, data, 200, headers);
408
+ };
409
+ const resource = resourceMap.get(uri);
410
+ if (resource) {
411
+ try {
412
+ const read = await resource.read(ctx);
413
+ return rpcResult(id, { contents: Array.isArray(read) ? read : [read] }, headers);
414
+ }
415
+ catch (error) {
416
+ return readError(error);
417
+ }
418
+ }
419
+ if (uri) {
420
+ for (const compiled of compiledTemplates) {
421
+ const match = compiled.regex.exec(uri);
422
+ if (!match)
423
+ continue;
424
+ const variables = {};
425
+ compiled.variables.forEach((name, position) => {
426
+ variables[name] = match[position + 1] ?? "";
427
+ });
428
+ try {
429
+ const read = await compiled.template.read(uri, variables, ctx);
430
+ return rpcResult(id, { contents: Array.isArray(read) ? read : [read] }, headers);
431
+ }
432
+ catch (error) {
433
+ return readError(error);
434
+ }
435
+ }
436
+ }
437
+ return rpcError(id, INVALID_PARAMS, `Unknown resource: ${uri || "<missing>"}`, undefined, 200, headers);
438
+ }
439
+ case "prompts/list":
440
+ return rpcResult(id, { prompts: prompts.map(publicPrompt) }, headers);
441
+ case "prompts/get": {
442
+ const name = typeof params.name === "string" ? params.name : "";
443
+ const prompt = promptMap.get(name);
444
+ if (!prompt) {
445
+ return rpcError(id, INVALID_PARAMS, `Unknown prompt: ${name || "<missing>"}`, undefined, 200, headers);
446
+ }
447
+ const promptArgs = asRecord(params.arguments);
448
+ const missing = (prompt.arguments ?? [])
449
+ .filter((argument) => argument.required && promptArgs[argument.name] === undefined)
450
+ .map((argument) => argument.name);
451
+ if (missing.length > 0) {
452
+ return rpcError(id, INVALID_PARAMS, `Missing required prompt arguments: ${missing.join(", ")}`, undefined, 200, headers);
453
+ }
454
+ try {
455
+ return rpcResult(id, await prompt.get(promptArgs, ctx), headers);
456
+ }
457
+ catch (error) {
458
+ const message = error instanceof McpToolError ? error.message : "Prompt rendering failed.";
459
+ const data = error instanceof McpToolError
460
+ ? undefined
461
+ : safeInternalErrorData(error, exposeInternalErrors);
462
+ return rpcError(id, error instanceof McpToolError ? INVALID_PARAMS : INTERNAL_ERROR, message, data, 200, headers);
463
+ }
464
+ }
465
+ default:
466
+ return rpcError(id, METHOD_NOT_FOUND, `Method not found: ${method}`, undefined, 200, headers);
467
+ }
468
+ }
469
+ return async function handleMcpRequest(request) {
470
+ // Streamable HTTP requires Origin validation on every request to defeat
471
+ // DNS rebinding; invalid browser origins are refused with 403.
472
+ const origin = request.headers.get("origin");
473
+ if (origin !== null && !isAllowedOrigin(origin, request, allowedOrigins)) {
474
+ return rpcError(null, INVALID_REQUEST, "Origin is not allowed for this MCP endpoint.", undefined, 403, headers);
475
+ }
476
+ if (request.method === "OPTIONS") {
477
+ return new Response(null, {
478
+ status: 204,
479
+ headers: { allow: "GET, POST, OPTIONS", ...(headers ?? {}) },
480
+ });
481
+ }
482
+ if (request.method === "GET") {
483
+ return jsonResponse({
484
+ transport: "streamable-http",
485
+ protocolVersions,
486
+ capabilities: {
487
+ tools: tools.map((tool) => tool.name),
488
+ resources: resources.map((resource) => resource.uri),
489
+ resourceTemplates: resourceTemplates.map((template) => template.uriTemplate),
490
+ prompts: prompts.map((prompt) => prompt.name),
491
+ },
492
+ hint: "Send JSON-RPC 2.0 over HTTP POST to this endpoint.",
493
+ }, 405, { allow: "POST, OPTIONS", ...(headers ?? {}) });
494
+ }
495
+ if (request.method !== "POST") {
496
+ return jsonResponse({ error: "MCP Streamable HTTP endpoints accept POST requests." }, 405, {
497
+ allow: "POST, OPTIONS",
498
+ ...(headers ?? {}),
499
+ });
500
+ }
501
+ const contentType = request.headers.get("content-type") ?? "";
502
+ if (!contentType.toLowerCase().includes("application/json")) {
503
+ return rpcError(null, INVALID_REQUEST, "MCP POST requests must use application/json.", undefined, 415, headers);
504
+ }
505
+ const protocolHeader = request.headers.get("mcp-protocol-version");
506
+ if (protocolHeader && !supported.has(protocolHeader)) {
507
+ return rpcError(null, INVALID_REQUEST, `Unsupported MCP-Protocol-Version: ${protocolHeader}`, { supported: protocolVersions }, 400, headers);
508
+ }
509
+ const declaredLength = Number(request.headers.get("content-length") ?? "");
510
+ if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
511
+ return rpcError(null, INVALID_REQUEST, "Request body too large.", undefined, 413, headers);
512
+ }
513
+ const body = await request.arrayBuffer();
514
+ if (body.byteLength > maxBodyBytes) {
515
+ return rpcError(null, INVALID_REQUEST, "Request body too large.", undefined, 413, headers);
516
+ }
517
+ let raw;
518
+ try {
519
+ raw = new TextDecoder("utf-8", { fatal: true }).decode(body);
520
+ }
521
+ catch {
522
+ return rpcError(null, PARSE_ERROR, "Request body must be valid UTF-8.", undefined, 400, headers);
523
+ }
524
+ let message;
525
+ try {
526
+ message = JSON.parse(raw);
527
+ }
528
+ catch {
529
+ return rpcError(null, PARSE_ERROR, "Invalid JSON in request body.", undefined, 400, headers);
530
+ }
531
+ if (Array.isArray(message)) {
532
+ return rpcError(null, INVALID_REQUEST, "JSON-RPC batch requests are not supported.", undefined, 400, headers);
533
+ }
534
+ if (!message || typeof message !== "object" || message.jsonrpc !== "2.0") {
535
+ return rpcError(null, INVALID_REQUEST, "Request must be a JSON-RPC 2.0 message.", undefined, 400, headers);
536
+ }
537
+ if (message.id !== undefined && !isJsonRpcId(message.id)) {
538
+ return rpcError(null, INVALID_REQUEST, "JSON-RPC id must be a string, number, or null.", undefined, 400, headers);
539
+ }
540
+ if (message.method === undefined) {
541
+ if (!("result" in message) && !("error" in message)) {
542
+ return rpcError(null, INVALID_REQUEST, "JSON-RPC message is missing `method`, `result`, or `error`.", undefined, 400, headers);
543
+ }
544
+ return new Response(null, { status: 202, headers });
545
+ }
546
+ if (typeof message.method !== "string") {
547
+ return rpcError(null, INVALID_REQUEST, "JSON-RPC method must be a string.", undefined, 400, headers);
548
+ }
549
+ if (message.id === undefined) {
550
+ return new Response(null, { status: 202, headers });
551
+ }
552
+ try {
553
+ return await handleRpcRequest(message, request);
554
+ }
555
+ catch (error) {
556
+ return rpcError(message.id, INTERNAL_ERROR, "Internal server error.", safeInternalErrorData(error, exposeInternalErrors), 200, headers);
557
+ }
558
+ };
559
+ }
560
+ /**
561
+ * Build the Daloy route definitions for a Streamable HTTP MCP endpoint.
562
+ *
563
+ * Register each returned route on the Daloy app that should host MCP. A
564
+ * separate app is often the cleanest production shape: the REST API can keep
565
+ * its public contract and auth policy, while the MCP server can use its own
566
+ * bearer token, rate limit, network allowlist, and tool set.
567
+ *
568
+ * @param path - Public MCP endpoint path, usually `"/mcp"`.
569
+ * @param handler - Handler returned by {@link createMcpHandler}.
570
+ * @returns Route definitions for `POST`, `GET`, and `OPTIONS` on the same
571
+ * path. `POST` is the actual MCP transport; `GET` gives a human-readable
572
+ * 405 hint because this helper does not open server-initiated SSE streams;
573
+ * `OPTIONS` supports preflight when CORS middleware is installed.
574
+ *
575
+ * @example
576
+ * ```ts
577
+ * const app = new App();
578
+ * const mcp = createMcpHandler({ serverInfo, tools });
579
+ *
580
+ * for (const route of mcpRoutes("/mcp", mcp)) {
581
+ * app.route(route);
582
+ * }
583
+ * ```
584
+ *
585
+ * @since 1.0.0
586
+ */
587
+ export function mcpRoutes(path, handler) {
588
+ const responses = {
589
+ 200: { description: "MCP JSON-RPC response", body: MCP_JSON_RESPONSE_SCHEMA },
590
+ 202: { description: "MCP notification accepted", body: MCP_JSON_RESPONSE_SCHEMA },
591
+ 204: { description: "CORS preflight accepted" },
592
+ 400: { description: "Invalid MCP request" },
593
+ 403: { description: "Origin not allowed" },
594
+ 405: { description: "Unsupported MCP transport method" },
595
+ 413: { description: "MCP request body too large" },
596
+ };
597
+ return [
598
+ {
599
+ method: "POST",
600
+ path,
601
+ operationId: "mcpPost",
602
+ summary: "MCP Streamable HTTP endpoint",
603
+ responses,
604
+ handler: ({ request }) => handler(request),
605
+ },
606
+ {
607
+ method: "GET",
608
+ path,
609
+ operationId: "mcpGet",
610
+ summary: "MCP Streamable HTTP discovery hint",
611
+ responses,
612
+ handler: ({ request }) => handler(request),
613
+ },
614
+ {
615
+ method: "OPTIONS",
616
+ path,
617
+ operationId: "mcpOptions",
618
+ summary: "MCP Streamable HTTP preflight",
619
+ responses,
620
+ handler: ({ request }) => handler(request),
621
+ },
622
+ ];
623
+ }