@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.
- package/README.md +25 -14
- package/dist/adapters/bun.js +1 -2
- package/dist/adapters/node.js +16 -30
- package/dist/app.d.ts +5 -1
- package/dist/app.js +74 -6
- package/dist/auto-ban.d.ts +16 -0
- package/dist/auto-ban.js +20 -12
- package/dist/bot-guard.d.ts +14 -0
- package/dist/bot-guard.js +12 -12
- package/dist/cli.js +9 -6
- package/dist/concurrency-limit.d.ts +14 -0
- package/dist/concurrency-limit.js +18 -9
- package/dist/config.js +1 -3
- package/dist/conn-info.d.ts +65 -0
- package/dist/conn-info.js +99 -4
- package/dist/errors.js +2 -5
- package/dist/etag.js +12 -2
- package/dist/geo-block.d.ts +15 -0
- package/dist/geo-block.js +14 -19
- package/dist/hashing.js +1 -1
- package/dist/http-signatures.js +3 -8
- package/dist/index.d.ts +5 -5
- package/dist/index.js +4 -4
- package/dist/ip-reputation.d.ts +14 -0
- package/dist/ip-reputation.js +11 -11
- package/dist/ip-restriction.d.ts +14 -0
- package/dist/ip-restriction.js +7 -18
- package/dist/jwt.js +12 -14
- package/dist/logger.js +1 -3
- package/dist/mcp.d.ts +305 -34
- package/dist/mcp.js +554 -49
- package/dist/middleware.d.ts +31 -1
- package/dist/middleware.js +21 -19
- package/dist/multipart.js +9 -12
- package/dist/openapi.d.ts +1 -1
- package/dist/openapi.js +2 -2
- package/dist/rate-limit-redis.d.ts +4 -4
- package/dist/response-cache.d.ts +179 -21
- package/dist/response-cache.js +338 -29
- package/dist/safe-redirect.js +3 -1
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security-schemes.js +1 -2
- package/dist/subdomains.js +1 -4
- package/dist/tenancy.d.ts +40 -0
- package/dist/tenancy.js +54 -3
- package/dist/waf.js +40 -8
- package/dist/webhook-delivery.js +19 -3
- package/dist/websocket.d.ts +8 -0
- package/dist/websocket.js +19 -4
- 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/
|
|
5
|
+
* @see https://modelcontextprotocol.io/specification/2026-07-28
|
|
6
6
|
* @since 1.0.0
|
|
7
7
|
*/
|
|
8
|
-
export const MCP_PROTOCOL_VERSION = "
|
|
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
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
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,
|
|
412
|
-
*
|
|
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
|
-
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
927
|
+
return ok({ resources: resources.map(publicResource) }, true);
|
|
569
928
|
case "resources/templates/list":
|
|
570
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|