@daloyjs/core 1.0.0-beta.6 → 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 (99) hide show
  1. package/README.md +2 -2
  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 +1 -1
  51. package/dist/ip-restriction.d.ts +40 -3
  52. package/dist/ip-restriction.js +35 -3
  53. package/dist/jwk.d.ts +12 -1
  54. package/dist/jwk.js +6 -0
  55. package/dist/jwt.d.ts +14 -0
  56. package/dist/jwt.js +10 -0
  57. package/dist/load-shedding.d.ts +3 -0
  58. package/dist/load-shedding.js +3 -0
  59. package/dist/logger.d.ts +13 -0
  60. package/dist/logger.js +3 -0
  61. package/dist/mcp.d.ts +152 -10
  62. package/dist/mcp.js +223 -19
  63. package/dist/middleware.d.ts +68 -0
  64. package/dist/middleware.js +17 -0
  65. package/dist/mtls.d.ts +19 -2
  66. package/dist/mtls.js +12 -2
  67. package/dist/multipart.d.ts +42 -5
  68. package/dist/multipart.js +41 -5
  69. package/dist/openapi.d.ts +15 -9
  70. package/dist/openapi.js +6 -9
  71. package/dist/rate-limit-redis.d.ts +21 -2
  72. package/dist/rate-limit-redis.js +17 -2
  73. package/dist/safe-redirect.d.ts +6 -0
  74. package/dist/safe-redirect.js +6 -0
  75. package/dist/sbom.cdx.json +9 -9
  76. package/dist/sbom.spdx.json +5 -5
  77. package/dist/scheduler.d.ts +4 -0
  78. package/dist/schema.d.ts +25 -0
  79. package/dist/security-schemes.d.ts +50 -0
  80. package/dist/security-schemes.js +6 -0
  81. package/dist/security.d.ts +33 -0
  82. package/dist/security.js +28 -10
  83. package/dist/session.d.ts +34 -5
  84. package/dist/session.js +31 -5
  85. package/dist/streaming.d.ts +19 -0
  86. package/dist/streaming.js +16 -0
  87. package/dist/subdomains.d.ts +4 -0
  88. package/dist/subdomains.js +4 -0
  89. package/dist/time-claims.d.ts +22 -2
  90. package/dist/time-claims.js +6 -0
  91. package/dist/tracing.d.ts +12 -0
  92. package/dist/tracing.js +6 -0
  93. package/dist/types.d.ts +70 -1
  94. package/dist/waf.js +21 -1
  95. package/dist/webhook-delivery.d.ts +6 -0
  96. package/dist/webhook-delivery.js +5 -0
  97. package/dist/websocket.d.ts +137 -4
  98. package/dist/websocket.js +105 -4
  99. package/package.json +1 -1
package/dist/mcp.d.ts CHANGED
@@ -52,6 +52,22 @@ export type McpJsonSchema = McpJsonObject;
52
52
  * @since 1.0.0
53
53
  */
54
54
  export type McpJsonRpcId = string | number | null;
55
+ /**
56
+ * Icon metadata clients may render next to a server, tool, resource, or
57
+ * prompt (MCP 2025-11-25, SEP-973).
58
+ *
59
+ * @since 1.0.0
60
+ */
61
+ export interface McpIcon {
62
+ /** Icon URL. Prefer `https:` or `data:` URIs that clients can fetch safely. */
63
+ src: string;
64
+ /** Optional icon media type, e.g. `"image/png"`. */
65
+ mimeType?: string;
66
+ /** Optional pixel sizes the icon is available in, e.g. `["48x48"]`. */
67
+ sizes?: string[];
68
+ /** Optional theme the icon is designed for. */
69
+ theme?: "light" | "dark";
70
+ }
55
71
  /**
56
72
  * Identity block returned from the MCP `initialize` handshake.
57
73
  *
@@ -64,6 +80,12 @@ export interface McpServerInfo {
64
80
  title?: string;
65
81
  /** Server version surfaced to clients for debugging and compatibility. */
66
82
  version: string;
83
+ /** Optional human-readable server description (MCP 2025-11-25). */
84
+ description?: string;
85
+ /** Optional homepage URL for this server (MCP 2025-11-25). */
86
+ websiteUrl?: string;
87
+ /** Optional icons clients may display for this server (MCP 2025-11-25). */
88
+ icons?: McpIcon[];
67
89
  }
68
90
  /**
69
91
  * Per-request context passed to tool, resource, and prompt handlers.
@@ -74,9 +96,10 @@ export interface McpRequestContext {
74
96
  /** The original HTTP request received by the DaloyJS route. */
75
97
  request: Request;
76
98
  /**
77
- * Protocol version selected for this call. Before `initialize`, this is the
78
- * version from the `MCP-Protocol-Version` header when present, otherwise the
79
- * handler's preferred protocol version.
99
+ * Protocol version selected for this call. `initialize` negotiates it from
100
+ * `params.protocolVersion`; other calls take the `MCP-Protocol-Version`
101
+ * header, falling back to `2025-03-26` (the spec's assumption for
102
+ * headerless requests) when supported, otherwise the preferred version.
80
103
  */
81
104
  protocolVersion: string;
82
105
  /** JSON-RPC id for request/response correlation. */
@@ -90,7 +113,9 @@ export interface McpRequestContext {
90
113
  * @since 1.0.0
91
114
  */
92
115
  export interface McpTextContent {
116
+ /** Discriminator literal identifying this block as text. */
93
117
  type: "text";
118
+ /** The plain-text payload of the block. */
94
119
  text: string;
95
120
  }
96
121
  /**
@@ -102,8 +127,11 @@ export interface McpTextContent {
102
127
  * @since 1.0.0
103
128
  */
104
129
  export interface McpImageContent {
130
+ /** Discriminator literal identifying this block as an image. */
105
131
  type: "image";
132
+ /** Base64-encoded image bytes. */
106
133
  data: string;
134
+ /** Image media type, e.g. `"image/png"`. */
107
135
  mimeType: string;
108
136
  }
109
137
  /**
@@ -112,7 +140,9 @@ export interface McpImageContent {
112
140
  * @since 1.0.0
113
141
  */
114
142
  export interface McpEmbeddedResourceContent {
143
+ /** Discriminator literal identifying this block as an embedded resource. */
115
144
  type: "resource";
145
+ /** The embedded resource contents (uri plus text or base64 blob). */
116
146
  resource: McpResourceContents;
117
147
  }
118
148
  /**
@@ -131,13 +161,36 @@ export type McpContent = McpTextContent | McpImageContent | McpEmbeddedResourceC
131
161
  * @since 1.0.0
132
162
  */
133
163
  export interface McpToolResult {
134
- /** Human or model-readable content blocks returned to the MCP client. */
135
- content: McpContent[];
164
+ /**
165
+ * Human or model-readable content blocks returned to the MCP client. When
166
+ * omitted, {@link createMcpHandler} backfills a text block serializing
167
+ * `structuredContent` so pre-2025-06-18 clients still receive output.
168
+ */
169
+ content?: McpContent[];
136
170
  /** Optional structured payload for clients that can consume typed output. */
137
171
  structuredContent?: McpJsonObject;
138
172
  /** Set to `true` for domain/tool errors the model may recover from. */
139
173
  isError?: boolean;
140
174
  }
175
+ /**
176
+ * Behavioral hints a tool can advertise to MCP clients. Hints are untrusted
177
+ * metadata for UX decisions (confirmation prompts, badges); clients must not
178
+ * rely on them for security decisions.
179
+ *
180
+ * @since 1.0.0
181
+ */
182
+ export interface McpToolAnnotations {
183
+ /** Human-readable title for the tool. */
184
+ title?: string;
185
+ /** Hint that the tool does not modify its environment. */
186
+ readOnlyHint?: boolean;
187
+ /** Hint that the tool may perform destructive updates. */
188
+ destructiveHint?: boolean;
189
+ /** Hint that repeated calls with the same arguments have no extra effect. */
190
+ idempotentHint?: boolean;
191
+ /** Hint that the tool interacts with external entities. */
192
+ openWorldHint?: boolean;
193
+ }
141
194
  /**
142
195
  * Handler for a single MCP tool.
143
196
  *
@@ -172,6 +225,16 @@ export interface McpTool<TArgs extends Record<string, unknown> = Record<string,
172
225
  description: string;
173
226
  /** JSON Schema for `params.arguments`. */
174
227
  inputSchema: McpJsonSchema;
228
+ /**
229
+ * Optional JSON Schema describing `structuredContent` in tool results
230
+ * (MCP 2025-06-18). When set, handlers should return `structuredContent`
231
+ * matching it.
232
+ */
233
+ outputSchema?: McpJsonSchema;
234
+ /** Optional behavioral hints for clients. */
235
+ annotations?: McpToolAnnotations;
236
+ /** Optional icons clients may display for this tool (MCP 2025-11-25). */
237
+ icons?: McpIcon[];
175
238
  /** Execute the tool with untrusted JSON arguments. */
176
239
  handler: McpToolHandler<TArgs>;
177
240
  }
