@daloyjs/core 1.0.0-rc.5 → 1.0.0-rc.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 (51) hide show
  1. package/README.md +25 -14
  2. package/dist/adapters/bun.js +1 -2
  3. package/dist/adapters/node.js +16 -30
  4. package/dist/app.d.ts +5 -1
  5. package/dist/app.js +74 -6
  6. package/dist/auto-ban.d.ts +16 -0
  7. package/dist/auto-ban.js +20 -12
  8. package/dist/bot-guard.d.ts +14 -0
  9. package/dist/bot-guard.js +12 -12
  10. package/dist/cli.js +9 -6
  11. package/dist/concurrency-limit.d.ts +14 -0
  12. package/dist/concurrency-limit.js +18 -9
  13. package/dist/config.js +1 -3
  14. package/dist/conn-info.d.ts +65 -0
  15. package/dist/conn-info.js +99 -4
  16. package/dist/errors.js +2 -5
  17. package/dist/etag.js +12 -2
  18. package/dist/geo-block.d.ts +15 -0
  19. package/dist/geo-block.js +14 -19
  20. package/dist/hashing.js +1 -1
  21. package/dist/http-signatures.js +3 -8
  22. package/dist/index.d.ts +5 -5
  23. package/dist/index.js +4 -4
  24. package/dist/ip-reputation.d.ts +14 -0
  25. package/dist/ip-reputation.js +11 -11
  26. package/dist/ip-restriction.d.ts +14 -0
  27. package/dist/ip-restriction.js +7 -18
  28. package/dist/jwt.js +12 -14
  29. package/dist/logger.js +1 -3
  30. package/dist/mcp.d.ts +305 -34
  31. package/dist/mcp.js +554 -49
  32. package/dist/middleware.d.ts +31 -1
  33. package/dist/middleware.js +21 -19
  34. package/dist/multipart.js +9 -12
  35. package/dist/openapi.d.ts +1 -1
  36. package/dist/openapi.js +2 -2
  37. package/dist/rate-limit-redis.d.ts +4 -4
  38. package/dist/response-cache.d.ts +179 -21
  39. package/dist/response-cache.js +338 -29
  40. package/dist/safe-redirect.js +3 -1
  41. package/dist/sbom.cdx.json +9 -9
  42. package/dist/sbom.spdx.json +5 -5
  43. package/dist/security-schemes.js +1 -2
  44. package/dist/subdomains.js +1 -4
  45. package/dist/tenancy.d.ts +40 -0
  46. package/dist/tenancy.js +54 -3
  47. package/dist/waf.js +40 -8
  48. package/dist/webhook-delivery.js +19 -3
  49. package/dist/websocket.d.ts +8 -0
  50. package/dist/websocket.js +19 -4
  51. package/package.json +2 -2
package/dist/mcp.js CHANGED
@@ -2,14 +2,29 @@ import { safeJsonParseLimited } from "./security.js";
2
2
  /**
3
3
  * Latest MCP protocol version DaloyJS negotiates by default.
4
4
  *
5
- * @see https://modelcontextprotocol.io/specification/2025-11-25
5
+ * @see https://modelcontextprotocol.io/specification/2026-07-28
6
6
  * @since 1.0.0
7
7
  */
8
- export const MCP_PROTOCOL_VERSION = "2025-11-25";
8
+ export const MCP_PROTOCOL_VERSION = "2026-07-28";
9
+ /**
10
+ * First MCP revision of the *stateless* ("modern") era: version, client
11
+ * identity, and client capabilities travel in each request's `_meta` instead
12
+ * of being established once by an `initialize` handshake.
13
+ *
14
+ * Revisions are `YYYY-MM-DD` strings, so a lexicographic comparison against
15
+ * this constant correctly classifies every past and future revision.
16
+ *
17
+ * @since 1.0.0
18
+ */
19
+ export const MCP_MODERN_ERA_MIN_VERSION = "2026-07-28";
9
20
  /**
10
21
  * Protocol revisions accepted by {@link createMcpHandler} unless the caller
11
22
  * provides an explicit `protocolVersions` list.
12
23
  *
24
+ * The list spans both protocol eras: the stateless `2026-07-28` revision and
25
+ * the older handshake-based revisions, so one endpoint serves modern and
26
+ * legacy MCP clients at the same time.
27
+ *
13
28
  * @since 1.0.0
14
29
  */
