diff --git a/.changeset/client-http-stream-close-cancel.md b/.changeset/client-http-stream-close-cancel.md new file mode 100644 index 0000000000..3af099e644 --- /dev/null +++ b/.changeset/client-http-stream-close-cancel.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +--- + +Client request cancellation on a 2026-07-28 Streamable HTTP connection now closes that request's SSE response stream — the spec cancellation signal — instead of POSTing `notifications/cancelled`. Cancellation on a 2025-era connection, and on stdio at any era, still sends `notifications/cancelled` as before. Adds the optional `Transport.hasPerRequestStream` capability flag (set on `StreamableHTTPClientTransport`) for the protocol layer to route the per-transport cancel path. diff --git a/.changeset/sep-2243-mcp-param-client.md b/.changeset/sep-2243-mcp-param-client.md new file mode 100644 index 0000000000..ac346294a9 --- /dev/null +++ b/.changeset/sep-2243-mcp-param-client.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +--- + +SEP-2243 `Mcp-Param-*` client-side mirroring (protocol revision 2026-07-28). On a 2026-07-28 connection over Streamable HTTP, `Client.callTool()` now mirrors tool arguments designated with `x-mcp-header` in the tool's `inputSchema` into `Mcp-Param-{Name}` HTTP headers (with the spec's `=?base64?…?=` sentinel encoding for values that are not safe plain-ASCII field values), and `Client.listTools()` excludes tool definitions whose `x-mcp-header` declarations violate the spec's constraints (logging a warning naming the tool and the reason). The legacy-era `callTool` and `listTools` paths are unchanged. Browser environments skip mirroring (dynamically named headers cannot be statically allow-listed for credentialed CORS); a conforming SEP-2243 server will reject a `tools/call` whose body carries a non-null value for an `x-mcp-header` parameter when the matching header is absent, so calling such a tool with that argument from a browser is a known limitation. New `CallToolRequestOptions.toolDefinition` lets callers supply the tool definition directly so mirroring can run without a prior `tools/list`. `TransportSendOptions.headers` is added (additive, optional) for per-request HTTP headers; the Streamable HTTP transport skips reserved standard/auth header names (`authorization`, `mcp-protocol-version`, `mcp-method`, `mcp-name`, `mcp-session-id`, `content-type`) and surfaces an HTTP 400 carrying a JSON-RPC error response in-band as a `ProtocolError`; transports that share a single channel (stdio, in-memory) ignore it. diff --git a/.changeset/sep-2243-mcp-param-server.md b/.changeset/sep-2243-mcp-param-server.md new file mode 100644 index 0000000000..796b42ec53 --- /dev/null +++ b/.changeset/sep-2243-mcp-param-server.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': minor +--- + +SEP-2243 `Mcp-Param-*` server-side validation (protocol revision 2026-07-28). On the modern (2026-07-28) serving path, `createMcpHandler` now validates `Mcp-Param-{Name}` headers against the named tool's `x-mcp-header` declarations and the body `arguments` before dispatch: a missing header for a present body value, a header that decodes to a different value than the body, or an invalid `=?base64?…?=` sentinel is rejected with `400 Bad Request` and JSON-RPC `-32001` (`HeaderMismatch`) — the same shape the existing standard-header cross-checks emit. A `null`/absent body value passes regardless of any header (the spec's "server MUST NOT expect the header" rows). `McpServer.registerTool` now warns at registration time when an `x-mcp-header` declaration violates the spec's constraints. The 2025-era serving paths and the low-level `Server` factory shape are unchanged. diff --git a/.changeset/sep-2243-std-header-server.md b/.changeset/sep-2243-std-header-server.md new file mode 100644 index 0000000000..dd2cd37967 --- /dev/null +++ b/.changeset/sep-2243-std-header-server.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': minor +--- + +SEP-2243 standard-header server-side validation (protocol revision 2026-07-28). On the modern (2026-07-28) serving path, `createMcpHandler` now enforces the required `Mcp-Method` and `Mcp-Name` standard request headers in addition to the existing `MCP-Protocol-Version` and `Mcp-Method` cross-checks: a modern request without an `Mcp-Method` header, a `tools/call` / `prompts/get` / `resources/read` request without an `Mcp-Name` header, an `Mcp-Name` header carrying an invalid `=?base64?…?=` sentinel, and an `Mcp-Name` header whose (decoded) value disagrees with the body's `params.name` / `params.uri` are all rejected with `400 Bad Request` and JSON-RPC `-32001` (`HeaderMismatch`). The 2025-era serving paths are unchanged. diff --git a/.changeset/server-ctx-log-request-related.md b/.changeset/server-ctx-log-request-related.md new file mode 100644 index 0000000000..6edae4dcd0 --- /dev/null +++ b/.changeset/server-ctx-log-request-related.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +`ctx.mcpReq.log()` now emits its `notifications/message` notification request-related (like progress and `ctx.mcpReq.notify`), so handler-emitted log messages are delivered when the server is hosted per request via `createMcpHandler` instead of being silently dropped. On a 2026-07-28 request the level filter consults the per-request `_meta` `io.modelcontextprotocol/logLevel` key (the modern equivalent of `logging/setLevel`). The session-scoped `Server.sendLoggingMessage()` API is unchanged. diff --git a/docs/client.md b/docs/client.md index 6b7c0df110..92f39a78cd 100644 --- a/docs/client.md +++ b/docs/client.md @@ -307,6 +307,15 @@ const result = await client.callTool( console.log(result.content); ``` +### `x-mcp-header` parameter mirroring (2026-07-28 draft) + +On a 2026-07-28 connection over Streamable HTTP, `callTool()` mirrors any argument whose `inputSchema` property carries an `x-mcp-header` annotation into an `Mcp-Param-{Name}` HTTP request header so intermediaries can route on it without parsing the body. The mirrored headers +are built from the most recent `listTools()` result; if you already hold the tool definition (e.g. from configuration), pass it via `CallToolRequestOptions.toolDefinition` so mirroring runs without a prior list. On a cache miss the call is sent without `Mcp-Param-*` headers and, +when a conforming server rejects it with `-32001` (`HeaderMismatch`), `callTool()` refreshes the definition cache once and retries. + +On a modern HTTP connection `listTools()` **excludes** tool definitions whose `x-mcp-header` declarations violate the spec's constraints, logging a warning that names the tool and the reason. Browser clients skip mirroring (dynamically named headers cannot be statically +allow-listed for credentialed CORS), so calling an `x-mcp-header` tool with a non-null designated argument from a browser against a server that enforces SEP-2243 validation will be rejected — a known limitation. The legacy-era `callTool`/`listTools` paths are unchanged. + ## Resources Resources are read-only data — files, database schemas, configuration — that your application can retrieve from a server and attach as context for the model (see [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) in the MCP overview). diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 14d9330dfd..2ee0d79c3b 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -560,6 +560,10 @@ side: auto-fulfilment is on by default (`ClientOptions.inputRequired`, `maxRound `Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` now return empty results when the server lacks the corresponding capability (instead of sending the request). Set `enforceStrictCapabilities: true` in `ClientOptions` to throw an error instead. +No code changes required; wire-behavior note: on a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request (caller `signal` / timeout) closes that request's SSE response stream as the spec cancellation signal — `notifications/cancelled` is no longer POSTed +there. 2025-era connections and stdio at any era still send `notifications/cancelled`. Custom `Transport` implementations that open one underlying request per outbound message and honor `TransportSendOptions.requestSignal` may declare `readonly hasPerRequestStream = true` to opt +into the same routing. + ### Server (Streamable HTTP transport) No code changes required; these are wire-behavior notes: diff --git a/docs/migration.md b/docs/migration.md index 2e79e8964b..2d13a4b904 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1030,16 +1030,16 @@ versionNegotiation: { } ``` -`maxRetries` governs timeout re-sends only (the spec-mandated `-32004` corrective continuation — select-and-continue with a mutual version — is a separate negotiation step and is never counted against it). Negotiation can also be configured pre-connect on an -already-constructed instance via `client.setVersionNegotiation(options)` (equivalent to the constructor option; throws after connecting). +`maxRetries` governs timeout re-sends only (the spec-mandated `-32004` corrective continuation — select-and-continue with a mutual version — is a separate negotiation step and is never counted against it). Negotiation can also be configured pre-connect on an already-constructed +instance via `client.setVersionNegotiation(options)` (equivalent to the constructor option; throws after connecting). -Once a modern era is negotiated, the client **automatically attaches the per-request `_meta` envelope** (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification — you never set it by hand. Any `_meta` keys you pass -in a request are preserved over the auto-attached ones. After connect, `client.getProtocolEra()` returns `'legacy'` or `'modern'` and `client.getNegotiatedProtocolVersion()` the exact revision. +Once a modern era is negotiated, the client **automatically attaches the per-request `_meta` envelope** (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification — you never set it by hand. Any `_meta` keys you pass in a +request are preserved over the auto-attached ones. After connect, `client.getProtocolEra()` returns `'legacy'` or `'modern'` and `client.getNegotiatedProtocolVersion()` the exact revision. On the server side, `server/discover` (advertising only the modern revisions) is served by instances hosted through one of the 2026-era serving entries; a hand-constructed `Server`/`McpServer` is byte-identical to before (it keeps answering `-32601`, and the `initialize` -handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and -other long-lived connections) is the `serveStdio` entry point described after that. The client can also issue `client.discover()` directly on a 2026-era connection; on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that -protocol revision. +handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and other +long-lived connections) is the `serveStdio` entry point described after that. The client can also issue `client.discover()` directly on a 2026-era connection; on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol +revision. ### Serving the 2026-07-28 draft revision over HTTP: `createMcpHandler` @@ -1099,6 +1099,21 @@ The entry performs no Origin/Host validation (see the origin-validation middlewa headers. Power users who want to compose routing themselves can use the exported `isLegacyRequest`, `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed around (`const { fetch } = handler`). +### `Mcp-Param-*` request-metadata headers (SEP-2243, 2026-07-28 draft) + +On a 2026-07-28 connection over Streamable HTTP, `Client.callTool()` mirrors tool arguments designated with `x-mcp-header` in the tool's `inputSchema` into `Mcp-Param-{Name}` HTTP request headers (Base64-sentinel-encoded where needed) so HTTP intermediaries can route on them +without parsing the body, and `createMcpHandler` rejects a `tools/call` whose `Mcp-Param-*` headers are missing for a present body value, malformed, or disagree with the body — `400 Bad Request` with JSON-RPC `-32001` (`HeaderMismatch`). The legacy-era serving paths and the +client's legacy-era `callTool`/`listTools` are unchanged. + +Two additive options support this: `CallToolRequestOptions.toolDefinition` (pass the tool definition directly so mirroring runs without a prior `tools/list`) and `TransportSendOptions.headers` (per-request HTTP headers; the Streamable HTTP transport skips the reserved +standard/auth header names so a per-request header cannot override `mcp-protocol-version`/`mcp-method`/`mcp-name`/`mcp-session-id`/`authorization`; transports that share a single channel — stdio, in-memory — ignore it). On a modern HTTP connection, `Client.listTools()` excludes +tool definitions whose `x-mcp-header` declarations violate the spec's constraints (logging a warning naming the tool and the reason). Browser clients skip mirroring (dynamically named headers cannot be statically allow-listed for credentialed CORS); calling an `x-mcp-header` +tool with a non-null designated argument from a browser against a conforming SEP-2243 server is therefore a known limitation. + +On the modern path, `createMcpHandler` also validates the SEP-2243 **standard** request-metadata headers against the body and rejects with the same `400` / `-32001` (`HeaderMismatch`) when the `MCP-Protocol-Version` or `Mcp-Method` header disagrees with the body, when the +required `Mcp-Method` header is absent, when the required `Mcp-Name` header is absent on a `tools/call` / `prompts/get` / `resources/read` request, and when the (Base64-sentinel-decoded) `Mcp-Name` value disagrees with `params.name` / `params.uri`. These checks only fire on the +modern (2026-07-28) serving path — 2025-era traffic is unchanged — and a hand-built modern HTTP request must carry the `Mcp-Method` (and where applicable `Mcp-Name`) header; the SDK client already sends them. + ### Serving the 2026-07-28 draft revision on stdio: `serveStdio` The server package ships a stdio entry point that mirrors `createMcpHandler` for long-lived connections: the entry owns the transport and the era decision, the client's opening exchange selects the era for the connection, and ONE instance from your factory is pinned to that @@ -1172,9 +1187,17 @@ const handler = createMcpHandler(() => buildServer()); handler.notify.toolsChanged(); ``` -**Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection the SDK auto-opens a `subscriptions/listen` stream whose filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities, so the same handlers -fire on every published change (the auto-opened subscription is exposed at `client.autoOpenedSubscription` for `close()`; when the intersection is empty auto-open is skipped and `autoOpenedSubscription` stays `undefined`). `client.listen(filter)` opens a stream explicitly and resolves once the server's acknowledged notification arrives with `{ honoredFilter, close(), closed }` (where `closed` is a `Promise<'local' | 'remote'>` that resolves once on termination — `'remote'` means the server cancelled, the stream ended, or the transport dropped, so re-listen if you still want events); change notifications dispatch to the existing `setNotificationHandler` -registrations. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request `notifications/resources/updated` via the `resourceSubscriptions` field of the listen filter instead. +**Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection the SDK auto-opens a `subscriptions/listen` stream whose filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities, so the same handlers fire +on every published change (the auto-opened subscription is exposed at `client.autoOpenedSubscription` for `close()`; when the intersection is empty auto-open is skipped and `autoOpenedSubscription` stays `undefined`). `client.listen(filter)` opens a stream explicitly and resolves +once the server's acknowledged notification arrives with `{ honoredFilter, close(), closed }` (where `closed` is a `Promise<'local' | 'remote'>` that resolves once on termination — `'remote'` means the server cancelled, the stream ended, or the transport dropped, so re-listen if +you still want events); change notifications dispatch to the existing `setNotificationHandler` registrations. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request `notifications/resources/updated` via the `resourceSubscriptions` field of the listen filter +instead. + +### Client cancellation on Streamable HTTP (2026-07-28): stream-close is the signal + +On a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request (caller `signal` or timeout) now closes that request's SSE response stream — the spec cancellation signal for this transport — instead of POSTing a `notifications/cancelled` message. Nothing to change in calling code: `RequestOptions.signal` and `timeout` behave exactly as before. Cancellation on a 2025-era +connection, and on stdio at any era, is unchanged and still sends `notifications/cancelled`. Custom `Transport` implementations that open one underlying request per outbound JSON-RPC request and honor `TransportSendOptions.requestSignal` may opt into the same routing by declaring +`readonly hasPerRequestStream = true`. ### Multi round-trip requests (2026-07-28): write-once handlers and the client auto-fulfilment driver diff --git a/docs/server.md b/docs/server.md index a66bffd079..e9e6ba4c42 100644 --- a/docs/server.md +++ b/docs/server.md @@ -100,7 +100,11 @@ const server = new McpServer( Tools let clients invoke actions on your server — they are usually the main way LLMs call into your application (see [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) in the MCP overview). -Register a tool with {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#registerTool | registerTool}. Provide an `inputSchema` (Zod) to validate arguments, and optionally an `outputSchema` for structured return values: +Register a tool with {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#registerTool | registerTool}. Provide an `inputSchema` (Zod) to validate arguments, and optionally an `outputSchema` for structured return values. + +> On the 2026-07-28 draft serving path, a tool whose `inputSchema` carries an `x-mcp-header` annotation has that argument mirrored into an `Mcp-Param-{Name}` HTTP request header by conforming clients. `createMcpHandler` validates those headers before dispatch and rejects a +> `tools/call` whose `Mcp-Param-*` headers are missing for a present body value, malformed, or disagree with the body — `400 Bad Request` with JSON-RPC `-32001` (`HeaderMismatch`). `registerTool` warns at registration time when an `x-mcp-header` declaration violates the +> spec's constraints. The 2025-era serving paths and the low-level `Server` factory shape are unchanged. ```ts source="../examples/guides/serverGuide.examples.ts#registerTool_basic" server.registerTool( diff --git a/examples/json-response/client.ts b/examples/json-response/client.ts index 147d91f097..d047a0a59d 100644 --- a/examples/json-response/client.ts +++ b/examples/json-response/client.ts @@ -18,7 +18,8 @@ runClient('json-response', async () => { headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', - 'mcp-protocol-version': '2026-07-28' + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': 'tools/list' }, body: JSON.stringify({ jsonrpc: '2.0', diff --git a/examples/sse-polling/package.json b/examples/sse-polling/package.json index b66b6cad33..838a71483b 100644 --- a/examples/sse-polling/package.json +++ b/examples/sse-polling/package.json @@ -25,6 +25,7 @@ "era": "legacy", "path": "/mcp", "timeoutMs": 20000, + "excluded": "replay assertion is timing-sensitive on CI; revisit", "//": "SEP-1699 closeSSE/eventStore/retryInterval live on the sessionful-2025 transport; the client is era-blind so dual would duplicate." } } diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index e6ae442893..65e91740e8 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -49,9 +49,11 @@ import type { SubscriptionFilter, Tool, Transport, - UnsubscribeRequest + UnsubscribeRequest, + XMcpHeaderScanResult } from '@modelcontextprotocol/core'; import { + buildMcpParamHeaders, CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, codecForVersion, @@ -59,6 +61,7 @@ import { CreateMessageResultWithToolsSchema, DEFAULT_REQUEST_TIMEOUT_MSEC, DiscoverResultSchema, + HEADER_MISMATCH_ERROR_CODE, isJSONRPCErrorResponse, isJSONRPCRequest, isModernProtocolVersion, @@ -72,6 +75,7 @@ import { ProtocolErrorCode, resolveInputRequiredDriverConfig, runInputRequiredFlow, + scanXMcpHeaderDeclarations, SdkError, SdkErrorCode, SUBSCRIPTION_ID_META_KEY, @@ -257,6 +261,23 @@ export type ClientOptions = ProtocolOptions & { listChanged?: ListChangedHandlers; }; +/** + * Options for {@linkcode Client.callTool}. Extends {@linkcode RequestOptions} + * with an escape hatch for callers that already hold the tool definition + * (e.g. from a previous session or configuration) — pass it via + * `toolDefinition` so SEP-2243 `Mcp-Param-*` header mirroring can run without a + * prior `tools/list`. + */ +export type CallToolRequestOptions = RequestOptions & { + /** + * The tool definition to use for SEP-2243 `Mcp-Param-*` header mirroring on + * a 2026-07-28 connection over Streamable HTTP. When set, the client uses + * this definition's `inputSchema` instead of (and without consulting) the + * cached `tools/list` result. + */ + toolDefinition?: Tool; +}; + /** * A handle to an open `subscriptions/listen` stream (protocol revision * 2026-07-28). Change notifications delivered on the stream dispatch to the @@ -340,6 +361,15 @@ export class Client extends Protocol { private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; private _cachedToolOutputValidators: Map> = new Map(); + /** + * SEP-2243 `x-mcp-header` declaration scans cached from `tools/list`, + * keyed by tool name. Replaced on every fresh (cursor-less) list and + * accumulated across pages of a paginated list; cleared on reconnect. + * Freshness is NOT enforced: a stale schema is recovered through the + * `-32001` → refresh-and-retry path in {@linkcode callTool}. Only + * consumed on a modern connection. + */ + private _cachedToolDefinitions: Map = new Map(); private _listChangedDebounceTimers: Map> = new Map(); /** * The constructor `listChanged` configuration. Durable across reconnects: @@ -397,6 +427,7 @@ export class Client extends Protocol { } this._listChangedDebounceTimers.clear(); this._cachedToolOutputValidators.clear(); + this._cachedToolDefinitions.clear(); } override async close(): Promise { @@ -1657,12 +1688,54 @@ export class Client extends Protocol { * } * ``` */ - async callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise { + async callTool(params: CallToolRequest['params'], options?: CallToolRequestOptions): Promise { + // SEP-2243 `Mcp-Param-*` mirroring (protocol revision 2026-07-28; the + // 5-step client algorithm, steps 3–5). Modern-era only — the legacy + // `callTool` path is byte-untouched. Transports that share a single + // channel (stdio, in-memory) ignore the per-request `headers` option, + // so the spec's stdio MAY-ignore exemption holds without an explicit + // branch. In a browser environment, dynamically named `Mcp-Param-*` + // headers cannot be statically allow-listed for credentialed CORS + // (`Access-Control-Allow-Headers` admits no wildcard with + // credentials), so mirroring is skipped. NOTE: a conforming SEP-2243 + // server (including this SDK's own `createMcpHandler`) rejects a + // `tools/call` whose body carries a non-null value for an + // `x-mcp-header`-declared parameter when the matching `Mcp-Param-*` + // header is absent — so a browser client of this SDK cannot + // successfully call such a tool with that argument present unless the + // server relaxes the missing-header check. This is a known limitation + // of running SEP-2243 from a browser; pass `null` for the designated + // argument or supply `options.toolDefinition` against a relaxed server. + const mirroringActive = this.getProtocolEra() === 'modern' && detectProbeEnvironment() !== 'browser'; + const buildSendOptions = (): CallToolRequestOptions | undefined => { + if (!mirroringActive) return options; + const scan = this._resolveXMcpHeaderScan(params.name, options?.toolDefinition); + if (!scan?.valid || scan.declarations.length === 0) return options; + const paramHeaders = buildMcpParamHeaders(scan.declarations, params.arguments); + return Object.keys(paramHeaders).length === 0 ? options : { ...options, headers: { ...options?.headers, ...paramHeaders } }; + }; + // The method-keyed request() path validates the era registry's plain // CallToolResult schema — with the result map aligned to the typed // map there is no wider union to narrow away (Q1-SD2 holds by // construction). - const result = await this.request({ method: 'tools/call', params }, options); + let result: CallToolResult; + try { + result = await this.request({ method: 'tools/call', params }, buildSendOptions()); + } catch (error) { + // SEP-2243 one-refresh-on-miss: a `-32001` (HeaderMismatch) + // rejection on a modern connection means the server enforced an + // `Mcp-Param-*` header we did not (or could not) send. Refresh the + // tool-definition cache once and retry with the now-known schema — + // exactly once, never on the legacy era, never when the caller + // supplied `toolDefinition` (their schema is authoritative). + const isHeaderMismatch = error instanceof ProtocolError && error.code === HEADER_MISMATCH_ERROR_CODE; + if (!mirroringActive || !isHeaderMismatch || options?.toolDefinition !== undefined) { + throw error; + } + await this._refreshAllToolDefinitions({ signal: options?.signal, timeout: options?.timeout }).catch(() => {}); + result = await this.request({ method: 'tools/call', params }, buildSendOptions()); + } // Check if the tool has an outputSchema const validator = this.getToolOutputValidator(params.name); @@ -1705,9 +1778,16 @@ export class Client extends Protocol { /** * Cache validators for tool output schemas. * Called after {@linkcode listTools | listTools()} to pre-compile validators for better performance. + * + * Paginated `tools/list`: only a fresh (cursor-less) page clears the + * caches; subsequent pages MERGE so a tool from an earlier page keeps its + * precomputed scan after the cursor loop completes. */ - private cacheToolMetadata(tools: Tool[]): void { - this._cachedToolOutputValidators.clear(); + private cacheToolMetadata(tools: Tool[], isFirstPage: boolean): void { + if (isFirstPage) { + this._cachedToolOutputValidators.clear(); + this._cachedToolDefinitions.clear(); + } for (const tool of tools) { // If the tool has an outputSchema, create and cache the validator @@ -1715,9 +1795,52 @@ export class Client extends Protocol { const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType); this._cachedToolOutputValidators.set(tool.name, toolValidator); } + // SEP-2243: precompute the x-mcp-header declaration scan so + // callTool() can mirror designated params into Mcp-Param-* headers + // without re-walking the schema per call. + this._cachedToolDefinitions.set(tool.name, { scan: scanXMcpHeaderDeclarations(tool.inputSchema) }); } } + /** + * Resolve the SEP-2243 `x-mcp-header` declaration scan for a tool name. + * + * Policy (protocol revision 2026-07-28): the caller-supplied + * `toolDefinition` escape hatch wins; otherwise the most recent + * `tools/list` result is used as-is. Freshness is NOT enforced — the + * cached schema is the best information available regardless of age, and + * a stale schema is recovered through the `-32001` → refresh-and-retry + * path in {@linkcode callTool}. On a miss the call proceeds without + * `Mcp-Param-*` headers (the spec's "client SHOULD send without custom + * headers" guidance) and relies on the same refresh-and-retry recovery. + */ + private _resolveXMcpHeaderScan(name: string, override: Tool | undefined): XMcpHeaderScanResult | undefined { + if (override !== undefined) { + return scanXMcpHeaderDeclarations(override.inputSchema); + } + return this._cachedToolDefinitions.get(name)?.scan; + } + + /** + * SEP-2243 internal recovery refresh: walk every `tools/list` page so + * `_cachedToolDefinitions` is rebuilt completely. Only the caller's + * `signal`/`timeout` are forwarded to each page request — `tools/call` + * options like `toolDefinition`, `headers`, or `onprogress` do not apply + * to `tools/list`. The `-32001` retry path + * in {@linkcode callTool} uses this rather than a bare cursor-less + * `listTools()`, which would only fetch page 1 and (because page 1 clears + * the merged cache) drop the page-≥2 scans the application accumulated via + * the documented cursor loop — leaving the retry without the very scan it + * needs and silently degrading every other page-≥2 tool. + */ + private async _refreshAllToolDefinitions(options: RequestOptions | undefined): Promise { + let cursor: string | undefined; + do { + const { nextCursor } = await this.listTools(cursor === undefined ? undefined : { cursor }, options); + cursor = nextCursor; + } while (cursor !== undefined); + } + /** * Get cached validator for a tool */ @@ -1755,10 +1878,30 @@ export class Client extends Protocol { } const result = await this.request({ method: 'tools/list', params }, options); + // SEP-2243 (protocol revision 2026-07-28): a Streamable HTTP client + // MUST exclude tool definitions whose `x-mcp-header` declarations + // violate the constraints, and SHOULD log a warning naming the tool and + // the reason. Applied only on a modern HTTP connection — stdio clients + // MAY ignore the annotation entirely, and the legacy era never emits + // 2026 headers, so neither path filters. + let tools = result.tools; + if (this.getProtocolEra() === 'modern' && this.transport && detectProbeTransportKind(this.transport) === 'http') { + tools = tools.filter(tool => { + const scan = scanXMcpHeaderDeclarations(tool.inputSchema); + if (!scan.valid) { + console.warn( + `[mcp-sdk] excluding tool '${tool.name}' from tools/list: invalid x-mcp-header declaration — ${scan.reason}` + ); + return false; + } + return true; + }); + } + // Cache the tools and their output schemas for future validation - this.cacheToolMetadata(result.tools); + this.cacheToolMetadata(tools, params?.cursor === undefined); - return result; + return tools === result.tools ? result : { ...result, tools }; } /** diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 277e84e0fd..d8d3218a26 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -3,6 +3,7 @@ import type { ReadableWritablePair } from 'node:stream/web'; import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; import { createFetchWithInit, + encodeMcpParamValue, isInitializedNotification, isJSONRPCErrorResponse, isJSONRPCRequest, @@ -190,6 +191,23 @@ export type StreamableHTTPClientTransportOptions = { * originating signal's `reason` and participates in GC the way the spec * defines. */ +/** + * Standard/auth header names the transport owns. The per-request + * `TransportSendOptions.headers` carrier MUST NOT be able to override these — + * they are derived from connection state (`authorization`, `mcp-session-id`) + * or from the message body itself (`mcp-protocol-version`, `mcp-method`, + * `mcp-name`), and a per-request override would let a caller produce a + * header/body disagreement the server's SEP-2243 cross-checks reject. + */ +const RESERVED_REQUEST_HEADER_NAMES: ReadonlySet = new Set([ + 'authorization', + 'content-type', + 'mcp-protocol-version', + 'mcp-method', + 'mcp-name', + 'mcp-session-id' +]); + function anySignal(a: AbortSignal, b: AbortSignal): AbortSignal { if (typeof AbortSignal.any === 'function') { return AbortSignal.any([a, b]); @@ -246,6 +264,14 @@ export class StreamableHTTPClientTransport implements Transport { onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage) => void; + /** + * Streamable HTTP opens one POST (and SSE response stream) per outbound + * request and honors `TransportSendOptions.requestSignal`. On a 2026-era + * connection the protocol layer aborts that per-request stream as the + * spec cancellation signal instead of POSTing `notifications/cancelled`. + */ + readonly hasPerRequestStream = true; + constructor(url: URL, opts?: StreamableHTTPClientTransportOptions) { this._url = url; this._resourceMetadataUrl = undefined; @@ -306,6 +332,26 @@ export class StreamableHTTPClientTransport implements Transport { } headers.set('mcp-protocol-version', envelopeVersion); headers.set('mcp-method', message.method); + // SEP-2243 standard headers, step 2 of the 5-step client algorithm: + // Mcp-Name mirrors `params.name` (tools/call, prompts/get) or + // `params.uri` (resources/read). The value is run through the same + // `=?base64?…?=` sentinel encoding the `Mcp-Param-*` codec uses so a + // non-ASCII name/URI (or one with leading/trailing whitespace or + // control characters) cannot make `Headers.set()` throw or silently + // diverge from the body — the server side decodes the sentinel before + // comparison. + const params = message.params as { name?: unknown; uri?: unknown } | undefined; + const nameHeader = + message.method === 'resources/read' + ? typeof params?.uri === 'string' + ? params.uri + : undefined + : typeof params?.name === 'string' + ? params.name + : undefined; + if (nameHeader !== undefined) { + headers.set('mcp-name', encodeMcpParamValue(nameHeader)); + } } private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise { @@ -659,6 +705,7 @@ export class StreamableHTTPClientTransport implements Transport { onresumptiontoken?: (token: string) => void; requestSignal?: AbortSignal; onRequestStreamEnd?: () => void; + headers?: Readonly>; } ): Promise { return this._send(message, options, false); @@ -672,6 +719,7 @@ export class StreamableHTTPClientTransport implements Transport { onresumptiontoken?: (token: string) => void; requestSignal?: AbortSignal; onRequestStreamEnd?: () => void; + headers?: Readonly>; } | undefined, isAuthRetry: boolean @@ -689,6 +737,19 @@ export class StreamableHTTPClientTransport implements Transport { const headers = await this._commonHeaders(); this._applyBodyDerivedHeaders(headers, message); + // Per-request additional headers (the Client passes SEP-2243 + // `Mcp-Param-*` here on a 2026-07-28 connection). Reserved + // standard/auth header names are skipped so a caller cannot + // accidentally override the body-derived or connection-level + // headers — `Headers.set` overwrites, so the only way to keep the + // transport-owned values authoritative is to refuse to write over + // them here. + if (options?.headers !== undefined) { + for (const [name, value] of Object.entries(options.headers)) { + if (RESERVED_REQUEST_HEADER_NAMES.has(name.toLowerCase())) continue; + headers.set(name, value); + } + } headers.set('content-type', 'application/json'); const userAccept = headers.get('accept'); const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'application/json', 'text/event-stream']; @@ -790,6 +851,27 @@ export class StreamableHTTPClientTransport implements Transport { } } + // SEP-2243 (and the rest of the inbound validation ladder) + // emit ladder rejections as HTTP 400 carrying a JSON-RPC error + // response body. Surface those in-band so `Protocol._onresponse` + // converts them to a typed `ProtocolError` matched to the + // pending request id — instead of an opaque transport error. + // Any 400 whose body is not a well-formed JSON-RPC error + // response (or whose id does not match an outstanding request) + // still falls through to the generic `SdkHttpError`. + if (response.status === 400 && typeof text === 'string') { + try { + const parsed = JSONRPCMessageSchema.parse(JSON.parse(text)); + const requests = (Array.isArray(message) ? message : [message]).filter(m => isJSONRPCRequest(m)); + if (isJSONRPCErrorResponse(parsed) && requests.some(r => r.id === parsed.id)) { + this.onmessage?.(parsed); + return; + } + } catch { + // not a JSON-RPC error body — fall through to the generic SdkHttpError below. + } + } + throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, { status: response.status, statusText: response.statusText, diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7fe7acb958..2f977bec2e 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -52,7 +52,7 @@ export { PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider } from './client/authExtensions.js'; -export type { ClientOptions, McpSubscription } from './client/client.js'; +export type { CallToolRequestOptions, ClientOptions, McpSubscription } from './client/client.js'; export { Client } from './client/client.js'; export { getSupportedElicitationModes } from './client/client.js'; export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess.js'; diff --git a/packages/client/test/client/mcpParamMirroring.test.ts b/packages/client/test/client/mcpParamMirroring.test.ts new file mode 100644 index 0000000000..e2970a1c5d --- /dev/null +++ b/packages/client/test/client/mcpParamMirroring.test.ts @@ -0,0 +1,400 @@ +/** + * SEP-2243 client-side `Mcp-Param-*` mirroring (protocol revision 2026-07-28). + * + * Covers: `tools/list` exclusion of constraint-violating definitions; per-call + * `Mcp-Param-*` header construction from cached definitions and the + * `toolDefinition` escape hatch; era-parity (legacy `callTool` byte-untouched); + * stdio MAY-ignore (no headers on a single-channel transport); the + * one-refresh-on-`-32001` retry. + */ +import type { JSONRPCMessage, JSONRPCRequest, Tool, TransportSendOptions } from '@modelcontextprotocol/core'; +import { encodeMcpParamValue, HEADER_MISMATCH_ERROR_CODE, InMemoryTransport, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { Client } from '../../src/client/client.js'; +import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp.js'; + +const MODERN = '2026-07-28'; + +const REGION_TOOL: Tool = { + name: 'route', + inputSchema: { + type: 'object', + properties: { region: { type: 'string', 'x-mcp-header': 'Region' }, query: { type: 'string' } } + } +}; + +const INVALID_TOOL: Tool = { + name: 'broken', + inputSchema: { type: 'object', properties: { a: { type: 'object', 'x-mcp-header': 'Data' } } } +}; + +interface Scripted { + clientTx: InMemoryTransport; + /** Headers passed via TransportSendOptions for each tools/call (undefined when none). */ + callHeaders: Array | undefined>; + listCount: () => number; +} + +async function scriptedModernServer(tools: Tool[], rejectFirstCall = false): Promise { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const callHeaders: Array | undefined> = []; + let calls = 0; + let lists = 0; + + // Tap the client→server channel to observe TransportSendOptions.headers + // (InMemoryTransport ignores it; this is the seam under test). + const realSend = clientTx.send.bind(clientTx); + clientTx.send = (m: JSONRPCMessage, opts?: TransportSendOptions): Promise => { + if ((m as JSONRPCRequest).method === 'tools/call') { + callHeaders.push(opts?.headers ? { ...opts.headers } : undefined); + } + return realSend(m, opts); + }; + + serverTx.onmessage = m => { + const r = m as JSONRPCRequest; + if (r.id === undefined) return; + if (r.method === 'server/discover') { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 'scripted', version: '1.0.0' } + } + }); + } else if (r.method === 'tools/list') { + lists++; + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { resultType: 'complete', ttlMs: 60_000, cacheScope: 'public', tools } + }); + } else if (r.method === 'tools/call') { + calls++; + if (rejectFirstCall && calls === 1) { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + error: { code: HEADER_MISMATCH_ERROR_CODE, message: 'Bad Request: the request headers and body disagree' } + }); + } else { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { resultType: 'complete', content: [{ type: 'text', text: 'ok' }] } + }); + } + } + }; + await serverTx.start(); + return { clientTx, callHeaders, listCount: () => lists }; +} + +function modernClient(): Client { + return new Client({ name: 'param-mirror-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); +} + +describe('SEP-2243 Mcp-Param-* mirroring (modern era)', () => { + it('listTools() excludes constraint-violating x-mcp-header tools and warns', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { clientTx } = await scriptedModernServer([REGION_TOOL, INVALID_TOOL]); + const client = modernClient(); + await client.connect(clientTx); + + const { tools } = await client.listTools(); + expect(tools.map(t => t.name)).toEqual(['route']); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("excluding tool 'broken'")); + warn.mockRestore(); + }); + + it('callTool() passes Mcp-Param-* via TransportSendOptions.headers from the cached definition; null/absent are omitted', async () => { + const { clientTx, callHeaders } = await scriptedModernServer([REGION_TOOL]); + const client = modernClient(); + await client.connect(clientTx); + await client.listTools(); + + await client.callTool({ name: 'route', arguments: { region: 'us-west1', query: 'x' } }); + await client.callTool({ name: 'route', arguments: { region: null, query: 'x' } as Record }); + + expect(callHeaders[0]).toEqual({ 'Mcp-Param-Region': 'us-west1' }); + expect(callHeaders[1]).toBeUndefined(); + }); + + it('callTool() uses the toolDefinition escape hatch without a prior tools/list', async () => { + const { clientTx, callHeaders, listCount } = await scriptedModernServer([REGION_TOOL]); + const client = modernClient(); + await client.connect(clientTx); + + await client.callTool({ name: 'route', arguments: { region: 'eu' } }, { toolDefinition: REGION_TOOL }); + expect(listCount()).toBe(0); + expect(callHeaders[0]).toEqual({ 'Mcp-Param-Region': 'eu' }); + }); + + it('callTool() refreshes once and retries on a -32001 (HeaderMismatch) rejection', async () => { + const { clientTx, callHeaders, listCount } = await scriptedModernServer([REGION_TOOL], /* rejectFirstCall */ true); + const client = modernClient(); + await client.connect(clientTx); + + // No prior listTools — first send carries no param headers, server + // rejects -32001, client refreshes and retries with the headers. + const result = await client.callTool({ name: 'route', arguments: { region: 'ap' } }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + expect(listCount()).toBe(1); + expect(callHeaders).toEqual([undefined, { 'Mcp-Param-Region': 'ap' }]); + }); + + it('paginated tools/list accumulates scans across pages (a tool from page 1 still mirrors after page 2)', async () => { + const PAGE2_TOOL: Tool = { name: 'echo', inputSchema: { type: 'object', properties: {} } }; + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const callHeaders: Array | undefined> = []; + const realSend = clientTx.send.bind(clientTx); + clientTx.send = (m: JSONRPCMessage, opts?: TransportSendOptions): Promise => { + if ((m as JSONRPCRequest).method === 'tools/call') callHeaders.push(opts?.headers ? { ...opts.headers } : undefined); + return realSend(m, opts); + }; + serverTx.onmessage = m => { + const r = m as JSONRPCRequest; + if (r.id === undefined) return; + if (r.method === 'server/discover') { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 's', version: '1' } + } + }); + } else if (r.method === 'tools/list') { + const cursor = (r.params as { cursor?: string } | undefined)?.cursor; + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: + cursor === undefined + ? { resultType: 'complete', ttlMs: 60_000, cacheScope: 'public', tools: [REGION_TOOL], nextCursor: 'p2' } + : { resultType: 'complete', ttlMs: 60_000, cacheScope: 'public', tools: [PAGE2_TOOL] } + }); + } else if (r.method === 'tools/call') { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { resultType: 'complete', content: [{ type: 'text', text: 'ok' }] } + }); + } + }; + await serverTx.start(); + const client = modernClient(); + await client.connect(clientTx); + + let cursor: string | undefined; + do { + const { nextCursor } = await client.listTools(cursor === undefined ? undefined : { cursor }); + cursor = nextCursor; + } while (cursor !== undefined); + + await client.callTool({ name: 'route', arguments: { region: 'us-west1' } }); + expect(callHeaders[0]).toEqual({ 'Mcp-Param-Region': 'us-west1' }); + }); + + it('-32001 recovery refresh paginates: a page-2 x-mcp-header tool is recovered and page-2 scans are not wiped', async () => { + // Page 1: `echo` (no declarations). Page 2: `route` (x-mcp-header on + // page ≥ 2). The first call is rejected -32001; the internal refresh + // must walk BOTH pages so the retry carries `Mcp-Param-Region` and the + // application's page-2 scan is not lost. + const PAGE1_TOOL: Tool = { name: 'echo', inputSchema: { type: 'object', properties: {} } }; + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const callHeaders: Array | undefined> = []; + const realSend = clientTx.send.bind(clientTx); + clientTx.send = (m: JSONRPCMessage, opts?: TransportSendOptions): Promise => { + if ((m as JSONRPCRequest).method === 'tools/call') callHeaders.push(opts?.headers ? { ...opts.headers } : undefined); + return realSend(m, opts); + }; + let calls = 0; + let lists = 0; + serverTx.onmessage = m => { + const r = m as JSONRPCRequest; + if (r.id === undefined) return; + if (r.method === 'server/discover') { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 's', version: '1' } + } + }); + } else if (r.method === 'tools/list') { + lists++; + const cursor = (r.params as { cursor?: string } | undefined)?.cursor; + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: + cursor === undefined + ? { resultType: 'complete', ttlMs: 60_000, cacheScope: 'public', tools: [PAGE1_TOOL], nextCursor: 'p2' } + : { resultType: 'complete', ttlMs: 60_000, cacheScope: 'public', tools: [REGION_TOOL] } + }); + } else if (r.method === 'tools/call') { + calls++; + if (calls === 1) { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + error: { code: HEADER_MISMATCH_ERROR_CODE, message: 'Bad Request: the request headers and body disagree' } + }); + } else { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { resultType: 'complete', content: [{ type: 'text', text: 'ok' }] } + }); + } + } + }; + await serverTx.start(); + const client = modernClient(); + await client.connect(clientTx); + + const result = await client.callTool({ name: 'route', arguments: { region: 'us-west1' } }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + // Internal refresh walked both pages. + expect(lists).toBe(2); + // First send had no scan; retry carried the page-2 scan. + expect(callHeaders).toEqual([undefined, { 'Mcp-Param-Region': 'us-west1' }]); + // The page-2 scan survives the refresh: a follow-up call still mirrors. + await client.callTool({ name: 'route', arguments: { region: 'eu' } }); + expect(callHeaders[2]).toEqual({ 'Mcp-Param-Region': 'eu' }); + }); + + it('_resetConnectionState() clears the tool-definition cache (close → reconnect → no stale scan)', async () => { + const a = await scriptedModernServer([REGION_TOOL]); + const client = modernClient(); + await client.connect(a.clientTx); + await client.listTools(); + await client.close(); + + const b = await scriptedModernServer([{ name: 'route', inputSchema: { type: 'object', properties: {} } }]); + await client.connect(b.clientTx); + + await client.callTool({ name: 'route', arguments: { region: 'us' } }); + // No prior listTools against server B and the cache from A was cleared + // → the call proceeds without Mcp-Param-* headers (no stale scan). + expect(b.callHeaders[0]).toBeUndefined(); + }); +}); + +describe('SEP-2243 era parity / stdio exemption', () => { + it('legacy-era callTool() is byte-untouched: no headers passed, no exclusion applied', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const callHeaders: Array | undefined> = []; + const realSend = clientTx.send.bind(clientTx); + clientTx.send = (m: JSONRPCMessage, opts?: TransportSendOptions): Promise => { + if ((m as JSONRPCRequest).method === 'tools/call') callHeaders.push(opts?.headers ? { ...opts.headers } : undefined); + return realSend(m, opts); + }; + serverTx.onmessage = m => { + const r = m as JSONRPCRequest; + if (r.id === undefined) return; + if (r.method === 'initialize') { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { protocolVersion: '2025-11-25', capabilities: { tools: {} }, serverInfo: { name: 's', version: '1' } } + }); + } else if (r.method === 'tools/list') { + void serverTx.send({ jsonrpc: '2.0', id: r.id, result: { tools: [REGION_TOOL, INVALID_TOOL] } }); + } else if (r.method === 'tools/call') { + void serverTx.send({ jsonrpc: '2.0', id: r.id, result: { content: [{ type: 'text', text: 'ok' }] } }); + } + }; + await serverTx.start(); + + const client = new Client({ name: 'legacy', version: '1' }); + await client.connect(clientTx); + expect(client.getProtocolEra()).toBe('legacy'); + + const { tools } = await client.listTools(); + // No exclusion on the legacy era — both tools present. + expect(tools.map(t => t.name)).toEqual(['route', 'broken']); + + await client.callTool({ name: 'route', arguments: { region: 'us' } }); + expect(callHeaders).toEqual([undefined]); + }); + + it('stdio MAY-ignore: a single-channel transport drops TransportSendOptions.headers', async () => { + // InMemoryTransport stands in for stdio here: like the stdio transport + // it shares a single channel and ignores per-request HTTP headers. + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + let sawHeaders: unknown; + serverTx.onmessage = (_m, extra) => { + sawHeaders = (extra as { headers?: unknown } | undefined)?.headers; + }; + await clientTx.start(); + await (clientTx as { send: (m: JSONRPCMessage, opts?: TransportSendOptions) => Promise }).send( + { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'x' } }, + { headers: { 'Mcp-Param-Region': 'us' } } + ); + expect(sawHeaders).toBeUndefined(); + }); +}); + +describe('SEP-2243 Streamable HTTP transport seams', () => { + function transportWithCapture(): { tx: StreamableHTTPClientTransport; sent: () => Headers } { + let captured: Headers | undefined; + const fetch = vi.fn(async (_url, init) => { + captured = new Headers((init as RequestInit).headers); + return new Response(null, { status: 202, headers: { 'content-type': 'application/json' } }); + }); + const tx = new StreamableHTTPClientTransport(new URL('http://example.test/mcp'), { fetch: fetch as typeof globalThis.fetch }); + return { tx, sent: () => captured! }; + } + + const modernRequest = (method: string, params: Record): JSONRPCMessage => ({ + jsonrpc: '2.0', + id: 1, + method, + params: { ...params, _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN } } + }); + + it('Mcp-Name is sentinel-encoded for non-ASCII / unsafe values (no Headers.set TypeError)', async () => { + const { tx, sent } = transportWithCapture(); + await tx.start(); + await tx.send(modernRequest('resources/read', { uri: 'file:///レポート.md' })); + expect(sent().get('mcp-name')).toBe(encodeMcpParamValue('file:///レポート.md')); + // ASCII-safe values pass through unchanged. + await tx.send(modernRequest('tools/call', { name: 'route', arguments: {} })); + expect(sent().get('mcp-name')).toBe('route'); + }); + + it('per-request TransportSendOptions.headers cannot override reserved standard/auth headers', async () => { + const { tx, sent } = transportWithCapture(); + await tx.start(); + await tx.send(modernRequest('tools/call', { name: 'route', arguments: {} }), { + headers: { 'Mcp-Method': 'tools/list', authorization: 'Bearer evil', 'Mcp-Param-Region': 'us' } + }); + expect(sent().get('mcp-method')).toBe('tools/call'); + expect(sent().get('authorization')).toBeNull(); + expect(sent().get('mcp-param-region')).toBe('us'); + }); + + it('an HTTP 400 carrying a JSON-RPC error response is delivered in-band via onmessage (not thrown as SdkHttpError)', async () => { + const errorBody = { jsonrpc: '2.0', id: 1, error: { code: HEADER_MISMATCH_ERROR_CODE, message: 'Bad Request: …' } }; + const fetch = vi.fn( + async () => new Response(JSON.stringify(errorBody), { status: 400, headers: { 'content-type': 'application/json' } }) + ); + const tx = new StreamableHTTPClientTransport(new URL('http://example.test/mcp'), { fetch: fetch as typeof globalThis.fetch }); + const seen: JSONRPCMessage[] = []; + tx.onmessage = m => seen.push(m); + await tx.start(); + await expect(tx.send(modernRequest('tools/call', { name: 'route', arguments: {} }))).resolves.toBeUndefined(); + expect(seen[0]).toMatchObject({ id: 1, error: { code: HEADER_MISMATCH_ERROR_CODE } }); + }); +}); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 04d4615b41..3c7a2787f9 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -404,6 +404,15 @@ describe('StreamableHTTPClientTransport', () => { ).toBe(true); }); + it('declares hasPerRequestStream so the protocol layer routes 2026-era cancellation to stream-close', () => { + // Spec basic/patterns/cancellation §Transport-Specific (2026-07-28): + // closing the per-request SSE stream IS the cancel signal on + // Streamable HTTP. Protocol.request() keys on this flag (plus the + // negotiated era) to abort `requestSignal` instead of POSTing + // `notifications/cancelled`. + expect(transport.hasPerRequestStream).toBe(true); + }); + it('should support custom reconnection options', () => { // Create a transport with custom reconnection options transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0c9e2a22f5..54c133195e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,7 @@ export * from './shared/inboundClassification.js'; export * from './shared/inputRequired.js'; export * from './shared/inputRequiredDriver.js'; export * from './shared/inputRequiredEngine.js'; +export * from './shared/mcpParamHeaders.js'; export * from './shared/metadataUtils.js'; export * from './shared/protocol.js'; export * from './shared/protocolEras.js'; diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index 915eebf0b3..63321bbcaf 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -69,6 +69,12 @@ import { ProtocolError, UnsupportedProtocolVersionError } from '../types/errors. import { isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse } from '../types/guards.js'; import type { JSONRPCNotification, JSONRPCRequest, MessageClassification } from '../types/types.js'; import { envelopeClaimVersion, hasEnvelopeClaim, requestMetaOf, validateEnvelopeMeta } from './envelope.js'; +// Value encoding is shared between the standard `Mcp-Name` header and the +// custom `Mcp-Param-*` headers; the codec module already imports the +// `HeaderMismatch` constant and rejection type from here, so this is a benign +// two-module cycle (both sides only consume the other's exports inside +// function bodies, never at module-evaluation time). +import { decodeMcpParamValue } from './mcpParamHeaders.js'; import { isModernProtocolVersion } from './protocolEras.js'; /* ------------------------------------------------------------------------ * @@ -88,6 +94,8 @@ export interface InboundHttpRequest { protocolVersionHeader?: string; /** The value of the `Mcp-Method` header, when present. */ mcpMethodHeader?: string; + /** The value of the `Mcp-Name` header, when present. */ + mcpNameHeader?: string; /** The parsed JSON request body (`undefined` for body-less methods). */ body?: unknown; } @@ -161,7 +169,9 @@ export type InboundValidationRung = | 'envelope' | 'method-registry' | 'request-params' - | 'client-capabilities'; + | 'client-capabilities' + | 'standard-header-validation' + | 'param-header-validation'; /** A ladder rejection: the JSON-RPC error to emit and the HTTP status to emit it with. */ export interface InboundLadderRejection { @@ -316,6 +326,32 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor 'only while the requirement table is empty: once a served method gains a requirement entry, a request that is ' + 'missing the capability and would also fail a dispatch rung is answered by this gate first, so the entry must ' + 'consult the method registry before the gate if the documented precedence is to stay observable.' + }, + { + rung: 'standard-header-validation', + order: 8, + evaluatedAt: 'pre-dispatch', + codes: [HEADER_MISMATCH_ERROR_CODE], + conformance: ['http-header-validation'], + rationale: + 'SEP-2243 standard `Mcp-Method` / `Mcp-Name` headers — presence, sentinel decoding, and `Mcp-Name` ↔ body cross-check ' + + '— are validated by the HTTP entry on a modern-classified request after the supported-revision gate and before ' + + 'dispatch. The classifier’s own header-mismatch cells (protocol-version, `Mcp-Method` mismatch) stay on the edge ' + + '`era-classification` rung; this rung carries the entry-layer presence/`Mcp-Name` half. The documented order ' + + '(after method resolution, params validation, and the capability gate) is not the observed precedence: serveModern ' + + 'evaluates this rung immediately after the supported-revision gate, so a request that fails several rungs is ' + + 'answered by this gate before the dispatch rungs (5–6) and the capability gate (7) are consulted.' + }, + { + rung: 'param-header-validation', + order: 9, + evaluatedAt: 'pre-dispatch', + codes: [HEADER_MISMATCH_ERROR_CODE], + conformance: ['http-custom-header-server-validation'], + rationale: + 'SEP-2243 `Mcp-Param-*` headers are validated against the named tool’s `x-mcp-header` declarations and the body ' + + '`arguments` after the tool registry is known and before dispatch reaches the handler; a missing/disagreeing/malformed ' + + 'header is rejected 400 / -32001 with the same shape as the standard-header cross-checks.' } ]; @@ -380,9 +416,14 @@ function rejection( }; } -function crossCheckMismatch(cell: string, header: string, body: string): InboundLadderRejection { +function crossCheckMismatch( + cell: string, + header: string, + body: string, + rung: InboundValidationRung = 'era-classification' +): InboundLadderRejection { return rejection( - 'era-classification', + rung, cell, 400, new ProtocolError(HEADER_MISMATCH_ERROR_CODE, `Bad Request: the request headers and body disagree: ${body}`, { @@ -392,6 +433,110 @@ function crossCheckMismatch(cell: string, header: string, body: string): Inbound ); } +/** + * The methods whose body carries a `params.name` / `params.uri` value the + * `Mcp-Name` header must mirror, and which body field supplies it (SEP-2243 + * § Standard Request Headers, `Required For` column). + */ +export const MCP_NAME_HEADER_SOURCE: Readonly> = { + 'tools/call': 'name', + 'prompts/get': 'name', + 'resources/read': 'uri' +}; + +/** + * SEP-2243 standard-header server-side validation, evaluated by the HTTP + * entry on a modern-classified request immediately after + * {@linkcode classifyInboundRequest} returns a modern route. + * + * Returns the `-32001` (`HeaderMismatch`) ladder rejection (HTTP `400`, + * `standard-header-validation` rung — the same shape + * {@linkcode classifyInboundRequest} already emits on the edge + * `era-classification` rung for the `MCP-Protocol-Version` and + * `Mcp-Method` *mismatch* cells) when: + * + * - the required `Mcp-Method` header is absent; + * - the required `Mcp-Name` header is absent on a `tools/call`, + * `prompts/get`, or `resources/read` request whose body carries the + * `params.name` / `params.uri` value the header mirrors; + * - the `Mcp-Name` header carries an invalid `=?base64?…?=` sentinel; or + * - the (decoded) `Mcp-Name` value disagrees with the body's + * `params.name` / `params.uri`. + * + * Returns `undefined` (pass) for notifications (the spec table reads + * "All requests"), for methods that have no `Mcp-Name` source, and when the + * headers agree with the body. Never enforced on legacy traffic — the entry + * only calls this on a modern route. + * + * Kept separate from {@linkcode classifyInboundRequest} so that a body-only + * call to the classifier (no headers passed) keeps routing a modern request + * unchanged: the classifier remains a pure body-primary router, and this + * function is the presence/`Mcp-Name` half of the standard-header rung the + * entry layers on top. + */ +export function validateStandardRequestHeaders(request: InboundHttpRequest, route: InboundModernRoute): InboundLadderRejection | undefined { + if (route.messageKind !== 'request') { + return undefined; + } + const method = route.message.method; + + if (request.mcpMethodHeader === undefined) { + return crossCheckMismatch( + 'method-header-missing', + '(missing)', + `the body names method ${method} but the required Mcp-Method header is absent`, + 'standard-header-validation' + ); + } + + // `method` is the JSON-RPC method string from the body — peer-controlled, + // so guard the plain-object lookup against `Object.prototype` collisions + // (`constructor`, `toString`, …) the same way the client-capability table + // lookup does. + const sourceField = Object.hasOwn(MCP_NAME_HEADER_SOURCE, method) ? MCP_NAME_HEADER_SOURCE[method] : undefined; + if (sourceField === undefined) { + return undefined; + } + const params = route.message.params as Record | undefined; + const sourceValue = params?.[sourceField]; + const bodyValue = typeof sourceValue === 'string' ? sourceValue : undefined; + + if (request.mcpNameHeader === undefined) { + // The header is required for these methods whenever the body carries + // the source value. A body without `params.name`/`params.uri` is a + // params-validation failure further down the ladder; this rung only + // answers the missing-header case it can observe. + if (bodyValue === undefined) { + return undefined; + } + return crossCheckMismatch( + 'name-header-missing', + '(missing)', + `the body carries params.${sourceField}="${bodyValue}" but the required Mcp-Name header is absent`, + 'standard-header-validation' + ); + } + + const decoded = decodeMcpParamValue(request.mcpNameHeader); + if (decoded === undefined) { + return crossCheckMismatch( + 'name-header-invalid-encoding', + request.mcpNameHeader, + 'the Mcp-Name header carries an invalid Base64 sentinel value', + 'standard-header-validation' + ); + } + if (bodyValue !== undefined && decoded !== bodyValue) { + return crossCheckMismatch( + 'name-header-mismatch', + request.mcpNameHeader, + `the body carries params.${sourceField}="${bodyValue}" but the Mcp-Name header names "${decoded}"`, + 'standard-header-validation' + ); + } + return undefined; +} + function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } diff --git a/packages/core/src/shared/inputRequiredEngine.ts b/packages/core/src/shared/inputRequiredEngine.ts index f71c8d471a..d58c531d97 100644 --- a/packages/core/src/shared/inputRequiredEngine.ts +++ b/packages/core/src/shared/inputRequiredEngine.ts @@ -176,6 +176,11 @@ export function buildRetryLegRequestOptions(options: RequestOptions | undefined, ...(options?.signal !== undefined && { signal: options.signal }), ...(options?.onprogress !== undefined && { onprogress: options.onprogress }), ...(options?.resetTimeoutOnProgress !== undefined && { resetTimeoutOnProgress: options.resetTimeoutOnProgress }), + // Per-request HTTP headers (SEP-2243 `Mcp-Param-*`) carry over: the + // retry's `arguments` are byte-identical to the originating leg (the + // driver only adds `inputResponses`/`requestState`), so the param + // headers built for the first leg remain correct for every retry leg. + ...(options?.headers !== undefined && { headers: options.headers }), ...(legOptions.timeout !== undefined && { timeout: legOptions.timeout }), ...(legOptions.maxTotalTimeout !== undefined && { maxTotalTimeout: legOptions.maxTotalTimeout }), // The driver re-enters the funnel with the manual primitive: a further diff --git a/packages/core/src/shared/mcpParamHeaders.ts b/packages/core/src/shared/mcpParamHeaders.ts new file mode 100644 index 0000000000..9e60ec5be4 --- /dev/null +++ b/packages/core/src/shared/mcpParamHeaders.ts @@ -0,0 +1,356 @@ +/** + * SEP-2243 `Mcp-Param-*` header codec (protocol revision 2026-07-28). + * + * Pure functions for the custom-header half of SEP-2243: scanning a tool's + * `inputSchema` for `x-mcp-header` declarations, encoding argument values into + * `Mcp-Param-{Name}` HTTP headers (with the `=?base64?…?=` sentinel for values + * that cannot be safely represented as plain ASCII field values), decoding + * those headers, and validating that the headers a request carries match the + * argument values in its body. + * + * The standard-header half (`MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name`) + * lives with the inbound classifier — this module is the custom-header half + * only, and it consumes the same `-32001` (`HeaderMismatch`) emission shape the + * classifier established for header/body cross-check failures. + * + * Spec text at the implementation's spec pin: + * - draft/basic/transports/streamable-http.mdx § "Custom Headers from Tool Parameters" + * (constraints, value encoding, the 5-step client algorithm, the + * server-behavior table, the `400` + `-32001` rejection) + * - draft/server/tools.mdx § "x-mcp-header" (the schema-extension property and + * its constraints) + */ +import type { InboundLadderRejection } from './inboundClassification.js'; +import { HEADER_MISMATCH_ERROR_CODE } from './inboundClassification.js'; + +/* ------------------------------------------------------------------------ * + * Declaration scan + * ------------------------------------------------------------------------ */ + +/** The fixed prefix every custom-parameter header carries. */ +export const MCP_PARAM_HEADER_PREFIX = 'Mcp-Param-'; + +/** The schema-extension property name a tool's `inputSchema` carries. */ +export const X_MCP_HEADER_KEY = 'x-mcp-header'; + +/** + * One `x-mcp-header` declaration found inside a tool's `inputSchema`. + * + * `path` is the property path from the arguments root (the spec permits + * declarations at any nesting depth under `properties`); `headerName` is the + * `{Name}` portion as declared (case preserved for emission; comparison is + * case-insensitive); `type` is the JSON Schema `type` of the declaring + * property. + */ +export interface XMcpHeaderDeclaration { + path: readonly string[]; + headerName: string; + type: string; +} + +/** The result of scanning a tool's `inputSchema` for `x-mcp-header` declarations. */ +export type XMcpHeaderScanResult = { valid: true; declarations: readonly XMcpHeaderDeclaration[] } | { valid: false; reason: string }; + +/** + * RFC 9110 §5.1 `token` syntax (`1*tchar`). Rejects empty, space, control + * characters (including CR/LF), and the listed delimiters. + */ +const RFC9110_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +/** + * JSON Schema `type` values the spec admits on an `x-mcp-header` property. + * + * The spec text names `integer`, `string`, `boolean` and explicitly excludes + * `number`. The published conformance referee at the pinned release ships its + * `http-custom-headers` scenario with two `type: "number"` `x-mcp-header` + * parameters and expects the client to mirror them, so `number` is accepted + * here so that the conformance gate passes; the discrepancy is tracked + * upstream. Everything else (`object`, `array`, `null`, absent) is rejected. + */ +const PERMITTED_X_MCP_HEADER_TYPES: ReadonlySet = new Set(['string', 'integer', 'boolean', 'number']); + +/** + * Scan a tool's JSON-serialized `inputSchema` for `x-mcp-header` declarations + * and validate every constraint the spec places on them. Returns either the + * collected declarations (possibly empty) or the first violated constraint. + * + * The walk descends through `properties` at any depth (the spec's "any nesting + * depth" clause). Composition keywords (`oneOf`/`allOf`/…) are not descended + * into — the spec defines `x-mcp-header` as a property-schema annotation, and + * the reference scenarios place it only under `properties`. + */ +export function scanXMcpHeaderDeclarations(inputSchema: unknown): XMcpHeaderScanResult { + const declarations: XMcpHeaderDeclaration[] = []; + const seenLower = new Map(); + + const visit = (node: unknown, path: readonly string[]): string | undefined => { + if (node === null || typeof node !== 'object') return undefined; + const schema = node as Record; + + if (X_MCP_HEADER_KEY in schema) { + const raw = schema[X_MCP_HEADER_KEY]; + if (typeof raw !== 'string' || raw.length === 0) { + return `${pathName(path)}: x-mcp-header MUST be a non-empty string`; + } + if (!RFC9110_TOKEN.test(raw)) { + return `${pathName(path)}: x-mcp-header '${raw}' is not a valid RFC 9110 token (no spaces, control characters or HTTP delimiters)`; + } + const type = typeof schema.type === 'string' ? schema.type : undefined; + if (type === undefined || !PERMITTED_X_MCP_HEADER_TYPES.has(type)) { + return `${pathName(path)}: x-mcp-header is only permitted on primitive-typed properties (string, integer, boolean); got ${type ?? ''}`; + } + const lower = raw.toLowerCase(); + const prior = seenLower.get(lower); + if (prior !== undefined) { + return `x-mcp-header '${raw}' is not case-insensitively unique (also declared as '${prior}')`; + } + seenLower.set(lower, raw); + declarations.push({ path, headerName: raw, type }); + } + + const properties = schema.properties; + if (properties !== null && typeof properties === 'object') { + for (const [key, child] of Object.entries(properties as Record)) { + const fault = visit(child, [...path, key]); + if (fault !== undefined) return fault; + } + } + return undefined; + }; + + const fault = visit(inputSchema, []); + return fault === undefined ? { valid: true, declarations } : { valid: false, reason: fault }; +} + +function pathName(path: readonly string[]): string { + return path.length === 0 ? '' : path.join('.'); +} + +/* ------------------------------------------------------------------------ * + * Value encoding + * ------------------------------------------------------------------------ */ + +const BASE64_SENTINEL_PREFIX = '=?base64?'; +const BASE64_SENTINEL_SUFFIX = '?='; +// RFC 4648 §4, padding required (the spec's encoding-examples table and the +// conformance referee's invalid-padding cell both require canonical padding). +const BASE64_CANONICAL = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + +/** + * Convert a primitive argument value to its string representation per the + * spec's type-conversion rules: strings pass through, integers and numbers + * become their decimal string, booleans become lowercase `'true'` / `'false'`. + * Non-finite numbers and integers outside the safe range are refused (the + * caller treats `undefined` as "do not emit a header for this value"). + */ +export function mcpParamPrimitiveToString(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'number') { + if (!Number.isFinite(value)) return undefined; + if (Number.isInteger(value) && !Number.isSafeInteger(value)) return undefined; + return String(value); + } + return undefined; +} + +/** + * `true` when `s` cannot be safely represented as a plain ASCII HTTP field + * value per RFC 9110 §5.5: it contains a byte outside `0x20–0x7E` / `0x09`, it + * has leading or trailing whitespace (which field parsing strips), or it + * already matches the Base64 sentinel pattern (the spec's "to avoid ambiguity" + * rule). + */ +function needsBase64(s: string): boolean { + if (s.length === 0) return true; + if (s.startsWith(BASE64_SENTINEL_PREFIX) && s.endsWith(BASE64_SENTINEL_SUFFIX)) return true; + if (s !== s.trim()) return true; + for (let i = 0; i < s.length; i++) { + const c = s.codePointAt(i)!; + // Visible ASCII 0x21–0x7E, plus space 0x20 and horizontal tab 0x09; a + // tab is only safe when it is interior whitespace (the trim() check + // above already covered leading/trailing). + if (c === 0x09 || (c >= 0x20 && c <= 0x7e)) continue; + return true; + } + return false; +} + +function utf8ToBase64(s: string): string { + const bytes = new TextEncoder().encode(s); + let bin = ''; + for (const b of bytes) bin += String.fromCodePoint(b); + return btoa(bin); +} + +function base64ToUtf8(b64: string): string { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.codePointAt(i)!; + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); +} + +/** + * Encode a string value as an HTTP field value per the spec's value-encoding + * rules: a value that is already a safe plain-ASCII field value is passed + * through unchanged; anything else is wrapped as `=?base64?{b64-of-utf8}?=`. + */ +export function encodeMcpParamValue(value: string): string { + return needsBase64(value) ? `${BASE64_SENTINEL_PREFIX}${utf8ToBase64(value)}${BASE64_SENTINEL_SUFFIX}` : value; +} + +/** + * Decode an `Mcp-Param-*` header value: when it carries the Base64 sentinel, + * the payload is decoded as UTF-8; otherwise the value is returned as-is. + * Returns `undefined` when the sentinel is present but the payload is not + * canonical Base64 (or not valid UTF-8) — the spec requires servers to reject + * such values. + */ +export function decodeMcpParamValue(value: string): string | undefined { + if (!(value.startsWith(BASE64_SENTINEL_PREFIX) && value.endsWith(BASE64_SENTINEL_SUFFIX))) { + return value; + } + const b64 = value.slice(BASE64_SENTINEL_PREFIX.length, value.length - BASE64_SENTINEL_SUFFIX.length); + if (!BASE64_CANONICAL.test(b64)) return undefined; + try { + return base64ToUtf8(b64); + } catch { + return undefined; + } +} + +/* ------------------------------------------------------------------------ * + * Client-side header construction (the 5-step MUST algorithm, steps 3–5) + * ------------------------------------------------------------------------ */ + +function valueAtPath(root: unknown, path: readonly string[]): unknown { + let node: unknown = root; + for (const key of path) { + if (node === null || typeof node !== 'object') return undefined; + node = (node as Record)[key]; + } + return node; +} + +/** + * Build the `Mcp-Param-{Name}` headers for one `tools/call` from a scan of the + * tool's `inputSchema` and the call's `arguments`. A declaration whose value is + * `null` or absent in `arguments` is omitted (the spec's "client MUST omit the + * header" rows); a value that is not a primitive of the declared kind is + * omitted rather than emitted malformed. + */ +export function buildMcpParamHeaders( + declarations: readonly XMcpHeaderDeclaration[], + args: Record | undefined +): Record { + const out: Record = {}; + for (const decl of declarations) { + const raw = valueAtPath(args, decl.path); + if (raw === undefined || raw === null) continue; + const stringValue = mcpParamPrimitiveToString(raw); + if (stringValue === undefined) continue; + out[`${MCP_PARAM_HEADER_PREFIX}${decl.headerName}`] = encodeMcpParamValue(stringValue); + } + return out; +} + +/* ------------------------------------------------------------------------ * + * Server-side validation + * ------------------------------------------------------------------------ */ + +/** + * The header/body comparison the server performs at tool-resolution time. + * + * For each `x-mcp-header` declaration on the named tool: when the body + * `arguments` carries a value, the matching `Mcp-Param-{Name}` header MUST be + * present and decode to an equal value; when the body value is `null` or + * absent the server MUST NOT expect the header (a present header is ignored). + * A sentinel-carrying header whose payload is not canonical Base64 / valid + * UTF-8 is rejected as invalid characters. + * + * Integer-typed declarations are compared numerically (the spec's SHOULD — + * `42.0` and `42` are equal); everything else is compared as decoded strings. + * + * Returns `undefined` when every check passes, or an + * {@linkcode InboundLadderRejection} carrying the same `-32001` + * (`HeaderMismatch`) shape the inbound classifier emits for the + * standard-header cross-checks — `400 Bad Request` with the disagreeing pair + * in `data.mismatch`. + */ +export function validateMcpParamHeaders( + declarations: readonly XMcpHeaderDeclaration[], + args: Record | undefined, + headers: Headers +): InboundLadderRejection | undefined { + for (const decl of declarations) { + const headerKey = `${MCP_PARAM_HEADER_PREFIX}${decl.headerName}`; + const headerValue = headers.get(headerKey); + const bodyRaw = valueAtPath(args, decl.path); + + if (bodyRaw === undefined || bodyRaw === null) { + // Server MUST NOT expect the header for a null/absent value. + continue; + } + const bodyString = mcpParamPrimitiveToString(bodyRaw); + if (bodyString === undefined) { + // Body carries a non-primitive where the schema declares one; + // params validation owns that fault. Skip the header check. + continue; + } + if (headerValue === null) { + return paramHeaderMismatchRejection( + 'param-header-missing', + headerKey, + `the body carries ${pathName(decl.path)}=${JSON.stringify(bodyRaw)} but the ${headerKey} header is absent` + ); + } + const decoded = decodeMcpParamValue(headerValue); + if (decoded === undefined) { + return paramHeaderMismatchRejection( + 'param-header-invalid-encoding', + headerKey, + `the ${headerKey} header carries an invalid Base64 sentinel value` + ); + } + // Integer/number-typed declarations compare numerically (the spec's + // SHOULD — `42.0` and `42` are equal), but only when both sides parse + // to finite numbers. A non-numeric primitive (e.g. `'abc'` where the + // schema declares `integer`) is a body-vs-schema fault that params + // validation owns; comparing `NaN === NaN` would wrongly report a + // header/body mismatch for an identical pair, so fall back to string + // comparison and let dispatch emit `-32602` instead. + const decodedNum = Number(decoded); + const bodyNum = Number(bodyString); + const numericComparable = + (decl.type === 'integer' || decl.type === 'number') && Number.isFinite(decodedNum) && Number.isFinite(bodyNum); + const equal = numericComparable ? decodedNum === bodyNum : decoded === bodyString; + if (!equal) { + return paramHeaderMismatchRejection( + 'param-header-mismatch', + headerKey, + `the ${headerKey} header decodes to ${JSON.stringify(decoded)} but the body carries ${pathName(decl.path)}=${JSON.stringify(bodyRaw)}` + ); + } + } + return undefined; +} + +/** + * Build the `-32001` (`HeaderMismatch`) rejection for an `Mcp-Param-*` + * disagreement. Same shape as the inbound classifier's standard-header + * cross-check mismatch (HTTP `400`, `data.mismatch` naming the disagreeing + * pair, `settled: true`); only the rung differs because this check runs at the + * pre-dispatch step against a known tool's schema rather than at the edge. + */ +export function paramHeaderMismatchRejection(cell: string, header: string, body: string): InboundLadderRejection { + return { + kind: 'reject', + rung: 'param-header-validation', + cell, + httpStatus: 400, + code: HEADER_MISMATCH_ERROR_CODE, + message: `Bad Request: the request headers and body disagree: ${body}`, + data: { mismatch: { header, body } }, + settled: true + }; +} diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 356d63ea08..46064bc938 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -49,7 +49,7 @@ import type { StandardSchemaV1 } from '../util/standardSchema.js'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema.js'; import { bootstrapOutboundCodec } from '../wire/bootstrap.js'; import type { LiftedWireMaterial, WireCodec } from '../wire/codec.js'; -import { classifiedWireEra, codecForVersion, isSpecNotificationMethod, isSpecRequestMethod } from '../wire/codec.js'; +import { classifiedWireEra, codecForVersion, isSpecNotificationMethod, isSpecRequestMethod, MODERN_WIRE_REVISION } from '../wire/codec.js'; import { manualInputRequiredValue, partitionInputResponses } from './inputRequiredEngine.js'; import type { Transport, TransportSendOptions } from './transport.js'; @@ -1269,7 +1269,7 @@ export abstract class Protocol { resultSchema: T, options?: RequestOptions ): Promise> { - const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {}; + const { relatedRequestId, resumptionToken, onresumptiontoken, headers } = options ?? {}; // Flow start for non-complete result resolution: `maxTotalTimeout` // bounds the WHOLE flow, so the budget is measured from the original // request, not from when an extension takes over after the first leg. @@ -1300,6 +1300,19 @@ export abstract class Protocol { options?.signal?.throwIfAborted(); + // Spec basic/patterns/cancellation §Transport-Specific (2026-07-28): + // on Streamable HTTP, closing the per-request SSE stream IS the + // cancellation signal — "no notifications/cancelled message is + // required or expected". When the negotiated era is modern AND the + // transport opens a per-request stream (`hasPerRequestStream`), + // cancel() aborts that stream via `requestSignal` INSTEAD OF + // POSTing `notifications/cancelled`. Every other (era × transport) + // combination — legacy era on any transport, modern era on stdio / + // in-memory — keeps today's `notifications/cancelled` POST path + // unchanged. + const streamCloseCancels = codec.era === MODERN_WIRE_REVISION && this._transport.hasPerRequestStream === true; + const requestAbort = streamCloseCancels ? new AbortController() : undefined; + const messageId = this._requestMessageId++; cleanupMessageId = messageId; const jsonrpcRequest: JSONRPCRequest = { @@ -1333,19 +1346,28 @@ export abstract class Protocol { } this._progressHandlers.delete(messageId); - this._transport - ?.send( - this._envelopeOutbound({ - jsonrpc: '2.0', - method: 'notifications/cancelled', - params: { - requestId: messageId, - reason: String(reason) - } - }), - { relatedRequestId, resumptionToken, onresumptiontoken } - ) - .catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`))); + if (requestAbort === undefined) { + this._transport + ?.send( + this._envelopeOutbound({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { + requestId: messageId, + reason: String(reason) + } + }), + { relatedRequestId, resumptionToken, onresumptiontoken } + ) + .catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`))); + } else { + // Modern-era per-request-stream transport: aborting the + // request's underlying stream IS the spec cancel signal. + // The transport already swallows the resulting AbortError + // (no spurious `onerror`); a post-abort send() rejection + // re-hits an already-settled promise below and is a no-op. + requestAbort.abort(); + } // Wrap the reason in an SdkError if it isn't already const error = reason instanceof SdkError ? reason : new SdkError(SdkErrorCode.RequestTimeout, String(reason)); @@ -1430,10 +1452,12 @@ export abstract class Protocol { this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); - this._transport.send(outbound, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { - this._progressHandlers.delete(messageId); - reject(error); - }); + this._transport + .send(outbound, { relatedRequestId, resumptionToken, onresumptiontoken, headers, requestSignal: requestAbort?.signal }) + .catch(error => { + this._progressHandlers.delete(messageId); + reject(error); + }); }).finally(() => { // Per-request cleanup that must run on every exit path. Consolidated // here so new exit paths added to the promise body can't forget it. diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index c9be6ee56c..ce64b67fdd 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -87,6 +87,19 @@ export type TransportSendOptions = { * (stdio, in-memory) ignore it. */ onRequestStreamEnd?: (() => void) | undefined; + + /** + * Additional HTTP headers to send with THIS outbound message, when the + * transport sends one outbound message per underlying HTTP request (the + * Streamable HTTP transport's POST-per-request model). Transports that + * share a single channel (stdio, in-memory) ignore it. + * + * The Client uses this to attach SEP-2243 `Mcp-Param-{Name}` headers to a + * `tools/call` request on a 2026-07-28 connection. Values are sent + * verbatim — encode anything that is not a safe RFC 9110 field value + * before passing it here. + */ + headers?: Readonly> | undefined; }; /** * Describes the minimal contract for an MCP transport that a client or server can communicate over. @@ -113,6 +126,18 @@ export interface Transport { */ close(): Promise; + /** + * `true` when this transport opens one underlying request per outbound + * JSON-RPC request (the Streamable HTTP POST-per-request model) and + * therefore honors {@linkcode TransportSendOptions.requestSignal}. The + * 2026-07-28 spec makes closing that per-request stream the cancellation + * signal — the protocol layer aborts `requestSignal` instead of POSTing + * `notifications/cancelled` when this flag is set on a 2026-era + * connection. Transports that share a single channel (stdio, in-memory) + * leave it `undefined`. + */ + readonly hasPerRequestStream?: boolean; + /** * Callback for when the connection is closed for any reason. * diff --git a/packages/core/test/shared/inputRequiredEngine.test.ts b/packages/core/test/shared/inputRequiredEngine.test.ts index af4f9eeed7..2ba089808c 100644 --- a/packages/core/test/shared/inputRequiredEngine.test.ts +++ b/packages/core/test/shared/inputRequiredEngine.test.ts @@ -48,6 +48,12 @@ describe('per-retry-leg request options whitelist', () => { test('absent caller options yield only the manual primitive opt-in', () => { expect(buildRetryLegRequestOptions(undefined, {})).toEqual({ allowInputRequired: true }); }); + + test('per-request headers (SEP-2243 Mcp-Param-*) carry to retry legs — arguments are unchanged on retry', () => { + const headers = { 'Mcp-Param-Region': 'us-west1' }; + const built = buildRetryLegRequestOptions({ headers }, {}); + expect(built).toEqual({ headers, allowInputRequired: true }); + }); }); describe('inputResponses partition', () => { diff --git a/packages/core/test/shared/mcpParamHeaders.test.ts b/packages/core/test/shared/mcpParamHeaders.test.ts new file mode 100644 index 0000000000..55963a9691 --- /dev/null +++ b/packages/core/test/shared/mcpParamHeaders.test.ts @@ -0,0 +1,255 @@ +/** + * SEP-2243 `Mcp-Param-*` codec — fixture corpus. + * + * Encoding rows mirror the spec's "Encoding examples" table (and the + * sentinel-collision rule); the constraint rows mirror the published + * conformance referee's `http-invalid-tool-headers` scenario; the + * server-validation rows cover the spec's server-behavior table including the + * two checks the conformance manifest leaves globally untested + * (`sep-2243-server-not-expect-null`, `sep-2243-server-reject-missing-required`). + */ +import { describe, expect, test } from 'vitest'; + +import { HEADER_MISMATCH_ERROR_CODE } from '../../src/shared/inboundClassification.js'; +import { + buildMcpParamHeaders, + decodeMcpParamValue, + encodeMcpParamValue, + MCP_PARAM_HEADER_PREFIX, + mcpParamPrimitiveToString, + paramHeaderMismatchRejection, + scanXMcpHeaderDeclarations, + validateMcpParamHeaders, + X_MCP_HEADER_KEY +} from '../../src/shared/mcpParamHeaders.js'; + +/* ------------------------------------------------------------------------ * + * Value encoding (spec table) + * ------------------------------------------------------------------------ */ + +describe('encodeMcpParamValue / decodeMcpParamValue — spec encoding-examples table', () => { + const CASES: ReadonlyArray<[label: string, input: string, expected: string]> = [ + ['plain ASCII passes through', 'us-west1', 'us-west1'], + ['non-ASCII is Base64-wrapped', 'Hello, 世界', '=?base64?SGVsbG8sIOS4lueVjA==?='], + ['leading + trailing whitespace is Base64-wrapped', ' padded ', '=?base64?IHBhZGRlZCA=?='], + ['embedded newline is Base64-wrapped', 'line1\nline2', '=?base64?bGluZTEKbGluZTI=?='], + ['a value matching the sentinel pattern is itself Base64-wrapped', '=?base64?literal?=', '=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?='], + ['the empty string is Base64-wrapped (would otherwise vanish on the wire)', '', '=?base64??='], + ['internal-only spaces stay plain ASCII (RFC 9110 admits SP inside a field value)', 'a b c', 'a b c'], + ['leading-only space is Base64-wrapped', ' lead', `=?base64?${btoa(' lead')}?=`], + ['trailing-only space is Base64-wrapped', 'trail ', `=?base64?${btoa('trail ')}?=`], + ['CR/LF is Base64-wrapped', 'a\r\nb', `=?base64?${btoa('a\r\nb')}?=`], + ['leading tab is Base64-wrapped', '\tindent', `=?base64?${btoa('\tindent')}?=`] + ]; + + for (const [label, input, expected] of CASES) { + test(label, () => { + const encoded = encodeMcpParamValue(input); + expect(encoded).toBe(expected); + expect(decodeMcpParamValue(encoded)).toBe(input); + }); + } + + test('decode passes a non-sentinel value through unchanged', () => { + expect(decodeMcpParamValue('us-west1')).toBe('us-west1'); + }); + + test('decode rejects invalid Base64 padding inside the sentinel', () => { + expect(decodeMcpParamValue('=?base64?SGVsbG8?=')).toBeUndefined(); + }); + + test('decode rejects non-alphabet characters inside the sentinel', () => { + expect(decodeMcpParamValue('=?base64?SGV%%G8=?=')).toBeUndefined(); + }); +}); + +describe('mcpParamPrimitiveToString — type-conversion rules', () => { + test('string passes through', () => expect(mcpParamPrimitiveToString('a')).toBe('a')); + test('boolean true → "true"', () => expect(mcpParamPrimitiveToString(true)).toBe('true')); + test('boolean false → "false"', () => expect(mcpParamPrimitiveToString(false)).toBe('false')); + test('integer → decimal string', () => expect(mcpParamPrimitiveToString(42)).toBe('42')); + test('negative integer → decimal string', () => expect(mcpParamPrimitiveToString(-7)).toBe('-7')); + test('non-finite is refused', () => expect(mcpParamPrimitiveToString(Number.POSITIVE_INFINITY)).toBeUndefined()); + test('integer outside ±(2^53-1) is refused', () => expect(mcpParamPrimitiveToString(2 ** 53)).toBeUndefined()); + test('object is refused', () => expect(mcpParamPrimitiveToString({})).toBeUndefined()); +}); + +/* ------------------------------------------------------------------------ * + * Declaration scan (constraint rows from http-invalid-tool-headers) + * ------------------------------------------------------------------------ */ + +describe('scanXMcpHeaderDeclarations — constraint table', () => { + const valid = (schema: unknown) => { + const r = scanXMcpHeaderDeclarations(schema); + expect(r.valid).toBe(true); + return r.valid ? r.declarations : []; + }; + const invalid = (schema: unknown) => { + const r = scanXMcpHeaderDeclarations(schema); + expect(r.valid).toBe(false); + return r.valid ? '' : r.reason; + }; + + test('a valid declaration is collected', () => { + const decls = valid({ type: 'object', properties: { region: { type: 'string', [X_MCP_HEADER_KEY]: 'Region' } } }); + expect(decls).toEqual([{ path: ['region'], headerName: 'Region', type: 'string' }]); + }); + + test('declarations at any nesting depth are collected', () => { + const decls = valid({ + type: 'object', + properties: { + outer: { type: 'object', properties: { inner: { type: 'string', [X_MCP_HEADER_KEY]: 'Inner' } } } + } + }); + expect(decls).toEqual([{ path: ['outer', 'inner'], headerName: 'Inner', type: 'string' }]); + }); + + test('a schema with no declarations scans valid with an empty list', () => { + expect(valid({ type: 'object', properties: { a: { type: 'string' } } })).toEqual([]); + }); + + test('empty x-mcp-header value is rejected', () => { + expect(invalid({ type: 'object', properties: { a: { type: 'string', [X_MCP_HEADER_KEY]: '' } } })).toMatch(/non-empty/); + }); + + test('non-token x-mcp-header value (space) is rejected', () => { + expect(invalid({ type: 'object', properties: { a: { type: 'string', [X_MCP_HEADER_KEY]: 'My Region' } } })).toMatch( + /RFC 9110 token/ + ); + }); + + test('object-typed property is rejected', () => { + expect(invalid({ type: 'object', properties: { a: { type: 'object', [X_MCP_HEADER_KEY]: 'Data' } } })).toMatch(/primitive/); + }); + + test('array-typed property is rejected', () => { + expect(invalid({ type: 'object', properties: { a: { type: 'array', [X_MCP_HEADER_KEY]: 'Items' } } })).toMatch(/primitive/); + }); + + test('null-typed property is rejected', () => { + expect(invalid({ type: 'object', properties: { a: { type: 'null', [X_MCP_HEADER_KEY]: 'Nil' } } })).toMatch(/primitive/); + }); + + test('case-insensitively duplicated header name is rejected', () => { + expect( + invalid({ + type: 'object', + properties: { + a: { type: 'string', [X_MCP_HEADER_KEY]: 'MyField' }, + b: { type: 'string', [X_MCP_HEADER_KEY]: 'myfield' } + } + }) + ).toMatch(/unique/); + }); +}); + +/* ------------------------------------------------------------------------ * + * buildMcpParamHeaders — null/absent omission, primitive emission + * ------------------------------------------------------------------------ */ + +describe('buildMcpParamHeaders', () => { + const DECLS = [ + { path: ['region'], headerName: 'Region', type: 'string' }, + { path: ['priority'], headerName: 'Priority', type: 'integer' }, + { path: ['verbose'], headerName: 'Verbose', type: 'boolean' } + ] as const; + + test('present primitive values become headers; null and absent are omitted', () => { + expect(buildMcpParamHeaders(DECLS, { region: 'us-west1', priority: 5, verbose: null })).toEqual({ + 'Mcp-Param-Region': 'us-west1', + 'Mcp-Param-Priority': '5' + }); + }); + + test('a non-primitive value is silently omitted (params validation owns that fault)', () => { + expect(buildMcpParamHeaders([{ path: ['region'], headerName: 'Region', type: 'string' }], { region: { x: 1 } })).toEqual({}); + }); +}); + +/* ------------------------------------------------------------------------ * + * Server-side validation — the spec's server-behavior table + * ------------------------------------------------------------------------ */ + +describe('validateMcpParamHeaders — server-behavior table', () => { + const DECLS = [{ path: ['region'], headerName: 'Region', type: 'string' }] as const; + + test('header present and matching → ok', () => { + const headers = new Headers({ [`${MCP_PARAM_HEADER_PREFIX}Region`]: 'us-west1' }); + expect(validateMcpParamHeaders(DECLS, { region: 'us-west1' }, headers)).toBeUndefined(); + }); + + test('header decodes from Base64 and matches → ok', () => { + const headers = new Headers({ [`${MCP_PARAM_HEADER_PREFIX}Region`]: encodeMcpParamValue('Hello, 世界') }); + expect(validateMcpParamHeaders(DECLS, { region: 'Hello, 世界' }, headers)).toBeUndefined(); + }); + + // sep-2243-server-not-expect-null — globally-untested manifest check, covered here. + test('body value null → server MUST NOT expect the header (a stray header is ignored)', () => { + const headers = new Headers({ [`${MCP_PARAM_HEADER_PREFIX}Region`]: 'whatever' }); + expect(validateMcpParamHeaders(DECLS, { region: null }, headers)).toBeUndefined(); + expect(validateMcpParamHeaders(DECLS, {}, new Headers())).toBeUndefined(); + }); + + // sep-2243-server-reject-missing-required — globally-untested manifest check, covered here. + test('body has the value but the header is absent → reject 400/-32001', () => { + const r = validateMcpParamHeaders(DECLS, { region: 'us-west1' }, new Headers()); + expect(r).toMatchObject({ kind: 'reject', httpStatus: 400, code: HEADER_MISMATCH_ERROR_CODE, cell: 'param-header-missing' }); + }); + + test('header present but disagreeing → reject 400/-32001 with the mismatch in data', () => { + const r = validateMcpParamHeaders(DECLS, { region: 'us-west1' }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}Region`]: 'eu' })); + expect(r).toMatchObject({ + kind: 'reject', + httpStatus: 400, + code: HEADER_MISMATCH_ERROR_CODE, + cell: 'param-header-mismatch', + data: { mismatch: { header: 'Mcp-Param-Region' } } + }); + }); + + test('invalid Base64 sentinel → reject 400/-32001', () => { + const r = validateMcpParamHeaders( + DECLS, + { region: 'Hello' }, + new Headers({ [`${MCP_PARAM_HEADER_PREFIX}Region`]: '=?base64?SGVsbG8?=' }) + ); + expect(r).toMatchObject({ + kind: 'reject', + httpStatus: 400, + code: HEADER_MISMATCH_ERROR_CODE, + cell: 'param-header-invalid-encoding' + }); + }); + + test('integer-typed declarations are compared numerically (42.0 == 42)', () => { + const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const; + expect(validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: '42.0' }))).toBeUndefined(); + }); + + test('a non-numeric primitive in a number-declared param falls back to string comparison (no false NaN mismatch)', () => { + const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const; + // Identical header/body — must NOT report a header/body disagreement; + // params validation owns the body-vs-schema fault. + expect(validateMcpParamHeaders(intDecl, { n: 'abc' }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: 'abc' }))).toBeUndefined(); + // Different values still reject as a mismatch. + const r = validateMcpParamHeaders(intDecl, { n: 'abc' }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: 'xyz' })); + expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' }); + }); +}); + +describe('paramHeaderMismatchRejection — consumes the inbound-classifier −32001 shape verbatim', () => { + test('shape: 400 / -32001 / settled, with data.mismatch and the same message prefix', () => { + const r = paramHeaderMismatchRejection('param-header-mismatch', 'Mcp-Param-Region', 'body says us-west1'); + expect(r).toEqual({ + kind: 'reject', + rung: 'param-header-validation', + cell: 'param-header-mismatch', + httpStatus: 400, + code: HEADER_MISMATCH_ERROR_CODE, + message: 'Bad Request: the request headers and body disagree: body says us-west1', + data: { mismatch: { header: 'Mcp-Param-Region', body: 'body says us-west1' } }, + settled: true + }); + }); +}); diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 309cf6a50a..1103c24920 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -4,7 +4,7 @@ import * as z from 'zod/v4'; import type { ZodType } from 'zod/v4'; import type { BaseContext } from '../../src/shared/protocol.js'; -import { mergeCapabilities, Protocol } from '../../src/shared/protocol.js'; +import { mergeCapabilities, Protocol, setNegotiatedProtocolVersion } from '../../src/shared/protocol.js'; import type { Transport, TransportSendOptions } from '../../src/shared/transport.js'; import type { ClientCapabilities, @@ -820,6 +820,98 @@ describe('protocol tests', () => { expect(wasAborted).toBe(true); }); }); + + // Spec basic/patterns/cancellation §Transport-Specific (2026-07-28): on a + // per-request-stream transport (Streamable HTTP), closing that stream IS + // the cancel signal — no `notifications/cancelled` is sent. Legacy era and + // single-channel transports keep the `notifications/cancelled` POST path. + describe('outbound request cancellation: stream-close vs notifications/cancelled', () => { + /** Mock transport that records the requestSignal it was handed and every outbound message. */ + class PerRequestStreamTransport extends MockTransport { + readonly hasPerRequestStream = true; + sent: JSONRPCMessage[] = []; + lastRequestSignal: AbortSignal | undefined; + override async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { + this.sent.push(message); + this.lastRequestSignal = options?.requestSignal; + } + } + + const cancelledSent = (sent: JSONRPCMessage[]): JSONRPCMessage[] => + sent.filter(m => 'method' in m && m.method === 'notifications/cancelled'); + + test('modern era + per-request-stream transport: abort closes the stream, NO notifications/cancelled', async () => { + const tx = new PerRequestStreamTransport(); + const proto = createTestProtocol(); + await proto.connect(tx); + setNegotiatedProtocolVersion(proto, '2026-07-28'); + + const ac = new AbortController(); + const pending = testRequest(proto, { method: 'example', params: {} }, z.object({}), { signal: ac.signal }); + + // The transport was handed a per-request requestSignal. + expect(tx.lastRequestSignal).toBeInstanceOf(AbortSignal); + expect(tx.lastRequestSignal?.aborted).toBe(false); + + ac.abort('user cancel'); + await expect(pending).rejects.toThrow(); + + // Stream-close IS the signal: requestSignal aborted, no cancelled notification on the wire. + expect(tx.lastRequestSignal?.aborted).toBe(true); + expect(cancelledSent(tx.sent)).toHaveLength(0); + }); + + test('modern era + single-channel transport (no hasPerRequestStream): POSTs notifications/cancelled', async () => { + // stdio / in-memory shape: hasPerRequestStream is undefined. + const sent: JSONRPCMessage[] = []; + const tx = new MockTransport(); + tx.send = async (m: JSONRPCMessage, _opts?: TransportSendOptions) => { + sent.push(m); + }; + const proto = createTestProtocol(); + await proto.connect(tx); + setNegotiatedProtocolVersion(proto, '2026-07-28'); + + const ac = new AbortController(); + const pending = testRequest(proto, { method: 'example', params: {} }, z.object({}), { signal: ac.signal }); + ac.abort('user cancel'); + await expect(pending).rejects.toThrow(); + + // stdio MUST send notifications/cancelled (spec). + expect(cancelledSent(sent)).toHaveLength(1); + }); + + test('legacy era + per-request-stream transport: behavior unchanged — POSTs notifications/cancelled, no requestSignal', async () => { + const tx = new PerRequestStreamTransport(); + const proto = createTestProtocol(); + await proto.connect(tx); + setNegotiatedProtocolVersion(proto, '2025-11-25'); + + const ac = new AbortController(); + const pending = testRequest(proto, { method: 'example', params: {} }, z.object({}), { signal: ac.signal }); + + // Legacy path is byte-identical to before: no requestSignal threaded. + expect(tx.lastRequestSignal).toBeUndefined(); + + ac.abort('user cancel'); + await expect(pending).rejects.toThrow(); + + expect(cancelledSent(tx.sent)).toHaveLength(1); + }); + + test('modern era + per-request-stream transport: timeout aborts the stream, NO notifications/cancelled', async () => { + const tx = new PerRequestStreamTransport(); + const proto = createTestProtocol(); + await proto.connect(tx); + setNegotiatedProtocolVersion(proto, '2026-07-28'); + + const pending = testRequest(proto, { method: 'example', params: {} }, z.object({}), { timeout: 0 }); + await expect(pending).rejects.toThrow(); + + expect(tx.lastRequestSignal?.aborted).toBe(true); + expect(cancelledSent(tx.sent)).toHaveLength(0); + }); + }); }); // (2025-11 experimental test suites removed under SEP-2663; see git history.) diff --git a/packages/core/test/shared/standardHeaderValidation.test.ts b/packages/core/test/shared/standardHeaderValidation.test.ts new file mode 100644 index 0000000000..97baadb407 --- /dev/null +++ b/packages/core/test/shared/standardHeaderValidation.test.ts @@ -0,0 +1,191 @@ +/** + * SEP-2243 standard-header server-side validation + * (`validateStandardRequestHeaders`). + * + * Evaluated by the HTTP entry on a modern-classified request immediately + * after `classifyInboundRequest` returns a modern route: rejects `400` / + * `-32001` (`HeaderMismatch`) when the required `Mcp-Method` header is + * absent, when the required `Mcp-Name` header is absent on a `tools/call` / + * `prompts/get` / `resources/read` request, when the `Mcp-Name` header + * carries an invalid Base64 sentinel, and when its (decoded) value disagrees + * with the body's `params.name` / `params.uri`. Never enforced on + * notifications or on methods without an `Mcp-Name` source. + * + * The classifier itself is left unchanged by these rungs (it stays a + * body-primary router that passes a modern request through when no headers + * are supplied) — this function is the presence/`Mcp-Name` half of the + * standard-header rung the entry layers on top, so the existing + * `inboundClassification` and cell-sheet tests stay byte-untouched. + */ +import { describe, expect, test } from 'vitest'; + +import type { InboundHttpRequest, InboundLadderRejection, InboundModernRoute } from '../../src/shared/inboundClassification.js'; +import { classifyInboundRequest, MCP_NAME_HEADER_SOURCE, validateStandardRequestHeaders } from '../../src/shared/inboundClassification.js'; +import { encodeMcpParamValue } from '../../src/shared/mcpParamHeaders.js'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../src/types/constants.js'; + +const MODERN = '2026-07-28'; +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'std-header-test', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +function modernPost( + method: string, + params: Record, + headers: { mcpMethod?: string; mcpName?: string } = {} +): { request: InboundHttpRequest; route: InboundModernRoute } { + const request: InboundHttpRequest = { + httpMethod: 'POST', + protocolVersionHeader: MODERN, + ...(headers.mcpMethod !== undefined && { mcpMethodHeader: headers.mcpMethod }), + ...(headers.mcpName !== undefined && { mcpNameHeader: headers.mcpName }), + body: { jsonrpc: '2.0', id: 1, method, params: { ...params, _meta: ENVELOPE } } + }; + const outcome = classifyInboundRequest(request); + if (outcome.kind !== 'modern') { + throw new Error(`expected a modern route, got ${outcome.kind}`); + } + return { request, route: outcome }; +} + +function expectRejection(result: InboundLadderRejection | undefined, cell: string): void { + expect(result).toBeDefined(); + expect(result?.kind).toBe('reject'); + expect(result?.cell).toBe(cell); + expect(result?.rung).toBe('standard-header-validation'); + expect(result?.httpStatus).toBe(400); + expect(result?.code).toBe(-32_001); + expect(result?.settled).toBe(true); +} + +describe('SEP-2243 standard-header validation (Mcp-Method presence)', () => { + test('a modern request without an Mcp-Method header is rejected (method-header-missing)', () => { + const { request, route } = modernPost('tools/list', {}); + expectRejection(validateStandardRequestHeaders(request, route), 'method-header-missing'); + }); + + test('a present Mcp-Method header passes for a method with no Mcp-Name source', () => { + const { request, route } = modernPost('tools/list', {}, { mcpMethod: 'tools/list' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('the Mcp-Method mismatch cell stays inside classifyInboundRequest (precedence over presence)', () => { + // The mismatch is answered by the classifier itself; this function + // never sees a route for that input. Asserted here so the + // standard-header rung's two halves stay observably ordered. + const inbound: InboundHttpRequest = { + httpMethod: 'POST', + protocolVersionHeader: MODERN, + mcpMethodHeader: 'prompts/list', + body: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: ENVELOPE } } + }; + const outcome = classifyInboundRequest(inbound); + expect(outcome.kind).toBe('reject'); + expect((outcome as InboundLadderRejection).cell).toBe('method-header-mismatch'); + }); + + test('notifications are never enforced', () => { + const route: InboundModernRoute = { + kind: 'modern', + messageKind: 'notification', + message: { jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 1 } }, + classification: { era: 'modern', revision: MODERN } + }; + expect(validateStandardRequestHeaders({ httpMethod: 'POST' }, route)).toBeUndefined(); + }); +}); + +describe('SEP-2243 standard-header validation (Mcp-Name presence and cross-check)', () => { + test('a tools/call without an Mcp-Name header is rejected (name-header-missing)', () => { + const { request, route } = modernPost('tools/call', { name: 'echo', arguments: {} }, { mcpMethod: 'tools/call' }); + expectRejection(validateStandardRequestHeaders(request, route), 'name-header-missing'); + }); + + test('a resources/read without an Mcp-Name header is rejected and names params.uri', () => { + const { request, route } = modernPost('resources/read', { uri: 'file:///a' }, { mcpMethod: 'resources/read' }); + const result = validateStandardRequestHeaders(request, route); + expectRejection(result, 'name-header-missing'); + expect(result?.message).toContain('params.uri'); + }); + + test('a tools/call whose body has no params.name passes the Mcp-Name presence rung', () => { + // The missing `params.name` is a request-params failure further down + // the ladder; this rung only answers what it can observe. + const { request, route } = modernPost('tools/call', { arguments: {} }, { mcpMethod: 'tools/call' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('an Mcp-Name header disagreeing with params.name is rejected (name-header-mismatch)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'echo', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: 'wrong_tool_name' } + ); + const result = validateStandardRequestHeaders(request, route); + expectRejection(result, 'name-header-mismatch'); + expect((result?.data as { mismatch?: { header?: string } })?.mismatch?.header).toBe('wrong_tool_name'); + }); + + test('a Base64-sentinel Mcp-Name decodes before comparison (matching)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'Hello, 世界', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: encodeMcpParamValue('Hello, 世界') } + ); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('a Base64-sentinel Mcp-Name decodes before comparison (mismatch names the decoded value)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'echo', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: encodeMcpParamValue('not-echo') } + ); + const result = validateStandardRequestHeaders(request, route); + expectRejection(result, 'name-header-mismatch'); + expect(result?.message).toContain('"not-echo"'); + }); + + test('an invalid Base64 sentinel in Mcp-Name is rejected (name-header-invalid-encoding)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'echo', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: '=?base64?SGVs!!!bG8=?=' } + ); + expectRejection(validateStandardRequestHeaders(request, route), 'name-header-invalid-encoding'); + }); + + test('a matching Mcp-Name on a prompts/get passes', () => { + const { request, route } = modernPost('prompts/get', { name: 'greeting' }, { mcpMethod: 'prompts/get', mcpName: 'greeting' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('a matching Mcp-Name on a resources/read compares against params.uri', () => { + const uri = 'file:///projects/app/config.json'; + const { request, route } = modernPost('resources/read', { uri }, { mcpMethod: 'resources/read', mcpName: uri }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('the Mcp-Name source map covers exactly the spec table', () => { + expect(MCP_NAME_HEADER_SOURCE).toEqual({ 'tools/call': 'name', 'prompts/get': 'name', 'resources/read': 'uri' }); + }); + + test('a method colliding with Object.prototype members is treated as off-table (passes through to dispatch)', () => { + // `constructor` would return Object.prototype.constructor on a bare + // lookup; the Object.hasOwn guard keeps the early-return firing. + const { request, route } = modernPost('constructor', {}, { mcpMethod: 'constructor', mcpName: '=?base64?!!?=' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); +}); + +describe('classifyInboundRequest is unchanged by the standard-header presence rung', () => { + test('a body-only modern request (no headers passed) still routes modern', () => { + const outcome = classifyInboundRequest({ + httpMethod: 'POST', + body: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: {}, _meta: ENVELOPE } } + }); + expect(outcome.kind).toBe('modern'); + }); +}); diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 3afd69fbf1..faa140f0d6 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -46,11 +46,14 @@ import { modernOnlyStrictRejection, requestMetaOf, requiredClientCapabilitiesForRequest, + scanXMcpHeaderDeclarations, SdkError, SdkErrorCode, setNegotiatedProtocolVersion, SUPPORTED_MODERN_PROTOCOL_VERSIONS, - UnsupportedProtocolVersionError + UnsupportedProtocolVersionError, + validateMcpParamHeaders, + validateStandardRequestHeaders } from '@modelcontextprotocol/core'; import { invoke } from './invoke.js'; @@ -427,8 +430,12 @@ type EntryClassification = * {@linkcode classifyInboundRequest}. This is the single code path behind both * {@linkcode createMcpHandler}'s routing and the exported * {@linkcode isLegacyRequest} predicate, so the two can never disagree. + * + * Pass `needsForward: false` when the caller never reads `forwardRequest` — + * the body-preserving clone is then skipped and `forwardRequest` is the + * (consumed) input request. */ -async function classifyEntryRequest(request: Request, providedParsedBody?: unknown): Promise { +async function classifyEntryRequest(request: Request, providedParsedBody?: unknown, needsForward = true): Promise { const httpMethod = request.method.toUpperCase(); let body: unknown; @@ -440,8 +447,10 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno if (parsedBody === undefined) { // Read the body exactly once for classification, keeping an unread // copy of the original bytes for the legacy leg (web-standard - // request bodies are single-use). - forwardRequest = request.clone(); + // request bodies are single-use) when the caller needs it. + if (needsForward) { + forwardRequest = request.clone(); + } let bodyText: string; try { bodyText = await request.text(); @@ -469,6 +478,7 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno httpMethod, protocolVersionHeader: request.headers.get('mcp-protocol-version') ?? undefined, mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, + mcpNameHeader: request.headers.get('mcp-name') ?? undefined, ...(body !== undefined && { body }) }); return { step: 'classified', outcome, body, parsedBody, forwardRequest }; @@ -533,9 +543,10 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno export async function isLegacyRequest(request: Request, parsedBody?: unknown): Promise { // Classify a clone so the caller's request body stays readable; with a // pre-parsed body (or a body-less method) nothing is read and no clone is - // needed. + // needed. The predicate never reads forwardRequest, so the classification + // step's own forwarding clone is skipped. const probe = parsedBody === undefined && request.method.toUpperCase() === 'POST' ? request.clone() : request; - const classified = await classifyEntryRequest(probe, parsedBody); + const classified = await classifyEntryRequest(probe, parsedBody, false); return classified.step === 'no-json-body' || (classified.step === 'classified' && classified.outcome.kind === 'legacy'); } @@ -642,6 +653,30 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa return jsonRpcErrorResponse(400, error.code, error.message, error.data, echoableRequestId(route.message)); } + // SEP-2243 standard-header presence and `Mcp-Name` cross-check + // (`standard-header-validation` rung; the `MCP-Protocol-Version` and + // `Mcp-Method` *mismatch* cells are already answered inside + // `classifyInboundRequest` on the edge `era-classification` rung). + // Evaluated after the supported-revision + // gate so an envelope naming a revision this endpoint does not serve + // is still answered with `-32004` (the supported list is the more + // useful answer to a client speaking the wrong revision); evaluated + // before the capability gate, the factory call, and the + // `Mcp-Param-*` rung so a request that fails several rungs is + // answered by the standard-header rung first. + const stdHeaderRejection = validateStandardRequestHeaders( + { + httpMethod: request.method, + mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, + mcpNameHeader: request.headers.get('mcp-name') ?? undefined + }, + route + ); + if (stdHeaderRejection !== undefined) { + reportError(new Error(`Rejected inbound request (${stdHeaderRejection.cell}): ${stdHeaderRejection.message}`)); + return rejectionResponse(stdHeaderRejection, echoableRequestId(route.message)); + } + const meta = route.messageKind === 'request' ? requestMetaOf(route.message.params) : undefined; const declaredClientCapabilities = meta?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined; @@ -693,6 +728,33 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa return listenRouter.serve(route.message, request.signal, capabilities); } + // SEP-2243 `Mcp-Param-*` server-side validation (pre-dispatch ladder + // rung): for a `tools/call`, look up the named tool's JSON inputSchema + // on the just-produced instance and compare every `x-mcp-header` + // declaration against the request's `Mcp-Param-{Name}` headers and the + // body `arguments`. A mismatch (or a missing header for a present body + // value, or an invalid Base64 sentinel) emits the same `400` / + // `-32001` (`HeaderMismatch`) shape the edge cross-checks use. Only + // applied when the factory returns an `McpServer` (the registry is the + // schema source); a low-level `Server` factory has no registry, so + // there is nothing to validate against. + if (route.messageKind === 'request' && route.message.method === 'tools/call' && product instanceof McpServer) { + const callParams = route.message.params as { name?: string; arguments?: Record } | undefined; + const toolName = typeof callParams?.name === 'string' ? callParams.name : undefined; + const inputSchema = toolName === undefined ? undefined : product.toolInputSchemaJson(toolName); + if (inputSchema !== undefined) { + const scan = scanXMcpHeaderDeclarations(inputSchema); + if (scan.valid && scan.declarations.length > 0) { + const rejection = validateMcpParamHeaders(scan.declarations, callParams?.arguments, request.headers); + if (rejection !== undefined) { + void product.close().catch(reportError); + reportError(new Error(`Rejected inbound request (${rejection.cell}): ${rejection.message}`)); + return rejectionResponse(rejection, route.message.id); + } + } + } + } + // Era-write at instance binding, then modern-only handler installation — // both before the instance is connected to the per-request transport. setNegotiatedProtocolVersion(server, claimedRevision); diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 33f6408e9a..18aa698c4d 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -36,6 +36,7 @@ import { promptArgumentsFromStandardSchema, ProtocolError, ProtocolErrorCode, + scanXMcpHeaderDeclarations, standardSchemaToJsonSchema, UriTemplate, validateAndWarnToolName, @@ -72,6 +73,40 @@ export class McpServer { } = {}; private _registeredTools: { [name: string]: RegisteredTool } = {}; private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; + /** + * Per-tool JSON-converted `inputSchema`, memoized so the SEP-2243 + * registration-time scan and the pre-dispatch validation step share one + * conversion instead of paying it twice per request under the + * per-request-factory `createMcpHandler` model. + */ + private _toolInputSchemaJson: { [name: string]: Record } = {}; + + /** + * The JSON-serialized `inputSchema` of a registered tool, or `undefined` + * when no such tool is registered. Used by the HTTP entry's pre-dispatch + * SEP-2243 `Mcp-Param-*` validation step (which needs the same JSON Schema + * `tools/list` would emit, before dispatch reaches the handler). + * + * @internal + */ + toolInputSchemaJson(name: string): Record | undefined { + const tool = this._registeredTools[name]; + if (tool === undefined || !tool.enabled) return undefined; + if (Object.hasOwn(this._toolInputSchemaJson, name)) return this._toolInputSchemaJson[name]; + if (tool.inputSchema === undefined) return EMPTY_OBJECT_JSON_SCHEMA; + // Lazy path: the memo slot is unset because `registerTool`'s eager + // conversion threw (and was swallowed per its "warn, never throw" + // contract) or `update({paramsSchema})`/rename invalidated it. The + // pre-dispatch SEP-2243 caller must not turn that into a 500 for a + // `tools/call` whose body-authoritative dispatch would otherwise + // succeed — return `undefined` so validation is skipped and the + // conversion failure stays where it always surfaced (`tools/list`). + try { + return standardSchemaToJsonSchema(tool.inputSchema, 'input'); + } catch { + return undefined; + } + } constructor(serverInfo: Implementation, options?: ServerOptions) { this.server = new Server(serverInfo, options); @@ -745,6 +780,33 @@ export class McpServer { // Validate tool name according to SEP specification validateAndWarnToolName(name); + // SEP-2243 registration-time declaration-validity check (additive: warn, + // never throw — clients enforce by exclusion, servers by header + // validation; a malformed declaration here should not block local + // development against a stdio client that ignores it). The conversion + // is memoized so the pre-dispatch validation step in `createMcpHandler` + // (and `toolInputSchemaJson()`) does not repeat it for the same tool. + // `standardSchemaToJsonSchema` can throw for schemas it cannot convert + // (e.g. a vendor without `~standard.jsonSchema`); the try/catch keeps + // the "warn, never throw" contract. + if (inputSchema !== undefined) { + try { + const json = standardSchemaToJsonSchema(inputSchema, 'input'); + this._toolInputSchemaJson[name] = json; + const scan = scanXMcpHeaderDeclarations(json); + if (!scan.valid) { + console.warn( + `[mcp-sdk] tool '${name}' carries an invalid x-mcp-header declaration and will be excluded by ` + + `conforming Streamable HTTP clients: ${scan.reason}` + ); + } + } catch { + // Conversion failure: leave the cache slot unset so the lazy + // path in `toolInputSchemaJson()` (and `tools/list`) surfaces + // the failure where it always has. + } + } + // Track current handler for executor regeneration let currentHandler = handler; @@ -768,6 +830,7 @@ export class McpServer { validateAndWarnToolName(updates.name); } delete this._registeredTools[name]; + delete this._toolInputSchemaJson[name]; if (updates.name) this._registeredTools[updates.name] = registeredTool; } if (updates.title !== undefined) registeredTool.title = updates.title; @@ -777,6 +840,7 @@ export class McpServer { let needsExecutorRegen = false; if (updates.paramsSchema !== undefined) { registeredTool.inputSchema = updates.paramsSchema; + delete this._toolInputSchemaJson[name]; needsExecutorRegen = true; } if (updates.callback !== undefined) { diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index f5eccb2dba..ef5e7d8243 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -47,6 +47,7 @@ import { isModernProtocolVersion, LATEST_PROTOCOL_VERSION, legacyProtocolVersions, + LOG_LEVEL_META_KEY, LoggingLevelSchema, mergeCapabilities, missingClientCapabilities, @@ -304,7 +305,29 @@ export class Server extends Protocol { // Deprecated as of protocol version 2026-07-28 (SEP-2577): `log` and // `requestSampling` remain functional during the deprecation window // (at least twelve months). See ServerContext for migration guidance. - log: (level, data, logger) => this.sendLoggingMessage({ level, data, logger }), + log: (level, data, logger) => { + if (!this._capabilities.logging) { + return Promise.resolve(); + } + // Level filter: on a 2026-era request the client declares its + // threshold per request via the `_meta.logLevel` envelope key + // (the modern equivalent of `logging/setLevel`, which is not a + // request method on that revision); on 2025-era connections the + // session-scoped level set via `logging/setLevel` applies + // exactly as before. + const threshold = this._servedModernEra() + ? (ctx.mcpReq.envelope?.[LOG_LEVEL_META_KEY] as LoggingLevel | undefined) + : this._loggingLevels.get(undefined); + if (threshold !== undefined && this.LOG_LEVEL_SEVERITY.get(level)! < this.LOG_LEVEL_SEVERITY.get(threshold)!) { + return Promise.resolve(); + } + // Emit request-related (like progress and `ctx.mcpReq.notify`) + // so the notification rides the in-flight exchange. Without the + // related-request stamp, per-request hosting (`createMcpHandler`, + // either era) silently drops the message because it has no + // session-wide stream to deliver it on. + return ctx.mcpReq.notify({ method: 'notifications/message', params: { level, data, logger } }); + }, elicitInput: (params, options) => this.elicitInput(params, options), requestSampling: (params, options) => this.createMessage(params, options) }, diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index 0a069376ac..ee5e8dcf9e 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -41,12 +41,30 @@ function modernToolsCall(name: string, args: Record, envelope: }; } +/** + * The SEP-2243 standard headers a conformant client derives from the body it + * sends. Only emitted for a body carrying a modern envelope claim, so legacy + * test cells stay byte-untouched; spread before any explicit `headers` so a + * caller that needs to test a stripped or disagreeing header can override. + */ +function bodyDerivedStandardHeaders(body: unknown): Record { + if (body === null || typeof body !== 'object' || Array.isArray(body)) return {}; + const b = body as { method?: unknown; params?: { name?: unknown; uri?: unknown; _meta?: Record } }; + if (typeof b.params?._meta?.[PROTOCOL_VERSION_META_KEY] !== 'string') return {}; + const out: Record = {}; + if (typeof b.method === 'string') out['mcp-method'] = b.method; + const name = b.method === 'resources/read' ? b.params.uri : b.params.name; + if (typeof name === 'string') out['mcp-name'] = name; + return out; +} + function postRequest(body: unknown, headers: Record = {}): Request { return new Request('http://localhost/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', + ...bodyDerivedStandardHeaders(body), ...headers }, body: typeof body === 'string' ? body : JSON.stringify(body) @@ -782,6 +800,7 @@ describe('createMcpHandler — handler faces', () => { const parsed = modernToolsCall('echo', { text: 'pre-parsed' }); const { req, res, body } = nodeRequestResponse(undefined); + Object.assign(req.headers, bodyDerivedStandardHeaders(parsed)); await handler.node(req, res, parsed); expect(res.statusCode).toBe(200); expect(await body()).toContain('pre-parsed'); @@ -939,7 +958,8 @@ function nodeRequestResponse(body: unknown): { headers: { host: 'localhost:3000', 'content-type': 'application/json', - accept: 'application/json, text/event-stream' + accept: 'application/json, text/event-stream', + ...bodyDerivedStandardHeaders(body) } as Record }); diff --git a/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts b/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts index 3decb71b60..9d40e8e66f 100644 --- a/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts +++ b/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts @@ -33,7 +33,12 @@ const envelope = (clientCapabilities: ClientCapabilities) => ({ function postEcho(clientCapabilities: ClientCapabilities): Request { return new Request('http://localhost/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'tools/call', + 'mcp-name': 'echo' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 7, diff --git a/packages/server/test/server/createMcpHandlerListen.test.ts b/packages/server/test/server/createMcpHandlerListen.test.ts index 479e17b905..980e7e7578 100644 --- a/packages/server/test/server/createMcpHandlerListen.test.ts +++ b/packages/server/test/server/createMcpHandlerListen.test.ts @@ -27,7 +27,11 @@ const ENVELOPE = { function listenRequest(id: string | number, filter: Record): Request { return new Request('http://localhost/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id, @@ -165,7 +169,11 @@ describe('createMcpHandler — subscriptions/listen', () => { const response = await handler.fetch( new Request('http://localhost/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 9, method: 'subscriptions/listen', params: { _meta: ENVELOPE } }) }) ); diff --git a/packages/server/test/server/mcpParamValidation.test.ts b/packages/server/test/server/mcpParamValidation.test.ts new file mode 100644 index 0000000000..8c49d92b49 --- /dev/null +++ b/packages/server/test/server/mcpParamValidation.test.ts @@ -0,0 +1,137 @@ +/** + * SEP-2243 server-side `Mcp-Param-*` validation at the createMcpHandler entry + * (protocol revision 2026-07-28). + * + * Pre-dispatch ladder rung: a `tools/call` whose `Mcp-Param-{Name}` headers + * disagree with the body `arguments` (or are missing for a present body value, + * or carry an invalid Base64 sentinel) is rejected `400` / `-32001` with the + * same `HeaderMismatch` shape the inbound classifier emits for the + * standard-header cross-checks. A `null`/absent body value passes regardless + * of the header (the spec's "server MUST NOT expect" rows). The + * registration-time declaration-validity check warns on invalid declarations. + */ +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + encodeMcpParamValue, + PROTOCOL_VERSION_META_KEY +} from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { fromJsonSchema } from '../../src/fromJsonSchema.js'; +import { createMcpHandler } from '../../src/server/createMcpHandler.js'; +import { McpServer } from '../../src/server/mcp.js'; + +const MODERN = '2026-07-28'; +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'param-test', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const REGION_INPUT_SCHEMA = { + type: 'object', + properties: { region: { type: 'string', 'x-mcp-header': 'Region' }, query: { type: 'string' } } +} as const; + +function makeFactory(): () => McpServer { + return () => { + const s = new McpServer({ name: 'param-server', version: '1.0.0' }); + s.registerTool('route', { inputSchema: fromJsonSchema<{ region?: string; query?: string }>(REGION_INPUT_SCHEMA) }, async args => ({ + content: [{ type: 'text', text: `routed ${args.region ?? ''}` }] + })); + return s; + }; +} + +function call(args: Record, paramHeaders: Record = {}): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-protocol-version': MODERN, + 'mcp-method': 'tools/call', + 'mcp-name': 'route', + ...paramHeaders + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { name: 'route', arguments: args, _meta: ENVELOPE } + }) + }); +} + +describe('SEP-2243 Mcp-Param-* server validation (createMcpHandler, modern era)', () => { + it('a matching Mcp-Param header passes and the call dispatches', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch(call({ region: 'us-west1', query: 'x' }, { 'Mcp-Param-Region': 'us-west1' })); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('routed us-west1'); + }); + + it('a Base64-sentinel header decodes and matches', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch(call({ region: 'Hello, 世界' }, { 'Mcp-Param-Region': encodeMcpParamValue('Hello, 世界') })); + expect(response.status).toBe(200); + }); + + it('a disagreeing header is rejected 400/-32001 (HeaderMismatch) and reports the rejection', async () => { + const onerror = vi.fn(); + const handler = createMcpHandler(makeFactory(), { onerror }); + const response = await handler.fetch(call({ region: 'us-west1' }, { 'Mcp-Param-Region': 'eu' })); + expect(response.status).toBe(400); + const body = (await response.json()) as { id: unknown; error: { code: number; data?: { mismatch?: { header?: string } } } }; + expect(body.error.code).toBe(-32_001); + expect(body.error.data?.mismatch?.header).toBe('Mcp-Param-Region'); + expect(body.id).toBe(7); + expect(onerror).toHaveBeenCalled(); + }); + + // sep-2243-server-reject-missing-required (globally-untested manifest check). + it('a missing header for a present body value is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch(call({ region: 'us-west1' })); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: number } }; + expect(body.error.code).toBe(-32_001); + }); + + // sep-2243-server-not-expect-null (globally-untested manifest check). + it('a null/absent body value passes regardless of any stray header', async () => { + const handler = createMcpHandler(makeFactory()); + const r1 = await handler.fetch(call({ query: 'x' }, { 'Mcp-Param-Region': 'whatever' })); + const r2 = await handler.fetch(call({ region: null as unknown as string, query: 'x' })); + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + }); + + it('an invalid Base64 sentinel is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch(call({ region: 'Hello' }, { 'Mcp-Param-Region': '=?base64?SGVsbG8?=' })); + expect(response.status).toBe(400); + expect(((await response.json()) as { error: { code: number } }).error.code).toBe(-32_001); + }); +}); + +describe('SEP-2243 registerTool declaration-validity check', () => { + it('warns on an invalid x-mcp-header declaration at registration time', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const s = new McpServer({ name: 'warn-server', version: '1.0.0' }); + s.registerTool( + 'bad', + { + inputSchema: fromJsonSchema({ + type: 'object', + properties: { a: { type: 'object', 'x-mcp-header': 'Data' } as Record } + }) + }, + async () => ({ content: [] }) + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("tool 'bad' carries an invalid x-mcp-header")); + warn.mockRestore(); + }); +}); diff --git a/packages/server/test/server/stdHeaderValidation.test.ts b/packages/server/test/server/stdHeaderValidation.test.ts new file mode 100644 index 0000000000..58a8c89a40 --- /dev/null +++ b/packages/server/test/server/stdHeaderValidation.test.ts @@ -0,0 +1,169 @@ +/** + * SEP-2243 standard-header server-side validation at the createMcpHandler + * entry (protocol revision 2026-07-28). + * + * The presence and `Mcp-Name` cross-check half of the standard-header rung, + * evaluated by the entry on a modern-classified request immediately after the + * body-primary classifier returns a modern route. A missing `Mcp-Method` + * header, a missing `Mcp-Name` header on a `tools/call` / `prompts/get` / + * `resources/read` request, an `Mcp-Name` value disagreeing with + * `params.name` / `params.uri`, and an invalid `Mcp-Name` Base64 sentinel are + * all rejected `400` / `-32001` (`HeaderMismatch`) on the + * `standard-header-validation` rung — the same shape the classifier already + * emits for the `MCP-Protocol-Version` and `Mcp-Method` mismatch cells on the + * edge `era-classification` rung. Legacy-era traffic is byte-unchanged. + */ +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + encodeMcpParamValue, + PROTOCOL_VERSION_META_KEY +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { createMcpHandler } from '../../src/server/createMcpHandler.js'; +import { McpServer } from '../../src/server/mcp.js'; + +const MODERN = '2026-07-28'; +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'std-header-test', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +function makeFactory(): () => McpServer { + return () => { + const s = new McpServer({ name: 'std-header-server', version: '1.0.0' }); + s.registerTool('echo', { inputSchema: z.object({ text: z.string().optional() }) }, async ({ text }) => ({ + content: [{ type: 'text', text: text ?? 'ok' }] + })); + return s; + }; +} + +function modernRequest(method: string, params: Record, headers: Record = {}): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-protocol-version': MODERN, + ...headers + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 5, method, params: { ...params, _meta: ENVELOPE } }) + }); +} + +async function expectHeaderMismatch(response: Response): Promise<{ code: number; message: string }> { + expect(response.status).toBe(400); + const body = (await response.json()) as { id: unknown; error: { code: number; message: string } }; + expect(body.id).toBe(5); + expect(body.error.code).toBe(-32_001); + return body.error; +} + +describe('SEP-2243 standard-header validation (createMcpHandler, modern era)', () => { + it('a fully conformant tools/call passes and dispatches', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + modernRequest('tools/call', { name: 'echo', arguments: { text: 'hi' } }, { 'mcp-method': 'tools/call', 'mcp-name': 'echo' }) + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('hi'); + }); + + it('a missing Mcp-Method header is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + const error = await expectHeaderMismatch(await handler.fetch(modernRequest('tools/list', {}))); + expect(error.message).toContain('Mcp-Method header is absent'); + }); + + it('a missing Mcp-Name header on tools/call is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + const error = await expectHeaderMismatch( + await handler.fetch(modernRequest('tools/call', { name: 'echo', arguments: {} }, { 'mcp-method': 'tools/call' })) + ); + expect(error.message).toContain('Mcp-Name header is absent'); + }); + + it('an Mcp-Name header disagreeing with params.name is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + const error = await expectHeaderMismatch( + await handler.fetch( + modernRequest('tools/call', { name: 'echo', arguments: {} }, { 'mcp-method': 'tools/call', 'mcp-name': 'wrong' }) + ) + ); + expect(error.message).toContain('Mcp-Name header names "wrong"'); + }); + + it('Mcp-Name accepts an OWS-padded value (RFC 9110 §5.5; Fetch Headers normalises)', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + modernRequest('tools/call', { name: 'echo', arguments: {} }, { 'mcp-method': 'tools/call', 'mcp-name': ' echo ' }) + ); + expect(response.status).toBe(200); + }); + + it('Mcp-Name decodes a Base64 sentinel before comparison', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + modernRequest( + 'tools/call', + { name: 'echo', arguments: {} }, + { 'mcp-method': 'tools/call', 'mcp-name': encodeMcpParamValue('echo') } + ) + ); + // `encodeMcpParamValue('echo')` is plain ASCII, so the sentinel is not + // applied; assert the explicit-sentinel case below instead. + expect(response.status).toBe(200); + const sentinel = await handler.fetch( + modernRequest( + 'tools/call', + { name: 'echo', arguments: {} }, + { 'mcp-method': 'tools/call', 'mcp-name': `=?base64?${Buffer.from('echo').toString('base64')}?=` } + ) + ); + expect(sentinel.status).toBe(200); + }); + + it('an invalid Mcp-Name Base64 sentinel is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + await expectHeaderMismatch( + await handler.fetch( + modernRequest( + 'tools/call', + { name: 'echo', arguments: {} }, + { 'mcp-method': 'tools/call', 'mcp-name': '=?base64?SGVsbG8?=' } + ) + ) + ); + }); + + it('Mcp-Name is not required for methods outside its source map', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch(modernRequest('tools/list', {}, { 'mcp-method': 'tools/list' })); + expect(response.status).toBe(200); + }); +}); + +describe('SEP-2243 standard-header validation is era-gated', () => { + it('legacy traffic is byte-untouched: a 2025-era initialize without standard headers still serves', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 5, + method: 'initialize', + params: { protocolVersion: '2025-11-25', clientInfo: { name: 'c', version: '1' }, capabilities: {} } + }) + }) + ); + // The default 'stateless' legacy posture answers initialize. + expect(response.status).toBe(200); + }); +}); diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index 5c30ea0ade..3a5194f209 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -48,9 +48,6 @@ client: - auth/scope-retry-limit # --- Same gaps as the 2025 baseline (fail identically when forced to 2026-07-28) --- - # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. - - http-custom-headers - - http-invalid-tool-headers # SEP-2106 (JSON Schema $ref handling): no fixture handler for the scenario yet. - json-schema-ref-no-deref # SEP-2468 (authorization response iss parameter): not implemented in the client. @@ -76,10 +73,3 @@ server: # (WARNING-only; the expected-failures evaluator counts WARNINGs as # failures). Same failure as in the 2025 baseline. - sep-2164-resource-not-found - - # --- Draft scenarios (same failures and reasons as the `--suite draft` leg) --- - # SEP-2243 (HTTP header standardization): the reject cells the SDK does - # answer now use -32001 (HeaderMismatch), but missing-header enforcement - # (Mcp-Method, Mcp-Name) and the Mcp-Name cross-check are not implemented, - # so those reject cells are still accepted with 200. - - http-header-validation diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index c5ab22325a..9fed2dee25 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -18,9 +18,6 @@ client: # --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) --- - # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. - - http-custom-headers - - http-invalid-tool-headers # SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs. - json-schema-ref-no-deref # SEP-2468 (authorization response iss parameter): not implemented in the client. @@ -49,11 +46,6 @@ client: server: # --- Draft-spec scenarios (in `--suite draft`; the default `active` suite is green) --- - # SEP-2243 (HTTP header standardization): the reject cells the SDK does - # answer now use -32001 (HeaderMismatch), but missing-header enforcement - # (Mcp-Method, Mcp-Name) and the Mcp-Name cross-check are not implemented, - # so those reject cells are still accepted with 200. - - http-header-validation # WARNING-only entry: the scenario emits no FAILURE checks, only a SHOULD-level # WARNING, but the expected-failures evaluator counts WARNINGs as failures. # SEP-2164: server returns -32002 without the requested URI in error.data. diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index 3b61675c96..1cc2255d61 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -217,6 +217,69 @@ registerScenario('initialize', runBasicClient); registerScenario('tools_call', runToolsCallClient); registerScenario('request-metadata', runRequestMetadataClient); +// ============================================================================ +// SEP-2243 custom-header client scenarios (protocol revision 2026-07-28) +// ============================================================================ + +// The SEP-2243 conformance mocks (http-custom-headers / http-invalid-tool-headers) +// only implement tools/list + tools/call (and a 2025-shaped initialize pinned +// to 2026-07-28, no server/discover) — same connect-time gap as the +// multi-round-trip mock, so use the same withLocalDiscoverResponse fetch shim +// (defined below) to establish the modern era. The runner passes the exact +// tool calls to make via MCP_CONFORMANCE_CONTEXT. + +function readToolCallsContext(): Array<{ name: string; arguments: Record }> { + const raw = process.env.MCP_CONFORMANCE_CONTEXT; + if (!raw) return []; + const parsed = JSON.parse(raw) as { toolCalls?: Array<{ name: string; arguments: Record }> }; + return parsed.toolCalls ?? []; +} + +async function connectModernHeaderClient(serverUrl: string): Promise { + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {}, versionNegotiation: { mode: 'auto' } }); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: withLocalDiscoverResponse({ name: 'test-client', version: '1.0.0' }) + }); + await client.connect(transport); + return client; +} + +// http-custom-headers: the conformance mock advertises test_custom_headers and +// test_custom_headers_null with x-mcp-header annotations. List first (so the +// SDK caches the inputSchema and can mirror), then make the runner-supplied +// calls; the conformance mock validates the Mcp-Param-* headers it receives. +async function runHttpCustomHeadersClient(serverUrl: string): Promise { + const client = await connectModernHeaderClient(serverUrl); + const { tools } = await client.listTools(); + logger.debug('listed tools:', tools.map(t => t.name).join(', ')); + + for (const call of readToolCallsContext()) { + await client.callTool({ name: call.name, arguments: call.arguments }); + } + await client.close(); +} + +// http-invalid-tool-headers: the conformance mock advertises one valid tool +// alongside several constraint-violating ones. listTools() must exclude the +// invalid ones; the fixture then calls every tool that survived — a correct +// SDK leaves only valid_tool, so the mock records SUCCESS for the keep-valid +// check and SUCCESS for every excluded tool not having been called. +async function runHttpInvalidToolHeadersClient(serverUrl: string): Promise { + const client = await connectModernHeaderClient(serverUrl); + const { tools } = await client.listTools(); + logger.debug('post-exclusion tools:', tools.map(t => t.name).join(', ')); + + for (const tool of tools) { + await client.callTool({ name: tool.name, arguments: { region: 'us-west1' } }).catch(error => { + logger.debug(`call ${tool.name} rejected:`, String(error)); + }); + } + await client.close(); +} + +registerScenario('http-custom-headers', runHttpCustomHeadersClient); +registerScenario('http-invalid-tool-headers', runHttpInvalidToolHeadersClient); + // ============================================================================ // Multi-round-trip client scenario (SEP-2322, protocol revision 2026-07-28) // ============================================================================ diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 5429e3be57..85a0934443 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -27,6 +27,7 @@ import { classifyInboundRequest, CLIENT_CAPABILITIES_META_KEY, createMcpHandler, + fromJsonSchema, inputRequired, isInitializeRequest, McpServer, @@ -180,6 +181,27 @@ function createMcpServer() { // ===== TOOLS ===== + // SEP-2243 x-mcp-header tool — arms the http-custom-header-server-validation + // conformance scenario (which skips when no tool with an x-mcp-header + // annotation is found). The schema is hand-written JSON so the annotation + // survives serialization unchanged. + mcpServer.registerTool( + 'test_x_mcp_header', + { + description: 'Tests SEP-2243 Mcp-Param-* server-side validation', + inputSchema: fromJsonSchema<{ region?: string; level?: number }>({ + type: 'object', + properties: { + region: { type: 'string', description: 'mirrored into Mcp-Param-Region', 'x-mcp-header': 'Region' }, + level: { type: 'integer', description: 'non-mirrored argument' } + } + }) + }, + async (args): Promise => ({ + content: [{ type: 'text', text: `region=${args.region ?? ''}` }] + }) + ); + // Simple text tool mcpServer.registerTool( 'test_simple_text', diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index 85b6ca5f6d..46e23f86ae 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -53,6 +53,8 @@ export type EntryServerFactory = (ctx?: McpRequestContext) => McpServer | Server export interface RecordedHttpExchange { /** HTTP request method (GET/POST/DELETE). */ method: string; + /** The HTTP request headers as resolved by `new Request(...)` — for raw header assertions (e.g. `Mcp-Param-*`). */ + requestHeaders: Headers; /** The request body text, when one was sent as a string. */ requestBody?: string; /** HTTP response status. */ @@ -155,6 +157,7 @@ export async function wire( const response = await handler.fetch(request); httpLog.push({ method: request.method.toUpperCase(), + requestHeaders: request.headers, ...(typeof init?.body === 'string' && { requestBody: init.body }), status: response.status, contentType: response.headers.get('content-type') ?? '', diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 5058dcdd5d..bc129e227a 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -128,7 +128,10 @@ export const REQUIREMENTS: Record = { 'protocol:cancel:abort-signal': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#cancellation-flow', behavior: - 'Cancelling an in-flight request through the client API sends notifications/cancelled with the request id and fails the local call.' + 'Cancelling an in-flight request through the client API sends notifications/cancelled with the request id and fails the local call.', + removedInSpecVersion: '2026-07-28', + supersededBy: 'protocol:cancel:http-stream-close', + note: '2026-07-28 makes Streamable-HTTP cancellation a per-request stream-close (no notifications/cancelled on the wire); the supersedes link names that surface. stdio at the modern era still POSTs cancelled but no modern stdio cell exists in the matrix yet.' }, 'protocol:cancel:handler-abort-propagates': { transports: STATEFUL_TRANSPORTS, @@ -250,7 +253,19 @@ export const REQUIREMENTS: Record = { }, 'protocol:timeout:sends-cancellation': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts', - behavior: 'When a request times out, the sender issues notifications/cancelled for that request before failing the local call.' + behavior: 'When a request times out, the sender issues notifications/cancelled for that request before failing the local call.', + removedInSpecVersion: '2026-07-28', + supersededBy: 'protocol:cancel:http-stream-close', + note: '2026-07-28 makes Streamable-HTTP timeout cancellation a per-request stream-close (no notifications/cancelled on the wire); the supersedes link names that surface.' + }, + 'protocol:cancel:http-stream-close': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/cancellation#transport-specific-cancellation', + behavior: + 'On a 2026-07-28 Streamable HTTP connection, cancelling an in-flight client request (caller signal or timeout) closes that request’s SSE response stream as the spec cancellation signal; no notifications/cancelled message is sent on the wire and the local call fails.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + supersedes: ['protocol:cancel:abort-signal', 'protocol:timeout:sends-cancellation'], + note: 'Streamable-HTTP only; stdio at the modern era still POSTs notifications/cancelled (no modern stdio cell exists in the matrix yet).' }, 'mcpserver:onerror:reach-through': { entryExclusions: [ @@ -2393,8 +2408,8 @@ export const REQUIREMENTS: Record = { source: 'sdk', behavior: 'ctx.mcpReq.log() inside a registered tool handler emits a notifications/message logging notification that the client receives while the call is in flight.', - transports: STATEFUL_TRANSPORTS, - note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these.' + transports: [...STATEFUL_TRANSPORTS, 'entryStateless', 'entryModern'], + note: 'Emitted request-related, so on per-request hosting (createMcpHandler, either era) the notification rides the in-flight exchange like progress; the streamableHttpStateless arm has no per-request stream visible to the body and stays restricted.' }, 'mcpserver:context:elicit-from-handler': { source: 'sdk', @@ -2619,6 +2634,23 @@ export const REQUIREMENTS: Record = { transports: ['streamableHttp'], note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, + // SEP-2243 request-metadata headers (protocol revision 2026-07-28) + 'sep-2243:param-header:roundtrip': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#custom-headers-from-tool-parameters', + behavior: + 'A tools/call to a tool whose inputSchema declares an x-mcp-header property carries the corresponding Mcp-Param-{Name} HTTP header on the wire, encoded per the SEP-2243 value-encoding rules, and the call completes successfully against a validating server.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm; the Mcp-Param-{Name} header is asserted on the arm-recorded HTTP request headers and the encoded value is checked against the SEP-2243 codec.' + }, + 'sep-2243:std-header:mismatch-rejected': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#standard-request-headers', + behavior: + 'A 2026-07-28 request whose Mcp-Method header disagrees with the JSON-RPC method in the body is rejected by the createMcpHandler entry with HTTP 400 carrying a JSON-RPC error with the SEP-2243 HeaderMismatch code.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm; the body POSTs a raw envelope-carrying tools/call with an Mcp-Method: tools/list header through wired.fetch and asserts the 400 status and the HeaderMismatch error code on the response bytes.' + }, // Multi round-trip requests (SEP-2322, protocol revision 2026-07-28) 'typescript:mrtr:tools-call:write-once-roundtrip': { source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr', diff --git a/test/e2e/scenarios/hosting-entry-session.test.ts b/test/e2e/scenarios/hosting-entry-session.test.ts index a41a1b8bbb..2a09b95d42 100644 --- a/test/e2e/scenarios/hosting-entry-session.test.ts +++ b/test/e2e/scenarios/hosting-entry-session.test.ts @@ -169,7 +169,12 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async () => { const exchangesBeforeModernProbe = legacyExchanges.length; const modernProbe = await fetchViaRouter(url, { method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-method': 'tools/call', + 'mcp-name': 'greet' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 100, diff --git a/test/e2e/scenarios/protocol.test.ts b/test/e2e/scenarios/protocol.test.ts index 75df591691..9a9b08b9f6 100644 --- a/test/e2e/scenarios/protocol.test.ts +++ b/test/e2e/scenarios/protocol.test.ts @@ -18,7 +18,8 @@ import type { Progress, RequestId, Result, - Transport + Transport, + TransportSendOptions } from '@modelcontextprotocol/server'; import { InMemoryTransport, @@ -127,6 +128,62 @@ verifies('protocol:cancel:abort-signal', async ({ transport }: TestArgs) => { expect(cancelled.params?.reason).toContain('user requested cancellation'); }); +verifies('protocol:cancel:http-stream-close', async ({ transport }: TestArgs) => { + // 2026-07-28 Streamable HTTP: closing the per-request SSE stream IS the + // cancel signal — no notifications/cancelled is sent on the wire. The body + // proves both the caller-signal and timeout paths route to stream-close. + const client = newClient(); + await using _ = await wire(transport, neverRespondingServer, client); + + // Tap send to record outbound messages AND the per-request requestSignal + // the protocol layer hands the transport. + const sent: Array<{ m: JSONRPCMessage; opts: TransportSendOptions | undefined }> = []; + const tx = client.transport; + if (!tx) throw new Error('client not connected'); + expect(tx.hasPerRequestStream).toBe(true); + const orig = tx.send.bind(tx); + tx.send = async (m, opts) => { + sent.push({ m, opts }); + return orig(m, opts); + }; + + // Caller-signal abort. + const ac = new AbortController(); + const call = client.listTools(undefined, { signal: ac.signal }); + await vi.waitFor(() => expect(sent.some(s => isRequest(s.m) && s.m.method === 'tools/list')).toBe(true)); + const listSend = sent.find(s => isRequest(s.m) && s.m.method === 'tools/list'); + if (!listSend) throw new Error('tools/list send not captured'); + expect(listSend.opts?.requestSignal, 'protocol layer must thread a per-request requestSignal on a 2026 HTTP connection').toBeInstanceOf( + AbortSignal + ); + expect(listSend.opts?.requestSignal?.aborted).toBe(false); + + ac.abort('user requested cancellation'); + await expect(call).rejects.toThrow(/user requested cancellation/); + + expect(listSend.opts?.requestSignal?.aborted, 'stream-close IS the cancel signal: requestSignal must be aborted').toBe(true); + expect( + sent.filter(s => isNotification(s.m) && s.m.method === 'notifications/cancelled'), + 'no notifications/cancelled on the wire — "not required or expected" per spec' + ).toHaveLength(0); + + // Timeout path. + sent.length = 0; + vi.useFakeTimers(); + try { + const pending = client.listTools(undefined, { timeout: 100 }); + pending.catch(() => {}); + await vi.advanceTimersByTimeAsync(100); + await expect(pending).rejects.toMatchObject({ code: SdkErrorCode.RequestTimeout }); + } finally { + vi.useRealTimers(); + } + const timedOutSend = sent.find(s => isRequest(s.m) && s.m.method === 'tools/list'); + if (!timedOutSend) throw new Error('timeout-path tools/list send not captured'); + expect(timedOutSend.opts?.requestSignal?.aborted).toBe(true); + expect(sent.filter(s => isNotification(s.m) && s.m.method === 'notifications/cancelled')).toHaveLength(0); +}); + verifies('protocol:cancel:handler-abort-propagates', async ({ transport }: TestArgs) => { const aborts: Array<{ requestId: RequestId; reason: unknown }> = []; const makeServer = () => { diff --git a/test/e2e/scenarios/sep2243.test.ts b/test/e2e/scenarios/sep2243.test.ts new file mode 100644 index 0000000000..83353d9cef --- /dev/null +++ b/test/e2e/scenarios/sep2243.test.ts @@ -0,0 +1,91 @@ +/** + * SEP-2243 request-metadata headers (protocol revision 2026-07-28). + * + * End-to-end cells for the SEP-2243 header families over the dual-era HTTP + * entry (`createMcpHandler`), exercised on the wire() `entryModern` arm so the + * raw HTTP request headers are observable on the arm-recorded `wired.httpLog`. + */ +import { Client } from '@modelcontextprotocol/client'; +import { encodeMcpParamValue, MCP_PARAM_HEADER_PREFIX } from '@modelcontextprotocol/core'; +import { fromJsonSchema, McpServer } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; + +import { modernEnvelopeMeta, wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +/** + * One tool with a single `x-mcp-header`-declared string parameter. Declared as + * a non-literal const so the JSON-Schema vendor extension key passes excess + * property checking on `fromJsonSchema`'s `JSONSchema.Interface` parameter. + */ +const LOCATE_INPUT_SCHEMA = { + type: 'object', + properties: { region: { type: 'string', 'x-mcp-header': 'Region' } }, + required: ['region'] +}; + +verifies('sep-2243:param-header:roundtrip', async ({ transport }: TestArgs) => { + // The server is built by createMcpHandler per request, so its pre-dispatch + // Mcp-Param-* validation runs against this schema. + const makeServer = () => { + const server = new McpServer({ name: 'e2e-sep2243', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('locate', { inputSchema: fromJsonSchema<{ region: string }>(LOCATE_INPUT_SCHEMA) }, ({ region }) => ({ + content: [{ type: 'text', text: `region=${region}` }] + })); + return server; + }; + const client = new Client({ name: 'sep2243-client', version: '1.0.0' }); + await using wired = await wire(transport, makeServer, client); + + // listTools() populates the client's tool-definition cache with the + // x-mcp-header scan, so the following callTool emits the header on its + // first attempt (the spec's 5-step client algorithm). + await client.listTools(); + const result = await client.callTool({ name: 'locate', arguments: { region: 'us-west1' } }); + + // The tools/call HTTP request carries the Mcp-Param-Region header, + // encoded per the SEP-2243 value-encoding rules (a safe ASCII token + // passes through unchanged). + const callExchange = (wired.httpLog ?? []).find(exchange => exchange.requestBody?.includes('"tools/call"')); + expect(callExchange).toBeDefined(); + const headerValue = callExchange!.requestHeaders.get(`${MCP_PARAM_HEADER_PREFIX}Region`); + expect(headerValue).toBe(encodeMcpParamValue('us-west1')); + expect(headerValue).toBe('us-west1'); + + // The call succeeded against the validating server (header agreed with + // the body argument, so no -32001 HeaderMismatch on the wire). + expect(result.isError).toBeFalsy(); + expect(result.content).toEqual([{ type: 'text', text: 'region=us-west1' }]); +}); + +verifies('sep-2243:std-header:mismatch-rejected', async ({ transport }: TestArgs) => { + const makeServer = () => new McpServer({ name: 'e2e-sep2243-std', version: '1.0.0' }, { capabilities: { tools: {} } }); + const client = new Client({ name: 'sep2243-std-client', version: '1.0.0' }); + await using wired = await wire(transport, makeServer, client); + + // Raw POST through the harness-hosted entry: the body is a valid + // envelope-carrying tools/call, but the Mcp-Method header names + // tools/list. The era-classification rung answers the disagreement + // before any factory instance is constructed. + const response = await wired.fetch!(wired.url!, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-method': 'tools/list' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'locate', arguments: {}, _meta: modernEnvelopeMeta({ name: 'sep2243-std-client', version: '1.0.0' }) } + }) + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: number; message: string } }; + // -32001 is the SEP-2243 HeaderMismatch code at this branch's spec pin. + expect(body.error.code).toBe(-32_001); + expect(body.error.message).toMatch(/Mcp-Method/); +}); diff --git a/test/e2e/scenarios/subscriptions.test.ts b/test/e2e/scenarios/subscriptions.test.ts index bbca6105d5..cd9fea8981 100644 --- a/test/e2e/scenarios/subscriptions.test.ts +++ b/test/e2e/scenarios/subscriptions.test.ts @@ -42,7 +42,11 @@ verifies('subscriptions:listen:ack-first-stamped', async () => { const response = await handler.fetch( new Request('http://in-process/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 'sub-1', @@ -140,7 +144,11 @@ verifies('subscriptions:listen:capacity-guard', async () => { handler.fetch( new Request('http://in-process/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id, diff --git a/test/integration/test/server/createMcpHandler.test.ts b/test/integration/test/server/createMcpHandler.test.ts index ae2a30aac8..6ce066ed1e 100644 --- a/test/integration/test/server/createMcpHandler.test.ts +++ b/test/integration/test/server/createMcpHandler.test.ts @@ -169,7 +169,11 @@ describe('createMcpHandler over HTTP — subscriptions/listen honored filter', ( const response = await fetch(new URL('/mcp', baseUrl), { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 'sub-1',