@@ -191,6 +254,8 @@ export interface McpResource {
191
254
  description?: string;
192
255
  /** MIME type returned by `resources/read`, such as `"application/json"`. */
193
256
  mimeType?: string;
257
+ /** Optional icons clients may display for this resource (MCP 2025-11-25). */
258
+ icons?: McpIcon[];
194
259
  }
195
260
  /**
196
261
  * Resource payload returned from `resources/read`.
@@ -228,6 +293,51 @@ export interface McpResourceDefinition extends McpResource {
228
293
  */
229
294
  read: (ctx: McpRequestContext) => McpResourceContents | McpResourceContents[] | Promise<McpResourceContents | McpResourceContents[]>;
230
295
  }
296
+ /**
297
+ * Resource template metadata returned from `resources/templates/list`.
298
+ *
299
+ * @since 1.0.0
300
+ */
301
+ export interface McpResourceTemplate {
302
+ /**
303
+ * URI template for this resource family, e.g. `"daloy://records/{id}"`.
304
+ * DaloyJS supports simple `{name}` variables (RFC 6570 level 1); each
305
+ * variable matches one URI segment (no `/`).
306
+ */
307
+ uriTemplate: string;
308
+ /** Stable template name. */
309
+ name: string;
310
+ /** Optional human-readable title. */
311
+ title?: string;
312
+ /** Optional description shown by clients. */
313
+ description?: string;
314
+ /** MIME type of resources produced by this template. */
315
+ mimeType?: string;
316
+ /** Optional icons clients may display for this template (MCP 2025-11-25). */
317
+ icons?: McpIcon[];
318
+ }
319
+ /**
320
+ * Definition of a parameterized MCP resource template.
321
+ *
322
+ * Templates answer `resources/read` for URIs that match `uriTemplate` but are
323
+ * not listed as concrete resources. Template variables arrive as raw URI
324
+ * segment strings; validate them before touching databases or files.
325
+ *
326
+ * @since 1.0.0
327
+ */
328
+ export interface McpResourceTemplateDefinition extends McpResourceTemplate {
329
+ /**
330
+ * Read a resource instantiated from this template for `resources/read`.
331
+ *
332
+ * @param uri - The full resource URI requested by the client.
333
+ * @param variables - Template variable values extracted from `uri`.
334
+ * @param ctx - Request metadata and the original HTTP request.
335
+ * @returns One or more content entries for this resource.
336
+ * @throws {McpToolError} for caller-correctable failures such as an unknown
337
+ * record id; these become JSON-RPC invalid-params errors.
338
+ */
339
+ read: (uri: string, variables: Record<string, string>, ctx: McpRequestContext) => McpResourceContents | McpResourceContents[] | Promise<McpResourceContents | McpResourceContents[]>;
340
+ }
231
341
  /**
232
342
  * Argument metadata for an MCP prompt.
233
343
  *
@@ -253,8 +363,14 @@ export interface McpPrompt {
253
363
  title?: string;
254
364
  /** Optional prompt description. */
255
365
  description?: string;
256
- /** Prompt arguments clients may supply to `prompts/get`. */
366
+ /**
367
+ * Prompt arguments clients may supply to `prompts/get`. Arguments marked
368
+ * `required: true` are enforced by {@link createMcpHandler}: a `prompts/get`
369
+ * call missing one fails with a JSON-RPC invalid-params error.
370
+ */
257
371
  arguments?: McpPromptArgument[];
372
+ /** Optional icons clients may display for this prompt (MCP 2025-11-25). */
373
+ icons?: McpIcon[];
258
374
  }
259
375
  /**
260
376
  * Message returned from `prompts/get`.
@@ -326,8 +442,25 @@ export interface McpHandlerOptions {
326
442
  tools?: readonly McpTool[];
327
443
  /** Readable resources exposed through `resources/list` and `resources/read`. */
328
444
  resources?: readonly McpResourceDefinition[];
445
+ /**
446
+ * Parameterized resource templates exposed through
447
+ * `resources/templates/list` and matched by `resources/read` when a URI is
448
+ * not a listed concrete resource.
449
+ */
450
+ resourceTemplates?: readonly McpResourceTemplateDefinition[];
329
451
  /** Reusable prompts exposed through `prompts/list` and `prompts/get`. */
330
452
  prompts?: readonly McpPromptDefinition[];
453
+ /**
454
+ * Extra `Origin` header values allowed on MCP requests, e.g.
455
+ * `"https://app.example.com"` (or the literal `"null"` for opaque origins).
456
+ *
457
+ * The MCP Streamable HTTP spec requires servers to validate `Origin` to
458
+ * prevent DNS rebinding attacks. DaloyJS always allows requests without an
459
+ * `Origin` header (non-browser MCP clients), same-origin requests, and
460
+ * loopback origins (`localhost`, `*.localhost`, `127.0.0.1`, `[::1]`); every
461
+ * other origin is rejected with `403` unless listed here.
462
+ */
463
+ allowedOrigins?: readonly string[];
331
464
  /** Accepted MCP protocol versions. Defaults to {@link MCP_PROTOCOL_VERSIONS}. */
332
465
  protocolVersions?: readonly string[];
333
466
  /**
@@ -364,10 +497,16 @@ export type McpHandler = (request: Request) => Promise<Response>;
364
497
  *
365
498
  * The handler implements the server side of MCP over one HTTP endpoint:
366
499
  * `initialize`, `ping`, `tools/list`, `tools/call`, `resources/list`,
367
- * `resources/read`, `prompts/list`, and `prompts/get`. It accepts JSON-RPC
368
- * requests over `POST`, acknowledges notifications with `202`, validates the
369
- * `MCP-Protocol-Version` header, bounds request bodies, and returns JSON-RPC
370
- * errors for malformed input.
500
+ * `resources/templates/list`, `resources/read`, `prompts/list`, and
501
+ * `prompts/get`. It accepts JSON-RPC requests over `POST`, acknowledges
502
+ * notifications with `202`, validates the `MCP-Protocol-Version` header,
503
+ * bounds request bodies, enforces required prompt arguments, and returns
504
+ * JSON-RPC errors for malformed input.
505
+ *
506
+ * Security: per the Streamable HTTP spec's DNS-rebinding guidance, every
507
+ * request bearing an `Origin` header is validated. Same-origin and loopback
508
+ * origins pass; anything else is rejected with `403` unless listed in
509
+ * {@link McpHandlerOptions.allowedOrigins}.
371
510
  *
372
511
  * It intentionally does not spawn stdio servers, manage OAuth metadata, keep
373
512
  * durable sessions, or open server-initiated SSE streams. Use DaloyJS
@@ -378,6 +517,9 @@ export type McpHandler = (request: Request) => Promise<Response>;
378
517
  * @param options - Server identity, capabilities, limits, and response headers.
379
518
  * @returns A Fetch-compatible request handler suitable for {@link mcpRoutes}
380
519
  * or for direct use in any web-standard runtime.
520
+ * @throws {TypeError} at construction for invalid serverInfo, protocol
521
+ * versions, body limits, duplicate names/URIs, malformed `allowedOrigins`
522
+ * entries, or unsupported URI template expressions.
381
523
  *
382
524
  * @example
383
525
  * ```ts
package/dist/mcp.js CHANGED
@@ -25,17 +25,51 @@ export const MCP_PROTOCOL_VERSIONS = Object.freeze([
25
25
  * @since 1.0.0
26
26
  */
27
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";
28
34
  const PARSE_ERROR = -32700;
29
35
  const INVALID_REQUEST = -32600;
30
36
  const METHOD_NOT_FOUND = -32601;
31
37
  const INVALID_PARAMS = -32602;
32
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
+ };
33
64
  const MCP_JSON_RESPONSE_SCHEMA = {
34
65
  "~standard": {
35
66
  version: 1,
36
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.
37
70
  validate: (value) => ({ value }),
38
71
  },
72
+ toJSONSchema: () => MCP_JSONRPC_ENVELOPE_JSON_SCHEMA,
39
73
  };
40
74
  /**
41
75
  * Caller-correctable MCP tool/resource/prompt error.
@@ -103,21 +137,105 @@ function publicPrompt(prompt) {
103
137
  const { get: _get, ...rest } = prompt;
104
138
  return rest;
105
139
  }
140
+ function publicResourceTemplate(template) {
141
+ const { read: _read, ...rest } = template;
142
+ return rest;
143
+ }
106
144
  function normalizeToolResult(value) {
107
- return typeof value === "string" ? { content: [{ type: "text", text: value }] } : 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 };
108
155
  }
109
156
  function selectedProtocolVersion(requested, supported, preferred) {
110
157
  return supported.has(requested) ? requested : preferred;
111
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
+ }
112
224
  /**
113
225
  * Create a dependency-free MCP Streamable HTTP endpoint handler.
114
226
  *
115
227
  * The handler implements the server side of MCP over one HTTP endpoint:
116
228
  * `initialize`, `ping`, `tools/list`, `tools/call`, `resources/list`,
117
- * `resources/read`, `prompts/list`, and `prompts/get`. It accepts JSON-RPC
118
- * requests over `POST`, acknowledges notifications with `202`, validates the
119
- * `MCP-Protocol-Version` header, bounds request bodies, and returns JSON-RPC
120
- * errors for malformed input.
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}.
121
239
  *
122
240
  * It intentionally does not spawn stdio servers, manage OAuth metadata, keep
123
241
  * durable sessions, or open server-initiated SSE streams. Use DaloyJS
@@ -128,6 +246,9 @@ function selectedProtocolVersion(requested, supported, preferred) {
128
246
  * @param options - Server identity, capabilities, limits, and response headers.
129
247
  * @returns A Fetch-compatible request handler suitable for {@link mcpRoutes}
130
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.
131
252
  *
132
253
  * @example
133
254
  * ```ts
@@ -173,6 +294,7 @@ export function createMcpHandler(options) {
173
294
  }
174
295
  const tools = options.tools ?? [];
175
296
  const resources = options.resources ?? [];
297
+ const resourceTemplates = options.resourceTemplates ?? [];
176
298
  const prompts = options.prompts ?? [];
177
299
  const toolMap = new Map(tools.map((tool) => [tool.name, tool]));
178
300
  const resourceMap = new Map(resources.map((resource) => [resource.uri, resource]));
@@ -183,24 +305,68 @@ export function createMcpHandler(options) {
183
305
  throw new TypeError("MCP resource URIs must be unique.");
184
306
  if (promptMap.size !== prompts.length)
185
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
+ }
186
332
  const exposeInternalErrors = options.exposeInternalErrors ??
187
333
  (typeof process === "object" && process.env?.NODE_ENV !== "production");
188
334
  const headers = options.headers;
335
+ const legacyAssumed = supported.has(LEGACY_ASSUMED_PROTOCOL_VERSION)
336
+ ? LEGACY_ASSUMED_PROTOCOL_VERSION
337
+ : preferred;
189
338
  async function handleRpcRequest(message, request) {
190
339
  const id = (message.id ?? null);
191
340
  const method = message.method;
192
341
  const params = asRecord(message.params);
193
- const protocolVersion = selectedProtocolVersion(typeof params.protocolVersion === "string"
194
- ? params.protocolVersion
195
- : (request.headers.get("mcp-protocol-version") ?? ""), supported, preferred);
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;
196
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
+ }
197
363
  switch (method) {
198
364
  case "initialize":
199
365
  return rpcResult(id, {
200
366
  protocolVersion,
201
367
  capabilities: {
202
368
  ...(tools.length > 0 ? { tools: {} } : {}),
203
- ...(resources.length > 0 ? { resources: {} } : {}),
369
+ ...(resources.length > 0 || resourceTemplates.length > 0 ? { resources: {} } : {}),
204
370
  ...(prompts.length > 0 ? { prompts: {} } : {}),
205
371
  },
206
372
  serverInfo: options.serverInfo,
@@ -229,23 +395,46 @@ export function createMcpHandler(options) {
229
395
  }
230
396
  case "resources/list":
231
397
  return rpcResult(id, { resources: resources.map(publicResource) }, headers);
398
+ case "resources/templates/list":
399
+ return rpcResult(id, { resourceTemplates: resourceTemplates.map(publicResourceTemplate) }, headers);
232
400
  case "resources/read": {
233
401
  const uri = typeof params.uri === "string" ? params.uri : "";
234
- const resource = resourceMap.get(uri);
235
- if (!resource) {
236
- return rpcError(id, INVALID_PARAMS, `Unknown resource: ${uri || "<missing>"}`, undefined, 200, headers);
237
- }
238
- try {
239
- const read = await resource.read(ctx);
240
- return rpcResult(id, { contents: Array.isArray(read) ? read : [read] }, headers);
241
- }
242
- catch (error) {
402
+ const readError = (error) => {
243
403
  const message = error instanceof McpToolError ? error.message : "Resource read failed.";
244
404
  const data = error instanceof McpToolError
245
405
  ? undefined
246
406
  : safeInternalErrorData(error, exposeInternalErrors);
247
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
+ }
248
436
  }
437
+ return rpcError(id, INVALID_PARAMS, `Unknown resource: ${uri || "<missing>"}`, undefined, 200, headers);
249
438
  }
250
439
  case "prompts/list":
251
440
  return rpcResult(id, { prompts: prompts.map(publicPrompt) }, headers);
@@ -255,8 +444,15 @@ export function createMcpHandler(options) {
255
444
  if (!prompt) {
256
445
  return rpcError(id, INVALID_PARAMS, `Unknown prompt: ${name || "<missing>"}`, undefined, 200, headers);
257
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
+ }
258
454
  try {
259
- return rpcResult(id, await prompt.get(asRecord(params.arguments), ctx), headers);
455
+ return rpcResult(id, await prompt.get(promptArgs, ctx), headers);
260
456
  }
261
457
  catch (error) {
262
458
  const message = error instanceof McpToolError ? error.message : "Prompt rendering failed.";
@@ -271,6 +467,12 @@ export function createMcpHandler(options) {
271
467
  }
272
468
  }
273
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
+ }
274
476
  if (request.method === "OPTIONS") {
275
477
  return new Response(null, {
276
478
  status: 204,
@@ -284,6 +486,7 @@ export function createMcpHandler(options) {
284
486
  capabilities: {
285
487
  tools: tools.map((tool) => tool.name),
286
488
  resources: resources.map((resource) => resource.uri),
489
+ resourceTemplates: resourceTemplates.map((template) => template.uriTemplate),
287
490
  prompts: prompts.map((prompt) => prompt.name),
288
491
  },
289
492
  hint: "Send JSON-RPC 2.0 over HTTP POST to this endpoint.",
@@ -387,6 +590,7 @@ export function mcpRoutes(path, handler) {
387
590
  202: { description: "MCP notification accepted", body: MCP_JSON_RESPONSE_SCHEMA },
388
591
  204: { description: "CORS preflight accepted" },
389
592
  400: { description: "Invalid MCP request" },
593
+ 403: { description: "Origin not allowed" },
390
594
  405: { description: "Unsupported MCP transport method" },
391
595
  413: { description: "MCP request body too large" },
392
596
  };