15
30
  export const MCP_PROTOCOL_VERSIONS = Object.freeze([
@@ -17,7 +32,41 @@ export const MCP_PROTOCOL_VERSIONS = Object.freeze([
17
32
  "2025-03-26",
18
33
  "2025-06-18",
19
34
  "2025-11-25",
35
+ "2026-07-28",
20
36
  ]);
37
+ /**
38
+ * Reserved `_meta` keys defined by MCP `2026-07-28` for per-request protocol
39
+ * metadata. Exported so applications and tests can build spec-compliant
40
+ * requests without hard-coding string literals.
41
+ *
42
+ * @since 1.0.0
43
+ */
44
+ export const MCP_META_KEYS = Object.freeze({
45
+ /** Protocol version for this request. Required on every modern request. */
46
+ protocolVersion: "io.modelcontextprotocol/protocolVersion",
47
+ /** Self-reported client name/version. Advisory only; never a security input. */
48
+ clientInfo: "io.modelcontextprotocol/clientInfo",
49
+ /** Client capabilities relevant to this request. Required on every modern request. */
50
+ clientCapabilities: "io.modelcontextprotocol/clientCapabilities",
51
+ /** Minimum log level the server should emit for this request. */
52
+ logLevel: "io.modelcontextprotocol/logLevel",
53
+ /** Self-reported server name/version, returned in each modern result's `_meta`. */
54
+ serverInfo: "io.modelcontextprotocol/serverInfo",
55
+ });
56
+ /**
57
+ * JSON-RPC error codes defined by the MCP specification in its reserved
58
+ * `-32020`..`-32099` sub-range.
59
+ *
60
+ * @since 1.0.0
61
+ */
62
+ export const MCP_ERROR_CODES = Object.freeze({
63
+ /** HTTP headers disagree with the request body, or a required header is missing. */
64
+ headerMismatch: -32020,
65
+ /** The request needs a client capability the client did not declare. */
66
+ missingRequiredClientCapability: -32021,
67
+ /** The requested protocol version is not implemented by this server. */
68
+ unsupportedProtocolVersion: -32022,
69
+ });
21
70
  /**
22
71
  * Default maximum accepted JSON-RPC request body for a DaloyJS MCP endpoint.
23
72
  * The cap is intentionally small because MCP calls should carry parameters,
@@ -26,6 +75,16 @@ export const MCP_PROTOCOL_VERSIONS = Object.freeze([
26
75
  * @since 1.0.0
27
76
  */
28
77
  export const MCP_DEFAULT_MAX_BODY_BYTES = 1 << 18;
78
+ /**
79
+ * Maximum accepted length of a client-supplied `params.requestState` string.
80
+ *
81
+ * `requestState` is opaque server state that round-trips through an untrusted
82
+ * client during a multi round-trip request, so it is bounded independently of
83
+ * the body cap to keep a hostile client from forcing large state parsing.
84
+ *
85
+ * @since 1.0.0
86
+ */
87
+ export const MCP_MAX_REQUEST_STATE_LENGTH = 8192;
29
88
  /**
30
89
  * Protocol revision assumed when an HTTP request carries no
31
90
  * `MCP-Protocol-Version` header, as required by the Streamable HTTP spec for
@@ -37,6 +96,25 @@ const INVALID_REQUEST = -32600;
37
96
  const METHOD_NOT_FOUND = -32601;
38
97
  const INVALID_PARAMS = -32602;
39
98
  const INTERNAL_ERROR = -32603;
99
+ const HEADER_MISMATCH = MCP_ERROR_CODES.headerMismatch;
100
+ const MISSING_REQUIRED_CLIENT_CAPABILITY = MCP_ERROR_CODES.missingRequiredClientCapability;
101
+ const UNSUPPORTED_PROTOCOL_VERSION = MCP_ERROR_CODES.unsupportedProtocolVersion;
102
+ /**
103
+ * Report whether a protocol revision belongs to the stateless ("modern") MCP
104
+ * era introduced by {@link MCP_MODERN_ERA_MIN_VERSION}.
105
+ *
106
+ * Modern requests carry their protocol version, client identity, and client
107
+ * capabilities in `_meta` and are validated against the standard
108
+ * `MCP-Protocol-Version` / `Mcp-Method` / `Mcp-Name` HTTP headers. Older
109
+ * revisions keep the `initialize` handshake instead.
110
+ *
111
+ * @param version - A protocol revision string such as `"2026-07-28"`.
112
+ * @returns `true` when the revision uses per-request metadata.
113
+ * @since 1.0.0
114
+ */
115
+ export function isModernProtocolVersion(version) {
116
+ return version >= MCP_MODERN_ERA_MIN_VERSION;
117
+ }
40
118
  /**
41
119
  * JSON Schema for the JSON-RPC 2.0 envelope every MCP response uses. Exposed
42
120
  * through `toJSONSchema()` so the generated OpenAPI document describes the
@@ -382,34 +460,167 @@ function isAllowedOrigin(origin, _request, allowlist) {
382
460
  return true;
383
461
  return false;
384
462
  }
463
+ const HEADER_BASE64_PREFIX = "=?base64?";
464
+ const HEADER_BASE64_SUFFIX = "?=";
465
+ /**
466
+ * Decode a Streamable HTTP header value that may use the `2026-07-28` Base64
467
+ * sentinel form `=?base64?<b64>?=`.
468
+ *
469
+ * Clients must use the sentinel whenever a tool name, resource URI, or mirrored
470
+ * parameter cannot be represented as a plain ASCII header value. Servers must
471
+ * decode before comparing the header against the request body.
472
+ *
473
+ * @param raw - The raw HTTP header value.
474
+ * @returns The decoded value, or `undefined` when the sentinel wrapper is
475
+ * present but its payload is not valid Base64-encoded UTF-8.
476
+ */
477
+ function decodeMcpHeaderValue(raw) {
478
+ if (!raw.startsWith(HEADER_BASE64_PREFIX) || !raw.endsWith(HEADER_BASE64_SUFFIX))
479
+ return raw;
480
+ const encoded = raw.slice(HEADER_BASE64_PREFIX.length, raw.length - HEADER_BASE64_SUFFIX.length);
481
+ try {
482
+ const binary = atob(encoded);
483
+ const bytes = new Uint8Array(binary.length);
484
+ for (let i = 0; i < binary.length; i++)
485
+ bytes[i] = binary.charCodeAt(i);
486
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
487
+ }
488
+ catch {
489
+ return undefined;
490
+ }
491
+ }
492
+ /** RFC 9110 `token` (`1*tchar`) — the legal character set for an HTTP field name. */
493
+ const HTTP_TOKEN_PATTERN = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/;
494
+ /**
495
+ * Collect and validate a tool's `x-mcp-header` annotations.
496
+ *
497
+ * Only properties statically reachable from the schema root through a chain of
498
+ * `properties` keys may be annotated, and only primitive `string` / `integer` /
499
+ * `boolean` properties. Invalid annotations throw at construction so a server
500
+ * never advertises a mirroring contract it cannot enforce.
501
+ *
502
+ * @param tool - The tool whose `inputSchema` is being compiled.
503
+ * @returns The mirrored properties, in schema order.
504
+ * @throws {TypeError} when an annotation violates the specification's
505
+ * constraints (empty, non-token, duplicate, or on a non-primitive or
506
+ * non-statically-reachable property).
507
+ */
508
+ function collectHeaderParams(tool) {
509
+ const collected = [];
510
+ const seen = new Set();
511
+ const walk = (schema, path, depth) => {
512
+ if (depth > MAX_MCP_SCHEMA_DEPTH)
513
+ return;
514
+ const props = isSchemaObject(schema.properties) ? schema.properties : undefined;
515
+ if (!props)
516
+ return;
517
+ for (const key of Object.keys(props)) {
518
+ const sub = props[key];
519
+ if (!isSchemaObject(sub))
520
+ continue;
521
+ const nextPath = [...path, key];
522
+ const annotation = sub["x-mcp-header"];
523
+ if (annotation !== undefined) {
524
+ if (typeof annotation !== "string" || annotation.length === 0) {
525
+ throw new TypeError(`MCP tool "${tool.name}" has an empty or non-string "x-mcp-header" on "${nextPath.join(".")}".`);
526
+ }
527
+ if (!HTTP_TOKEN_PATTERN.test(annotation)) {
528
+ throw new TypeError(`MCP tool "${tool.name}" has an "x-mcp-header" value "${annotation}" that is not a valid HTTP field-name token.`);
529
+ }
530
+ const lower = annotation.toLowerCase();
531
+ if (seen.has(lower)) {
532
+ throw new TypeError(`MCP tool "${tool.name}" reuses the "x-mcp-header" name "${annotation}"; names must be case-insensitively unique.`);
533
+ }
534
+ const type = sub.type;
535
+ if (type !== "string" && type !== "integer" && type !== "boolean") {
536
+ throw new TypeError(`MCP tool "${tool.name}" annotates "${nextPath.join(".")}" with "x-mcp-header" but its type is not string, integer, or boolean.`);
537
+ }
538
+ seen.add(lower);
539
+ collected.push({ name: annotation, headerKey: `mcp-param-${lower}`, path: nextPath, type });
540
+ }
541
+ walk(sub, nextPath, depth + 1);
542
+ }
543
+ };
544
+ walk(tool.inputSchema, [], 0);
545
+ return collected;
546
+ }
547
+ /** Read the value at an exact chain of object keys, or `undefined` if absent. */
548
+ function valueAtPath(root, path) {
549
+ let cursor = root;
550
+ for (const key of path) {
551
+ if (cursor === null || typeof cursor !== "object" || Array.isArray(cursor))
552
+ return undefined;
553
+ if (!Object.prototype.hasOwnProperty.call(cursor, key))
554
+ return undefined;
555
+ cursor = cursor[key];
556
+ }
557
+ return cursor;
558
+ }
559
+ /** Narrow a handler return value to a multi round-trip interim result. */
560
+ function isInputRequiredResult(value) {
561
+ return (value !== null &&
562
+ typeof value === "object" &&
563
+ value.resultType === "input_required");
564
+ }
565
+ /**
566
+ * Client capability each server-to-client request in an `inputRequests` map
567
+ * depends on. A server must not ask for input the client cannot provide.
568
+ */
569
+ const INPUT_REQUEST_CAPABILITY = Object.freeze({
570
+ "elicitation/create": "elicitation",
571
+ "sampling/createMessage": "sampling",
572
+ "roots/list": "roots",
573
+ });
574
+ /** Modern methods that may answer with an `input_required` interim result. */
575
+ const MRTR_METHODS = new Set(["tools/call", "resources/read", "prompts/get"]);
385
576
  /**
386
577
  * Create a dependency-free MCP Streamable HTTP endpoint handler.
387
578
  *
388
- * The handler implements the server side of MCP over one HTTP endpoint:
389
- * `initialize`, `ping`, `tools/list`, `tools/call`, `resources/list`,
390
- * `resources/templates/list`, `resources/read`, `prompts/list`, and
391
- * `prompts/get`. It accepts JSON-RPC requests over `POST`, acknowledges
392
- * notifications with `202`, validates the `MCP-Protocol-Version` header,
393
- * bounds request bodies, enforces required prompt arguments, and returns
394
- * JSON-RPC errors for malformed input.
395
- *
396
- * Security: per the Streamable HTTP spec's DNS-rebinding guidance, every
397
- * request bearing an `Origin` header is validated. Same-origin and loopback
398
- * origins pass; anything else is rejected with `403` unless listed in
399
- * {@link McpHandlerOptions.allowedOrigins}.
400
- *
401
- * It intentionally does not spawn stdio servers, manage OAuth metadata, keep
402
- * durable sessions, or open server-initiated SSE streams. Use DaloyJS
403
- * middleware for authentication and authorization, and run this on a dedicated
404
- * Daloy app when your MCP server has a different trust boundary than your REST
405
- * API.
579
+ * The handler serves **both MCP protocol eras** on one endpoint:
580
+ *
581
+ * - **Modern (`2026-07-28`+, stateless).** No handshake. Every request carries
582
+ * its protocol version, client identity, and client capabilities in `_meta`,
583
+ * mirrored into the required `MCP-Protocol-Version`, `Mcp-Method`, and
584
+ * `Mcp-Name` headers. Methods: `server/discover`, `tools/list`,
585
+ * `tools/call`, `resources/list`, `resources/templates/list`,
586
+ * `resources/read`, `prompts/list`, `prompts/get`. Every result carries
587
+ * `resultType`, the server identity in `_meta`, and on cacheable methods —
588
+ * `ttlMs` / `cacheScope`. Handlers may return an
589
+ * {@link McpInputRequiredResult} to run a multi round-trip request.
590
+ * - **Legacy (`2025-11-25` and earlier).** The `initialize` / `ping` handshake
591
+ * protocol, unchanged, so existing clients keep working.
592
+ *
593
+ * A request is served as modern when its `_meta` protocol version (or the
594
+ * `MCP-Protocol-Version` header) is `2026-07-28` or later; otherwise it takes
595
+ * the legacy path.
596
+ *
597
+ * Security, on top of the era-independent body cap, prototype-pollution-safe
598
+ * parsing, and `inputSchema` enforcement:
599
+ *
600
+ * - Per the Streamable HTTP spec's DNS-rebinding guidance, every request
601
+ * bearing an `Origin` header is validated. Loopback origins pass; anything
602
+ * else is rejected with `403` unless listed in
603
+ * {@link McpHandlerOptions.allowedOrigins}.
604
+ * - Modern requests are rejected with `400` and `-32020` (`HeaderMismatch`)
605
+ * when a required standard header is missing or disagrees with the body.
606
+ * This closes the header/body confusion gap that lets a gateway route on one
607
+ * value while the server executes another.
608
+ * - `Mcp-Session-Id` and `Last-Event-ID` are ignored; no session is ever minted
609
+ * or echoed.
610
+ *
611
+ * It intentionally does not spawn stdio servers, manage OAuth metadata, open
612
+ * `subscriptions/listen` notification streams, or implement the tasks
613
+ * extension. Use DaloyJS middleware for authentication and authorization, and
614
+ * run this on a dedicated Daloy app when your MCP server has a different trust
615
+ * boundary than your REST API.
406
616
  *
407
617
  * @param options - Server identity, capabilities, limits, and response headers.
408
618
  * @returns A Fetch-compatible request handler suitable for {@link mcpRoutes}
409
619
  * or for direct use in any web-standard runtime.
410
620
  * @throws {TypeError} at construction for invalid serverInfo, protocol
411
- * versions, body limits, duplicate names/URIs, malformed `allowedOrigins`
412
- * entries, or unsupported URI template expressions.
621
+ * versions, body limits, cache hints, extension identifiers, duplicate
622
+ * names/URIs, malformed `allowedOrigins` entries, invalid `x-mcp-header`
623
+ * annotations, or unsupported URI template expressions.
413
624
  *
414
625
  * @example
415
626
  * ```ts
@@ -471,6 +682,30 @@ export function createMcpHandler(options) {
471
682
  throw new TypeError("MCP resource template URIs must be unique.");
472
683
  }
473
684
  const compiledTemplates = resourceTemplates.map(compileUriTemplate);
685
+ // Compile x-mcp-header annotations once so tools/call can verify that the
686
+ // mirrored Mcp-Param-* headers agree with the body on every request.
687
+ const toolHeaderParams = new Map();
688
+ for (const tool of tools) {
689
+ const params = collectHeaderParams(tool);
690
+ if (params.length > 0)
691
+ toolHeaderParams.set(tool.name, params);
692
+ }
693
+ const extensions = options.extensions;
694
+ if (extensions) {
695
+ for (const identifier of Object.keys(extensions)) {
696
+ if (!identifier.includes("/") || identifier.startsWith("/")) {
697
+ throw new TypeError(`MCP extension identifier "${identifier}" must carry a reverse-DNS prefix, e.g. "io.modelcontextprotocol/tasks".`);
698
+ }
699
+ }
700
+ }
701
+ const cacheTtlMs = options.cache?.ttlMs ?? 0;
702
+ if (!Number.isSafeInteger(cacheTtlMs) || cacheTtlMs < 0) {
703
+ throw new TypeError("MCP cache.ttlMs must be a non-negative safe integer.");
704
+ }
705
+ const cacheScope = options.cache?.scope ?? "private";
706
+ if (cacheScope !== "public" && cacheScope !== "private") {
707
+ throw new TypeError('MCP cache.scope must be "public" or "private".');
708
+ }
474
709
  const allowedOrigins = new Set();
475
710
  for (const entry of options.allowedOrigins ?? []) {
476
711
  const normalized = entry.toLowerCase();
@@ -496,21 +731,122 @@ export function createMcpHandler(options) {
496
731
  const legacyAssumed = supported.has(LEGACY_ASSUMED_PROTOCOL_VERSION)
497
732
  ? LEGACY_ASSUMED_PROTOCOL_VERSION
498
733
  : preferred;
734
+ const capabilities = Object.freeze({
735
+ ...(tools.length > 0 ? { tools: {} } : {}),
736
+ ...(resources.length > 0 || resourceTemplates.length > 0 ? { resources: {} } : {}),
737
+ ...(prompts.length > 0 ? { prompts: {} } : {}),
738
+ ...(extensions ? { extensions } : {}),
739
+ });
740
+ const serverInfoMeta = Object.freeze({ [MCP_META_KEYS.serverInfo]: options.serverInfo });
499
741
  async function handleRpcRequest(message, request) {
500
742
  const id = (message.id ?? null);
501
743
  const method = message.method;
502
744
  const params = asRecord(message.params);
745
+ const meta = asRecord(params._meta);
503
746
  const headerVersion = request.headers.get("mcp-protocol-version");
504
- // Per the Streamable HTTP spec, a request without the header is assumed
505
- // to speak 2025-03-26; `initialize` negotiates via params instead.
506
- const protocolVersion = method === "initialize"
507
- ? selectedProtocolVersion(typeof params.protocolVersion === "string"
508
- ? params.protocolVersion
509
- : (headerVersion ?? ""), supported, preferred)
510
- : headerVersion !== null
511
- ? selectedProtocolVersion(headerVersion, supported, preferred)
512
- : legacyAssumed;
513
- const ctx = { request, protocolVersion, id, method };
747
+ const metaVersion = meta[MCP_META_KEYS.protocolVersion];
748
+ // Era selection: a request speaks the stateless revision when either the
749
+ // `_meta` version or the transport header names 2026-07-28 or later. Every
750
+ // other request keeps the handshake-based behavior unchanged.
751
+ const era = (typeof metaVersion === "string" && isModernProtocolVersion(metaVersion)) ||
752
+ (headerVersion !== null && isModernProtocolVersion(headerVersion))
753
+ ? "modern"
754
+ : "legacy";
755
+ if (era === "modern") {
756
+ const modernError = validateModernRequest(request, method, params, meta, id);
757
+ if (modernError)
758
+ return modernError;
759
+ }
760
+ else {
761
+ // Legacy revisions predate the standard headers, so they are optional
762
+ // here — but a legacy request that sends them is still held to them.
763
+ // Otherwise declaring an old protocol version would be a free bypass of
764
+ // the header/body agreement an intermediary in front of us relies on.
765
+ const headerError = validateStandardHeaders(request, method, params, id, false);
766
+ if (headerError)
767
+ return headerError;
768
+ }
769
+ // Per the Streamable HTTP spec, a legacy request without the header is
770
+ // assumed to speak 2025-03-26; `initialize` negotiates via params instead.
771
+ const protocolVersion = era === "modern"
772
+ ? metaVersion
773
+ : method === "initialize"
774
+ ? selectedProtocolVersion(typeof params.protocolVersion === "string"
775
+ ? params.protocolVersion
776
+ : (headerVersion ?? ""), supported, preferred)
777
+ : headerVersion !== null
778
+ ? selectedProtocolVersion(headerVersion, supported, preferred)
779
+ : legacyAssumed;
780
+ const ctx = {
781
+ request,
782
+ protocolVersion,
783
+ era,
784
+ id,
785
+ method,
786
+ clientCapabilities: era === "modern" ? asRecord(meta[MCP_META_KEYS.clientCapabilities]) : {},
787
+ };
788
+ if (era === "modern") {
789
+ const clientInfo = meta[MCP_META_KEYS.clientInfo];
790
+ if (clientInfo !== null && typeof clientInfo === "object" && !Array.isArray(clientInfo)) {
791
+ ctx.clientInfo = clientInfo;
792
+ }
793
+ const logLevel = meta[MCP_META_KEYS.logLevel];
794
+ if (typeof logLevel === "string")
795
+ ctx.logLevel = logLevel;
796
+ const inputResponses = params.inputResponses;
797
+ if (inputResponses !== null &&
798
+ typeof inputResponses === "object" &&
799
+ !Array.isArray(inputResponses)) {
800
+ ctx.inputResponses = inputResponses;
801
+ }
802
+ if (typeof params.requestState === "string")
803
+ ctx.requestState = params.requestState;
804
+ }
805
+ /**
806
+ * Finalize a successful result. Modern results gain the required
807
+ * `resultType`, the server identity in `_meta`, and — on cacheable
808
+ * methods — the client-caching hints. Legacy results are untouched.
809
+ */
810
+ const ok = (result, cacheable = false) => rpcResult(id, era === "modern"
811
+ ? {
812
+ resultType: "complete",
813
+ ...result,
814
+ ...(cacheable ? { ttlMs: cacheTtlMs, cacheScope } : {}),
815
+ _meta: serverInfoMeta,
816
+ }
817
+ : result, headers);
818
+ /**
819
+ * Turn a handler-supplied interim result into an `input_required`
820
+ * response, refusing to ask for input the client cannot provide.
821
+ */
822
+ const inputRequired = (interim) => {
823
+ if (era !== "modern" || !MRTR_METHODS.has(method)) {
824
+ return rpcError(id, INTERNAL_ERROR, "Multi round-trip results require MCP 2026-07-28 on tools/call, resources/read, or prompts/get.", undefined, 200, headers);
825
+ }
826
+ const requests = interim.inputRequests;
827
+ const hasRequests = requests !== undefined && Object.keys(requests).length > 0;
828
+ if (!hasRequests && interim.requestState === undefined) {
829
+ return rpcError(id, INTERNAL_ERROR, "An input_required result must carry inputRequests or requestState.", undefined, 200, headers);
830
+ }
831
+ if (hasRequests) {
832
+ const missing = [];
833
+ for (const key of Object.keys(requests)) {
834
+ const needed = INPUT_REQUEST_CAPABILITY[requests[key]?.method ?? ""];
835
+ if (needed && ctx.clientCapabilities[needed] === undefined && !missing.includes(needed)) {
836
+ missing.push(needed);
837
+ }
838
+ }
839
+ if (missing.length > 0) {
840
+ return rpcError(id, MISSING_REQUIRED_CLIENT_CAPABILITY, `Client did not declare required capabilities: ${missing.join(", ")}`, { requiredCapabilities: missing }, 400, headers);
841
+ }
842
+ }
843
+ return rpcResult(id, {
844
+ resultType: "input_required",
845
+ ...(hasRequests ? { inputRequests: requests } : {}),
846
+ ...(interim.requestState !== undefined ? { requestState: interim.requestState } : {}),
847
+ _meta: serverInfoMeta,
848
+ }, headers);
849
+ };
514
850
  const cursor = params.cursor;
515
851
  if (cursor !== undefined &&
516
852
  (method === "tools/list" ||
@@ -521,22 +857,33 @@ export function createMcpHandler(options) {
521
857
  // client-supplied cursor is unknown by definition.
522
858
  return rpcError(id, INVALID_PARAMS, "Unknown pagination cursor.", undefined, 200, headers);
523
859
  }
860
+ // `initialize` and `ping` were removed in 2026-07-28; `server/discover`
861
+ // exists only there. Answering each in the wrong era would let a client
862
+ // infer a handshake or a session that this endpoint does not have.
863
+ if (era === "modern" && (method === "initialize" || method === "ping")) {
864
+ return rpcError(id, METHOD_NOT_FOUND, `Method not found: ${method}`, { supported: protocolVersions }, 404, headers);
865
+ }
866
+ if (era === "legacy" && method === "server/discover") {
867
+ return rpcError(id, METHOD_NOT_FOUND, "Method not found: server/discover", { supported: protocolVersions }, 200, headers);
868
+ }
524
869
  switch (method) {
870
+ case "server/discover":
871
+ return ok({
872
+ supportedVersions: protocolVersions,
873
+ capabilities,
874
+ ...(options.instructions ? { instructions: options.instructions } : {}),
875
+ }, true);
525
876
  case "initialize":
526
877
  return rpcResult(id, {
527
878
  protocolVersion,
528
- capabilities: {
529
- ...(tools.length > 0 ? { tools: {} } : {}),
530
- ...(resources.length > 0 || resourceTemplates.length > 0 ? { resources: {} } : {}),
531
- ...(prompts.length > 0 ? { prompts: {} } : {}),
532
- },
879
+ capabilities,
533
880
  serverInfo: options.serverInfo,
534
881
  ...(options.instructions ? { instructions: options.instructions } : {}),
535
882
  }, headers);
536
883
  case "ping":
537
884
  return rpcResult(id, {}, headers);
538
885
  case "tools/list":
539
- return rpcResult(id, { tools: tools.map(publicTool) }, headers);
886
+ return ok({ tools: tools.map(publicTool) }, true);
540
887
  case "tools/call": {
541
888
  const name = typeof params.name === "string" ? params.name : "";
542
889
  const tool = toolMap.get(name);
@@ -553,21 +900,33 @@ export function createMcpHandler(options) {
553
900
  if (validationErrors.length > 0) {
554
901
  return rpcError(id, INVALID_PARAMS, `Invalid arguments for tool "${name}": ${validationErrors[0]}`, { validationErrors }, 200, headers);
555
902
  }
903
+ // The mirrored Mcp-Param-* headers must agree with the arguments the
904
+ // handler is about to run on, so an intermediary cannot route on one
905
+ // value while the tool executes another. Legacy requests are not
906
+ // required to send them, but any they do send must still match.
907
+ {
908
+ const mismatch = validateMirroredParams(request, tool, asRecord(rawArgs), era === "modern");
909
+ if (mismatch) {
910
+ return rpcError(id, HEADER_MISMATCH, mismatch, undefined, 400, headers);
911
+ }
912
+ }
556
913
  try {
557
914
  const result = await tool.handler(asRecord(rawArgs), ctx);
558
- return rpcResult(id, normalizeToolResult(result), headers);
915
+ if (isInputRequiredResult(result))
916
+ return inputRequired(result);
917
+ return ok({ ...normalizeToolResult(result) });
559
918
  }
560
919
  catch (error) {
561
920
  if (error instanceof McpToolError) {
562
- return rpcResult(id, { content: [{ type: "text", text: error.message }], isError: true }, headers);
921
+ return ok({ content: [{ type: "text", text: error.message }], isError: true });
563
922
  }
564
923
  return rpcError(id, INTERNAL_ERROR, "Tool execution failed.", safeInternalErrorData(error, exposeInternalErrors), 200, headers);
565
924
  }
566
925
  }
567
926
  case "resources/list":
568
- return rpcResult(id, { resources: resources.map(publicResource) }, headers);
927
+ return ok({ resources: resources.map(publicResource) }, true);
569
928
  case "resources/templates/list":
570
- return rpcResult(id, { resourceTemplates: resourceTemplates.map(publicResourceTemplate) }, headers);
929
+ return ok({ resourceTemplates: resourceTemplates.map(publicResourceTemplate) }, true);
571
930
  case "resources/read": {
572
931
  const uri = typeof params.uri === "string" ? params.uri : "";
573
932
  const readError = (error) => {
@@ -581,7 +940,9 @@ export function createMcpHandler(options) {
581
940
  if (resource) {
582
941
  try {
583
942
  const read = await resource.read(ctx);
584
- return rpcResult(id, { contents: Array.isArray(read) ? read : [read] }, headers);
943
+ if (isInputRequiredResult(read))
944
+ return inputRequired(read);
945
+ return ok({ contents: Array.isArray(read) ? read : [read] }, true);
585
946
  }
586
947
  catch (error) {
587
948
  return readError(error);
@@ -598,7 +959,9 @@ export function createMcpHandler(options) {
598
959
  });
599
960
  try {
600
961
  const read = await compiled.template.read(uri, variables, ctx);
601
- return rpcResult(id, { contents: Array.isArray(read) ? read : [read] }, headers);
962
+ if (isInputRequiredResult(read))
963
+ return inputRequired(read);
964
+ return ok({ contents: Array.isArray(read) ? read : [read] }, true);
602
965
  }
603
966
  catch (error) {
604
967
  return readError(error);
@@ -608,7 +971,7 @@ export function createMcpHandler(options) {
608
971
  return rpcError(id, INVALID_PARAMS, `Unknown resource: ${uri || "<missing>"}`, undefined, 200, headers);
609
972
  }
610
973
  case "prompts/list":
611
- return rpcResult(id, { prompts: prompts.map(publicPrompt) }, headers);
974
+ return ok({ prompts: prompts.map(publicPrompt) }, true);
612
975
  case "prompts/get": {
613
976
  const name = typeof params.name === "string" ? params.name : "";
614
977
  const prompt = promptMap.get(name);
@@ -623,7 +986,10 @@ export function createMcpHandler(options) {
623
986
  return rpcError(id, INVALID_PARAMS, `Missing required prompt arguments: ${missing.join(", ")}`, undefined, 200, headers);
624
987
  }
625
988
  try {
626
- return rpcResult(id, await prompt.get(promptArgs, ctx), headers);
989
+ const rendered = await prompt.get(promptArgs, ctx);
990
+ if (isInputRequiredResult(rendered))
991
+ return inputRequired(rendered);
992
+ return ok({ ...rendered });
627
993
  }
628
994
  catch (error) {
629
995
  const message = error instanceof McpToolError ? error.message : "Prompt rendering failed.";
@@ -634,8 +1000,142 @@ export function createMcpHandler(options) {
634
1000
  }
635
1001
  }
636
1002
  default:
637
- return rpcError(id, METHOD_NOT_FOUND, `Method not found: ${method}`, undefined, 200, headers);
1003
+ // 2026-07-28 maps an unimplemented RPC to HTTP 404 so a client can
1004
+ // tell "modern server, unknown method" from a legacy 404.
1005
+ return rpcError(id, METHOD_NOT_FOUND, `Method not found: ${method}`, undefined, era === "modern" ? 404 : 200, headers);
1006
+ }
1007
+ }
1008
+ /**
1009
+ * Enforce the 2026-07-28 per-request contract before any handler runs:
1010
+ * required `_meta` fields, and the standard headers that intermediaries are
1011
+ * allowed to route on.
1012
+ *
1013
+ * @returns A JSON-RPC error response, or `undefined` when the request is
1014
+ * well-formed.
1015
+ */
1016
+ function validateModernRequest(request, method, params, meta, id) {
1017
+ const mismatch = (message) => rpcError(id, HEADER_MISMATCH, message, undefined, 400, headers);
1018
+ const metaVersion = meta[MCP_META_KEYS.protocolVersion];
1019
+ if (typeof metaVersion !== "string") {
1020
+ return rpcError(id, INVALID_PARAMS, `Missing required _meta field "${MCP_META_KEYS.protocolVersion}".`, undefined, 400, headers);
1021
+ }
1022
+ if (!supported.has(metaVersion)) {
1023
+ return rpcError(id, UNSUPPORTED_PROTOCOL_VERSION, `Unsupported protocol version: ${metaVersion}`, { supported: protocolVersions, requested: metaVersion }, 400, headers);
1024
+ }
1025
+ const capabilitiesMeta = meta[MCP_META_KEYS.clientCapabilities];
1026
+ if (capabilitiesMeta === null ||
1027
+ typeof capabilitiesMeta !== "object" ||
1028
+ Array.isArray(capabilitiesMeta)) {
1029
+ return rpcError(id, INVALID_PARAMS, `Missing required _meta field "${MCP_META_KEYS.clientCapabilities}".`, undefined, 400, headers);
638
1030
  }
1031
+ const headerVersion = request.headers.get("mcp-protocol-version");
1032
+ if (headerVersion === null) {
1033
+ return mismatch("Missing required header: MCP-Protocol-Version");
1034
+ }
1035
+ if (headerVersion !== metaVersion) {
1036
+ return mismatch(`Header mismatch: MCP-Protocol-Version header value '${headerVersion}' does not match body value '${metaVersion}'`);
1037
+ }
1038
+ const headerError = validateStandardHeaders(request, method, params, id, true);
1039
+ if (headerError)
1040
+ return headerError;
1041
+ if (typeof params.requestState === "string" &&
1042
+ params.requestState.length > MCP_MAX_REQUEST_STATE_LENGTH) {
1043
+ return rpcError(id, INVALID_PARAMS, `requestState exceeds ${MCP_MAX_REQUEST_STATE_LENGTH} characters.`, undefined, 400, headers);
1044
+ }
1045
+ return undefined;
1046
+ }
1047
+ /**
1048
+ * Validate the `Mcp-Method` / `Mcp-Name` headers against the request body.
1049
+ *
1050
+ * These headers exist so intermediaries can route, authorize, and rate-limit
1051
+ * without parsing the body; letting a header disagree with the body is what
1052
+ * turns that convenience into a confused-deputy bug.
1053
+ *
1054
+ * `require` is `true` for modern requests, where the specification makes both
1055
+ * headers mandatory. It is `false` for legacy requests, which predate the
1056
+ * headers entirely — but a legacy request that *does* carry them is still
1057
+ * held to them. That closes the obvious downgrade: an attacker cannot declare
1058
+ * an older protocol version to keep a gateway-satisfying header while the
1059
+ * server executes a different body value.
1060
+ *
1061
+ * @returns A JSON-RPC error response, or `undefined` when the headers agree
1062
+ * with the body (or are legitimately absent on a legacy request).
1063
+ */
1064
+ function validateStandardHeaders(request, method, params, id, require) {
1065
+ const mismatch = (message) => rpcError(id, HEADER_MISMATCH, message, undefined, 400, headers);
1066
+ const headerMethod = request.headers.get("mcp-method");
1067
+ if (headerMethod === null) {
1068
+ if (require)
1069
+ return mismatch("Missing required header: Mcp-Method");
1070
+ }
1071
+ else if (headerMethod !== method) {
1072
+ return mismatch(`Header mismatch: Mcp-Method header value '${headerMethod}' does not match body value '${method}'`);
1073
+ }
1074
+ // Mcp-Name mirrors params.name (tools/prompts) or params.uri (resources).
1075
+ if (method === "tools/call" || method === "prompts/get" || method === "resources/read") {
1076
+ const bodyName = method === "resources/read" ? params.uri : params.name;
1077
+ const rawName = request.headers.get("mcp-name");
1078
+ if (rawName === null) {
1079
+ if (require)
1080
+ return mismatch("Missing required header: Mcp-Name");
1081
+ return undefined;
1082
+ }
1083
+ const decoded = decodeMcpHeaderValue(rawName);
1084
+ if (decoded === undefined) {
1085
+ return mismatch("Header mismatch: Mcp-Name is not valid Base64-encoded UTF-8");
1086
+ }
1087
+ if (typeof bodyName !== "string" || decoded !== bodyName) {
1088
+ return mismatch("Header mismatch: Mcp-Name header value does not match the request body");
1089
+ }
1090
+ }
1091
+ return undefined;
1092
+ }
1093
+ /**
1094
+ * Verify that every `x-mcp-header` mirrored tool parameter matches its
1095
+ * `Mcp-Param-{Name}` header.
1096
+ *
1097
+ * When `require` is `true` (modern requests) the check runs in both
1098
+ * directions: a value present in the arguments requires the header, and an
1099
+ * absent value forbids it. When `require` is `false` (legacy requests, which
1100
+ * predate mirroring) a missing header is accepted, but a header that *is*
1101
+ * present must still match the arguments — so declaring an older protocol
1102
+ * version cannot be used to slip a gateway-satisfying header past the tool.
1103
+ *
1104
+ * @returns A human-readable mismatch description, or `undefined` when the
1105
+ * headers agree with the arguments.
1106
+ */
1107
+ function validateMirroredParams(request, tool, args, require) {
1108
+ const mirrored = toolHeaderParams.get(tool.name);
1109
+ if (!mirrored)
1110
+ return undefined;
1111
+ for (const param of mirrored) {
1112
+ const raw = request.headers.get(param.headerKey);
1113
+ const value = valueAtPath(args, param.path);
1114
+ if (value === undefined || value === null) {
1115
+ if (raw !== null) {
1116
+ return `Header mismatch: Mcp-Param-${param.name} was sent but "${param.path.join(".")}" is absent from the arguments`;
1117
+ }
1118
+ continue;
1119
+ }
1120
+ if (raw === null) {
1121
+ if (require)
1122
+ return `Header mismatch: missing required header Mcp-Param-${param.name}`;
1123
+ continue;
1124
+ }
1125
+ const decoded = decodeMcpHeaderValue(raw);
1126
+ if (decoded === undefined) {
1127
+ return `Header mismatch: Mcp-Param-${param.name} is not valid Base64-encoded UTF-8`;
1128
+ }
1129
+ // Integers compare numerically ("42.0" equals 42); strings and booleans
1130
+ // compare against their canonical string form.
1131
+ const matches = param.type === "integer"
1132
+ ? typeof value === "number" && Number(decoded) === value
1133
+ : decoded === String(value);
1134
+ if (!matches) {
1135
+ return `Header mismatch: Mcp-Param-${param.name} header value does not match the request body`;
1136
+ }
1137
+ }
1138
+ return undefined;
639
1139
  }
640
1140
  return async function handleMcpRequest(request) {
641
1141
  // Streamable HTTP requires Origin validation on every request to defeat
@@ -651,6 +1151,8 @@ export function createMcpHandler(options) {
651
1151
  });
652
1152
  }
653
1153
  if (request.method === "GET") {
1154
+ // 2026-07-28 removed the standalone GET stream; older clients that still
1155
+ // try it get 405 plus a human-readable pointer at the POST endpoint.
654
1156
  return jsonResponse({
655
1157
  transport: "streamable-http",
656
1158
  protocolVersions,
@@ -660,7 +1162,7 @@ export function createMcpHandler(options) {
660
1162
  resourceTemplates: resourceTemplates.map((template) => template.uriTemplate),
661
1163
  prompts: prompts.map((prompt) => prompt.name),
662
1164
  },
663
- hint: "Send JSON-RPC 2.0 over HTTP POST to this endpoint.",
1165
+ hint: "Send JSON-RPC 2.0 over HTTP POST to this endpoint; call server/discover for capabilities.",
664
1166
  }, 405, { allow: "POST, OPTIONS", ...(headers ?? {}) });
665
1167
  }
666
1168
  if (request.method !== "POST") {
@@ -675,7 +1177,10 @@ export function createMcpHandler(options) {
675
1177
  }
676
1178
  const protocolHeader = request.headers.get("mcp-protocol-version");
677
1179
  if (protocolHeader && !supported.has(protocolHeader)) {
678
- return rpcError(null, INVALID_REQUEST, `Unsupported MCP-Protocol-Version: ${protocolHeader}`, { supported: protocolVersions }, 400, headers);
1180
+ // 2026-07-28 requires an UnsupportedProtocolVersionError naming the
1181
+ // versions this server does implement, so the client can retry on a
1182
+ // mutually supported revision instead of guessing.
1183
+ return rpcError(null, UNSUPPORTED_PROTOCOL_VERSION, `Unsupported protocol version: ${protocolHeader}`, { supported: protocolVersions, requested: protocolHeader }, 400, headers);
679
1184
  }
680
1185
  const declaredLength = Number(request.headers.get("content-length") ?? "");
681
1186
  if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {