diff --git a/.changeset/sep-2243-mcp-param-client.md b/.changeset/sep-2243-mcp-param-client.md new file mode 100644 index 0000000000..bdefc019a4 --- /dev/null +++ b/.changeset/sep-2243-mcp-param-client.md @@ -0,0 +1,10 @@ +--- +'@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 on a non-stdio modern connection `Client.listTools()` (and the client's internal `tools/list` cache) exclude 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 at the wire level. 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 and output-schema validation 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`); transports that share a single channel (stdio, in-memory) ignore it. + +The Streamable HTTP transport now emits the `Mcp-Name` standard header on every modern-enveloped request (`params.name` for `tools/call`/`prompts/get`, `params.uri` for `resources/read`), sentinel-encoded. + +**Behavior change (modern era only):** on a modern-enveloped request the Streamable HTTP transport now surfaces an HTTP `400` whose body is a well-formed JSON-RPC error response addressed to the pending request id in-band as a `ProtocolError` (instead of `SdkHttpError`), so the `HEADER_MISMATCH` recovery retry can fire. Legacy-era exchanges are unchanged. 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/docs/client.md b/docs/client.md index 79ddd12157..36518fae75 100644 --- a/docs/client.md +++ b/docs/client.md @@ -301,6 +301,16 @@ 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 client's internal `tools/list` cache; 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 non-stdio modern connection `listTools()` (and the internal `tools/list` cache) exclude 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 8b14aa8cc1..ee3200c524 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -116,7 +116,7 @@ Three error classes now exist: | Capability not supported | `new Error(...)` | `SdkError` with `SdkErrorCode.CapabilityNotSupported` | | Not connected | `new Error('Not connected')` | `SdkError` with `SdkErrorCode.NotConnected` | | Invalid params (server response) | `McpError` with `ErrorCode.InvalidParams` | `ProtocolError` with `ProtocolErrorCode.InvalidParams` | -| HTTP transport error | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttp*` | +| HTTP transport error (legacy era) | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttp*` | | Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToOpenStream` | | 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpAuthentication` | | 403 after upscoping | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpForbidden` | @@ -124,6 +124,9 @@ Three error classes now exist: | Session termination failed | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToTerminateSession` | | Response result fails schema | `ZodError` (raw) | `SdkError` with `SdkErrorCode.InvalidResult` | +**Modern-era exception** to the `SdkHttpError` rows above: on a modern-enveloped (2026-07-28) Streamable HTTP request, an HTTP `400` whose body is a well-formed JSON-RPC error response addressed to the pending request id is delivered in-band as a `ProtocolError` (e.g. `-32001` +HeaderMismatch from a SEP-2243 `Mcp-Param-*` rejection), not as `SdkHttpError`. Legacy-era exchanges and generic HTTP failures are unchanged. + New `SdkErrorCode` enum values: - `SdkErrorCode.NotConnected` = `'NOT_CONNECTED'` @@ -174,6 +177,13 @@ if (error instanceof SdkHttpError) { break; } } +// Modern-era (2026-07-28) only: a 400 carrying a JSON-RPC error body addressed +// to the pending request id surfaces as ProtocolError, NOT SdkHttpError — e.g. +// a SEP-2243 -32001 HeaderMismatch from createMcpHandler. Legacy-era 400s and +// generic HTTP failures still map to SdkHttpError above. +if (error instanceof ProtocolError) { + console.log('In-band JSON-RPC error:', error.code); +} ``` ### OAuth error consolidation diff --git a/docs/migration.md b/docs/migration.md index 7e74ad8843..5c5c63a8bb 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1125,6 +1125,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 at the wire level. + +The Streamable HTTP transport now also emits the `Mcp-Name` standard header on every modern-enveloped request (`tools/call`/`prompts/get` → `params.name`; `resources/read` → `params.uri`), sentinel-encoded the same way, so intermediaries can route on the resource name without +parsing the body. **On a modern-enveloped request only**, an HTTP `400` whose body is a well-formed JSON-RPC error response addressed to the pending request id is now delivered in-band as a `ProtocolError` (so the `HEADER_MISMATCH` recovery retry can fire); a legacy-era +exchange still surfaces `400` as the existing `SdkHttpError`, so `e instanceof SdkHttpError && e.status === 400` callers are unchanged. + +Two additive options support this: `CallToolRequestOptions.toolDefinition` (pass the tool definition directly so mirroring and output-schema validation run 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 non-stdio modern connection, +`Client.listTools()` (and the client's internal `tools/list` cache) exclude 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. + ### 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 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/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index a43aee20a3..07071e8e34 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, @@ -288,6 +292,24 @@ export type ClientOptions = ProtocolOptions & { responseCacheStore?: ResponseCacheStore; }; +/** + * 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, AND for output-schema + * validation of the result. When set, the client uses this definition's + * `inputSchema` and `outputSchema` instead of (and without consulting) the + * cached `tools/list` result, so the two derived views agree. + */ + toolDefinition?: Tool; +}; + /** * `list_changed` notification → response-cache method(s) to evict. `resources` * covers both list verbs (the spec's "relevant notification ⇒ immediately @@ -1471,12 +1493,11 @@ export class Client extends Protocol { /** * Compile a single tool's `outputSchema` (or `undefined` when absent / - * uncompilable). Passed as the compile callback to - * {@linkcode ClientResponseCache.outputValidator} so the cache class stays - * free of any validator-provider dependency. One tool's uncompilable - * `outputSchema` (e.g. an invalid `pattern` regex or unresolvable `$ref`) - * must not poison every other tool's `callTool` — warn naming the - * offender and return `undefined` so the validator index simply omits it. + * uncompilable) — the caller-supplied-definition path of + * {@linkcode callTool} so an explicit `options.toolDefinition` is the + * source for BOTH mirroring AND output validation. Also passed as the + * compile callback to {@linkcode ClientResponseCache.outputValidator} so + * the cache class stays free of any validator-provider dependency. */ private _compileOutputValidator(tool: Tool): JsonSchemaValidator | undefined { if (!tool.outputSchema) return undefined; @@ -1490,6 +1511,24 @@ export class Client extends Protocol { } } + /** + * Resolve the SEP-2243 `x-mcp-header` declaration scan for a tool name. + * + * The caller-supplied `toolDefinition` escape hatch wins; otherwise the + * cached `tools/list` entry (via the cache's `toolDefinition`) is the + * source. Freshness is the response cache's lifecycle: `list_changed` + * evicts, otherwise the held schema is the best information available + * regardless of age, and a stale schema is recovered through the + * `HEADER_MISMATCH` → evict-refetch-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 recovery. + */ + private async _resolveXMcpHeaderScan(name: string, override: Tool | undefined): Promise { + const tool = override ?? (await this._cache.toolDefinition(name)); + return tool === undefined ? undefined : scanXMcpHeaderDeclarations(tool.inputSchema); + } + /** Reads the contents of a resource by URI. */ async readResource(params: ReadResourceRequest['params'], options?: RequestOptions): Promise { return this.request({ method: 'resources/read', params }, options); @@ -1861,29 +1900,106 @@ 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'; + // Mirroring (and output-schema validation below) read the cached + // `tools/list` entry directly via `_cache.toolDefinition` / + // `_cache.outputValidator`. `callTool` never issues a `tools/list` + // itself — the cache is populated by the caller's own + // {@linkcode listTools | listTools()} (which now auto-aggregates) and + // by the `HEADER_MISMATCH` recovery path below. A cold cache means + // the call proceeds without `Mcp-Param-*` headers (the spec's + // "client SHOULD send without custom headers" guidance) and without + // output-schema validation (the v1.x opportunistic behaviour, kept so + // a legacy/stdio `callTool` still issues zero extra requests). + const buildSendOptions = async (): Promise => { + if (!mirroringActive) return options; + // A custom store's `get()` may reject (the documented store + // contract); route that to `onerror` and degrade to sending + // without `Mcp-Param-*` headers — same posture as a cold cache — + // rather than aborting the call before it reaches the wire. + let scan: XMcpHeaderScanResult | undefined; + try { + scan = await this._resolveXMcpHeaderScan(params.name, options?.toolDefinition); + } catch (error) { + this._reportStoreError(error); + } + 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); - - // Check if the tool has an outputSchema. Reads the cached - // `tools/list` entry (via the response cache's stamp-memoized - // `outputValidator` index) — `callTool` never issues a `tools/list` - // itself; the cache is populated by the caller's own - // {@linkcode listTools | listTools()}. A cold cache means validation - // is skipped (the v1.x opportunistic behaviour, kept so a legacy/stdio - // `callTool` still issues zero extra requests). The cache read is - // guarded the same way as `evict()`/`set()`: a custom store whose - // `get()` rejects AFTER the server has already executed the call must - // not surface as a `callTool()` rejection (a caller that retries on + let result: CallToolResult; + try { + result = await this.request({ method: 'tools/call', params }, await buildSendOptions()); + } catch (error) { + // SEP-2243 one-refresh-on-miss: a `HEADER_MISMATCH` rejection on a + // modern connection means the server enforced an `Mcp-Param-*` + // header we did not (or could not) send — the cached `tools/list` + // entry is stale (or cold). Evict it, repopulate via + // {@linkcode listTools | listTools()} (which auto-aggregates and + // writes the cache), and retry once with the now-known schema. + // 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; + } + const refreshOptions = { signal: options?.signal, timeout: options?.timeout }; + // A custom store's `evict()` may throw or reject (the documented + // store contract); route that to `onerror` and proceed — the + // generation bump already happened, so the refetch overwrites the + // stale entry regardless. + await this._cache.evict('tools/list').catch(error_ => this._reportStoreError(error_)); + // The recovery refetch may itself fail (e.g. `listMaxPages`, a + // transient error that hits only the `tools/list` walk). Surface + // it via `onerror` so the real cause is observable, then proceed + // to the retry. NOTE: when the refetch fails the cache stays + // empty and the retry goes out without `Mcp-Param-*` headers, so + // a conforming server will likely answer a second + // `HEADER_MISMATCH` — the refetch failure is observable only + // through `onerror`. + await this.listTools(undefined, refreshOptions).catch(error_ => this._reportStoreError(error_)); + result = await this.request({ method: 'tools/call', params }, await buildSendOptions()); + } + + // Check if the tool has an outputSchema. When the caller supplied + // `toolDefinition`, that definition is the source for BOTH the + // `Mcp-Param-*` mirroring above AND the output validation here — the + // two derived views must agree. The cache read is guarded the same + // way as `evict()`/`set()` above: a custom store whose `get()` + // rejects AFTER the server has already executed the call must not + // surface as a `callTool()` rejection (a caller that retries on // failure would re-execute a possibly side-effecting tool). Route to // `onerror` and degrade to skipping validation — the same outcome as // a cold cache. - const validator = await this._cache - .outputValidator(params.name, tool => this._compileOutputValidator(tool)) - .catch(error => void this._reportStoreError(error)); + const validator = + options?.toolDefinition === undefined + ? await this._cache + .outputValidator(params.name, tool => this._compileOutputValidator(tool)) + .catch(error => void this._reportStoreError(error)) + : this._compileOutputValidator(options.toolDefinition); if (validator) { // If tool has outputSchema, it MUST return structuredContent (unless it's an error) if (!result.structuredContent && !result.isError) { @@ -1954,12 +2070,53 @@ export class Client extends Protocol { return { tools: [] }; } if (params?.cursor !== undefined) { - // Explicit-cursor per-page contract: return one page; do NOT touch - // the response cache (a single page is not the complete aggregate - // the derived `outputValidator` index keys against). - return await this.request({ method: 'tools/list', params }, options); + // Per-page: single request, never written to the response cache. + // SEP-2243: the spec's MUST has no carve-out for paginated reads, + // so the per-page result is filtered (on a non-stdio modern + // connection) before returning — the suppressed tool is never + // observable. + const page = await this.request({ method: 'tools/list', params }, options); + this._excludeInvalidXMcpHeaderTools(page); + return page; } - return this._listAllPages('tools/list', params, options, (acc, page) => acc.tools.push(...page.tools)); + // Auto-aggregate: SEP-2243 invalid-`x-mcp-header` exclusion runs on + // the complete aggregate via the `finalize` hook before the cache + // write, so the cached entry never holds an unmirrorable tool. + return this._listAllPages( + 'tools/list', + params, + options, + (acc, page) => acc.tools.push(...page.tools), + acc => this._excludeInvalidXMcpHeaderTools(acc) + ); + } + + /** + * 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 to the CACHED aggregated `tools/list` result (so the entry + * mirroring reads never holds an unmirrorable tool) AND to every public + * per-page {@linkcode listTools | listTools()} return (the spec's MUST + * has no carve-out for paginated reads). The gate is era-only on + * non-stdio transports — `detectProbeTransportKind` cannot distinguish a + * real HTTP transport from in-memory/custom transports (it only + * positively recognizes stdio), and over-excluding on a non-HTTP modern + * connection is harmless: those transports never carry per-request + * headers, so an excluded tool would have been uncallable on a Streamable + * HTTP arm of the same server. Mutates `result.tools` in place. + */ + private _excludeInvalidXMcpHeaderTools(result: ListToolsResult): void { + if (this.getProtocolEra() !== 'modern' || !this.transport || detectProbeTransportKind(this.transport) === 'stdio') return; + const filtered = result.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; + }); + if (filtered.length !== result.tools.length) result.tools = filtered; } /** diff --git a/packages/client/src/client/responseCache.ts b/packages/client/src/client/responseCache.ts index faa3f54195..8fbe90ca49 100644 --- a/packages/client/src/client/responseCache.ts +++ b/packages/client/src/client/responseCache.ts @@ -251,10 +251,8 @@ export class ClientResponseCache { * Returns `undefined` only when no `tools/list` response is held at all, * or the held list does not contain `name`. * - * No production caller in the substrate commit — the stacked SEP-2243 PR - * wires `callTool()`'s `Mcp-Param-*` mirroring through it. - * {@linkcode outputValidator} is the substrate's own derived view over the - * same entry. + * Consumed by `callTool()`'s SEP-2243 `_resolveXMcpHeaderScan` (mirroring) + * and, via {@linkcode outputValidator}, its output-schema validation. */ async toolDefinition(name: string): Promise { const entry = await this._store.get({ method: 'tools/list' }); diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 277e84e0fd..440422c130 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -3,10 +3,12 @@ import type { ReadableWritablePair } from 'node:stream/web'; import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; import { createFetchWithInit, + encodeMcpParamValue, isInitializedNotification, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, + isModernProtocolVersion, JSONRPCMessageSchema, normalizeHeaders, PROTOCOL_VERSION_META_KEY, @@ -182,6 +184,23 @@ export type StreamableHTTPClientTransportOptions = { protocolVersion?: string; }; +/** + * 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' +]); + /** * `AbortSignal.any` with a manual fallback. `AbortSignal.any` landed in * Node 20.3; this package's `engines` floor is `>=20`, so 20.0–20.2 must be @@ -306,6 +325,42 @@ 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, + // control characters, or CR/LF) cannot make `Headers.set()` throw a + // TypeError or silently normalize to a value that differs from the + // body. The spec's value-encoding rules apply to `Mcp-Name`; this + // SDK's server does not yet cross-check `Mcp-Name` against the body + // (tracked in expected-failures.yaml) — when it does it will decode + // 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)); + } + } + + /** + * `true` when the outbound message is a single request carrying a + * modern-era protocol-version envelope claim — the same predicate that + * gates body-derived `mcp-method`/`mcp-name` emission. Used to confine the + * 400-body-as-ProtocolError delivery to modern-era exchanges only. + */ + private _isModernEnvelopedRequest(message: JSONRPCMessage | JSONRPCMessage[]): boolean { + if (Array.isArray(message) || !isJSONRPCRequest(message)) return false; + const meta = (message.params as { _meta?: Record } | undefined)?._meta; + const v = meta?.[PROTOCOL_VERSION_META_KEY]; + return typeof v === 'string' && isModernProtocolVersion(v); } private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise { @@ -659,6 +714,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 +728,7 @@ export class StreamableHTTPClientTransport implements Transport { onresumptiontoken?: (token: string) => void; requestSignal?: AbortSignal; onRequestStreamEnd?: () => void; + headers?: Readonly>; } | undefined, isAuthRetry: boolean @@ -689,6 +746,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 +860,36 @@ 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`. + // + // Modern-era only: gated on the outbound message carrying a + // 2026-07-28 envelope claim (the same gate the body-derived + // `mcp-method`/`mcp-name` headers use), so a legacy-era + // exchange keeps surfacing 400 as `SdkHttpError` exactly as + // before — the changeset's "legacy-era paths are unchanged" + // claim stays true and existing + // `e instanceof SdkHttpError && e.status === 400` callers do + // not silently stop matching. + if (response.status === 400 && typeof text === 'string' && this._isModernEnvelopedRequest(message)) { + 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 de5814a8c2..694084598d 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..424e0d5289 --- /dev/null +++ b/packages/client/test/client/mcpParamMirroring.test.ts @@ -0,0 +1,420 @@ +/** + * 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 the response-cache's `tools/list` + * entry and the `toolDefinition` escape hatch; era-parity (legacy `callTool` + * byte-untouched); stdio MAY-ignore (no headers on a single-channel + * transport); the one-evict-refetch-retry on `HEADER_MISMATCH`. + */ +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 { InMemoryResponseCacheStore, type ResponseCacheStore } from '../../src/client/responseCache.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; + serverTx: InMemoryTransport; + /** Headers passed via TransportSendOptions for each tools/call (undefined when none). */ + callHeaders: Array | undefined>; + listCount: () => number; +} + +async function scriptedModernServer(pages: 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: { listChanged: true } }, + serverInfo: { name: 'scripted', version: '1.0.0' } + } + }); + } else if (r.method === 'tools/list') { + lists++; + const cursor = (r.params as { cursor?: string } | undefined)?.cursor; + const idx = cursor === undefined ? 0 : Number(cursor); + const next = idx + 1 < pages.length ? String(idx + 1) : undefined; + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { + resultType: 'complete', + ttlMs: 60_000, + cacheScope: 'public', + tools: pages[idx] ?? [], + ...(next !== undefined && { nextCursor: next }) + } + }); + } 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, serverTx, callHeaders, listCount: () => lists }; +} + +function modernClient(store?: InMemoryResponseCacheStore): Client { + return new Client( + { name: 'param-mirror-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: MODERN } }, ...(store && { responseCacheStore: store }) } + ); +} + +describe('SEP-2243 Mcp-Param-* mirroring (modern era)', () => { + it('listTools() and the cached tools/list entry exclude constraint-violating x-mcp-header tools and warn', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const store = new InMemoryResponseCacheStore(); + const { clientTx } = await scriptedModernServer([[REGION_TOOL, INVALID_TOOL]]); + const client = modernClient(store); + await client.connect(clientTx); + + // Auto-aggregate listTools() filters and writes the CACHED aggregate + // (the entry mirroring reads). + const { tools } = await client.listTools(); + expect(tools.map(t => t.name)).toEqual(['route']); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("excluding tool 'broken'")); + expect((store.get({ method: 'tools/list' })?.value as { tools: Tool[] }).tools.map(t => t.name)).toEqual(['route']); + // The explicit-cursor per-page path is filtered too (the spec's MUST + // has no carve-out for paginated reads). + const page = await client.listTools({ cursor: '0' }); + expect(page.tools.map(t => t.name)).toEqual(['route']); + warn.mockRestore(); + }); + + it('callTool() passes Mcp-Param-* via TransportSendOptions.headers from the cached tools/list entry; 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() evicts the tools/list entry, refetches once and retries on a HEADER_MISMATCH rejection (stale-cache path)', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]], /* rejectFirstCall */ true); + const client = modernClient(store); + await client.connect(clientTx); + // Seed a STALE entry (no declarations) so callTool reads it and the + // first send carries no param headers — server rejects + // HEADER_MISMATCH, client evicts, refetches via listTools() + // (the live REGION_TOOL), and retries with the headers. + store.set({ method: 'tools/list' }, { value: { tools: [{ name: 'route', inputSchema: { type: 'object', properties: {} } }] } }); + + 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' }]); + // The recovery refetch wrote a fresh cache entry (REGION_TOOL, with the declaration). + expect((store.get({ method: 'tools/list' })?.value as { tools: Tool[] }).tools[0]?.inputSchema.properties).toHaveProperty('region'); + }); + + it('callTool() with a cold cache issues NO tools/list and sends without Mcp-Param-* headers (cache reads only)', async () => { + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]); + const client = modernClient(); + await client.connect(clientTx); + + const result = await client.callTool({ name: 'route', arguments: { region: 'ap' } }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + // No on-demand populate: callTool reads the cache directly. Cold ⇒ + // proceed without headers (the spec's "client SHOULD send without + // custom headers" guidance) — the only callTool-driven tools/list is + // the HEADER_MISMATCH recovery path. + expect(listCount()).toBe(0); + expect(callHeaders).toEqual([undefined]); + }); + + it('a custom store whose get() rejects is routed to onerror and callTool degrades (no headers, no validation, result preserved)', async () => { + const store = new InMemoryResponseCacheStore(); + (store as ResponseCacheStore).get = () => Promise.reject(new Error('redis down')); + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]); + const client = modernClient(store); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + await client.connect(clientTx); + + // The pre-send mirroring read AND the post-success validator read both + // hit a rejecting `get()`. Neither aborts the call: the request goes + // out without `Mcp-Param-*` headers (cold-cache posture), the + // server-side result is returned, and both store failures surface via + // `onerror`. The post-success guard is the critical one — a store + // failure after the server has executed the call must never surface + // as a `callTool()` rejection (duplicate-execution hazard on retry). + const result = await client.callTool({ name: 'route', arguments: { region: 'ap' } }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + expect(callHeaders).toEqual([undefined]); + expect(listCount()).toBe(0); + expect(errors.map(e => e.message)).toEqual(['redis down', 'redis down']); + }); + + it('a paginating server: the cached aggregate holds every page and a page-2 x-mcp-header tool mirrors on the first call', async () => { + const PAGE1: Tool = { name: 'echo', inputSchema: { type: 'object', properties: {} } }; + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[PAGE1], [REGION_TOOL]]); + const client = modernClient(); + await client.connect(clientTx); + + const { tools } = await client.listTools(); + expect(tools.map(t => t.name)).toEqual(['echo', 'route']); + expect(listCount()).toBe(2); + + await client.callTool({ name: 'route', arguments: { region: 'us-west1' } }); + expect(callHeaders[0]).toEqual({ 'Mcp-Param-Region': 'us-west1' }); + }); + + it('HEADER_MISMATCH recovery refetch walks every page; a page-2 x-mcp-header tool is recovered (stale-cache path)', async () => { + const PAGE1: Tool = { name: 'echo', inputSchema: { type: 'object', properties: {} } }; + const store = new InMemoryResponseCacheStore(); + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[PAGE1], [REGION_TOOL]], /* rejectFirstCall */ true); + const client = modernClient(store); + await client.connect(clientTx); + // Seed a STALE entry so the first send goes without headers; the + // recovery refetch (via listTools()) then walks BOTH pages. + store.set({ method: 'tools/list' }, { value: { tools: [PAGE1] } }); + + const result = await client.callTool({ name: 'route', arguments: { region: 'us-west1' } }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + // The recovery refetch walked both pages. + expect(listCount()).toBe(2); + expect(callHeaders).toEqual([undefined, { 'Mcp-Param-Region': 'us-west1' }]); + // A follow-up call still mirrors from the cached entry (no extra list). + await client.callTool({ name: 'route', arguments: { region: 'eu' } }); + expect(callHeaders[2]).toEqual({ 'Mcp-Param-Region': 'eu' }); + expect(listCount()).toBe(2); + }); + + it('notifications/tools/list_changed evicts the cached entry; the next callTool reads cold (no auto-refetch)', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, serverTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]); + const client = modernClient(store); + await client.connect(clientTx); + // Seed a STALE entry (no declarations); list_changed evicts it; the + // next callTool reads cold and sends without headers — callTool + // never refetches on its own. + store.set({ method: 'tools/list' }, { value: { tools: [{ name: 'route', inputSchema: { type: 'object', properties: {} } }] } }); + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' } as JSONRPCMessage); + expect(store.get({ method: 'tools/list' })).toBeUndefined(); + + const result = await client.callTool({ name: 'route', arguments: { region: 'us' } }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + expect(listCount()).toBe(0); + expect(callHeaders).toEqual([undefined]); + }); + + it('_resetConnectionState() clears the response 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' } }); + // The cache from A was cleared on close → callTool reads cold against + // server B → no Mcp-Param-* headers (no stale scan from A's entry), + // and no callTool-driven tools/list either. + expect(b.listCount()).toBe(0); + expect(b.callHeaders[0]).toBeUndefined(); + }); +}); + +describe('SEP-2243 era parity / stdio exemption', () => { + it('legacy-era callTool() is byte-untouched: zero tools/list requests, no headers, no exclusion', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const callHeaders: Array | undefined> = []; + const sentMethods: string[] = []; + const realSend = clientTx.send.bind(clientTx); + clientTx.send = (m: JSONRPCMessage, opts?: TransportSendOptions): Promise => { + if ('method' in m) sentMethods.push((m as JSONRPCRequest).method); + 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'); + + // PIN: a legacy/stdio callTool issues ZERO tools/list requests — + // callTool never auto-populates the cache; mirroring/validation read + // it directly (cold ⇒ skip). + await client.callTool({ name: 'route', arguments: { region: 'us' } }); + expect(sentMethods.filter(m => m === 'tools/list')).toEqual([]); + expect(callHeaders).toEqual([undefined]); + + const { tools } = await client.listTools(); + // No exclusion on the legacy era — both tools present. + expect(tools.map(t => t.name)).toEqual(['route', 'broken']); + }); + + it('modern-era stdio callTool() issues zero tools/list requests (cold cache, mirroring inactive)', async () => { + // Mirrors the legacy pin above but on the modern era over a + // single-channel transport: even though `mirroringActive` is true, + // callTool reads the cache directly and sends nothing extra. + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]); + const client = modernClient(); + await client.connect(clientTx); + + await client.callTool({ name: 'route', arguments: { region: 'us' } }); + expect(listCount()).toBe(0); + 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 on a modern-enveloped request; legacy still throws 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 } }); + + // Legacy-era exchange (no envelope claim) still surfaces 400 as the + // generic SdkHttpError — gating keeps the "legacy paths unchanged" + // claim true. + await expect(tx.send({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'route' } })).rejects.toMatchObject({ + status: 400 + }); + }); +}); 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..800d65941e 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -161,7 +161,8 @@ export type InboundValidationRung = | 'envelope' | 'method-registry' | 'request-params' - | 'client-capabilities'; + | 'client-capabilities' + | '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 +317,21 @@ 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: 'param-header-validation', + order: 8, + 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. The documented order ' + + '(after method resolution and params validation) is preserved observably only when the body `arguments` would ' + + 'otherwise validate: the check runs pre-dispatch, so a `tools/call` that fails BOTH this rung and a dispatch-time ' + + 'rung (e.g. order-6 `request-params`, -32602) is answered by this gate first with 400 / -32001, not by the ' + + 'earlier-ordered rung.' } ]; 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..6f6ec7f294 --- /dev/null +++ b/packages/core/src/shared/mcpParamHeaders.ts @@ -0,0 +1,412 @@ +/** + * 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). The static-reachability MUST is enforced as a structural + * sweep: every position the chain MUST NOT pass through (`items`/ + * `additionalProperties`, `oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`, + * `$defs`, `$ref` targets within `$defs`) is visited too, and an + * `x-mcp-header` found anywhere on that path invalidates the schema — "an + * annotation anywhere else makes the tool definition invalid". + */ +export function scanXMcpHeaderDeclarations(inputSchema: unknown): XMcpHeaderScanResult { + const declarations: XMcpHeaderDeclaration[] = []; + const seenLower = new Map(); + + const visit = (node: unknown, path: readonly string[], reachable: boolean): string | undefined => { + if (node === null || typeof node !== 'object') return undefined; + const schema = node as Record; + + if (X_MCP_HEADER_KEY in schema) { + if (!reachable || path.length === 0) { + return `${pathName(path)}: x-mcp-header is only permitted on properties statically reachable via a chain of 'properties' keys (not under items, additionalProperties, oneOf/anyOf/allOf/not, if/then/else, or $ref)`; + } + 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], reachable); + if (fault !== undefined) return fault; + } + } + // Static-reachability sweep: descend the keywords the chain MUST NOT + // pass through with `reachable: false` so an annotation under any of + // them is reported (rather than silently ignored). `$defs` covers + // `$ref`-within-`$defs` — chasing arbitrary `$ref` URIs is out of scope. + for (const k of NON_REACHABLE_SUBSCHEMA_KEYWORDS) { + const sub = schema[k]; + if (sub === undefined) continue; + const branches: unknown[] = Array.isArray(sub) + ? sub + : sub !== null && typeof sub === 'object' && OBJECT_VALUED_SUBSCHEMA_KEYWORDS.has(k) + ? Object.values(sub as Record) + : [sub]; + for (const branch of branches) { + const fault = visit(branch, [...path, `<${k}>`], false); + if (fault !== undefined) return fault; + } + } + return undefined; + }; + + const fault = visit(inputSchema, [], true); + return fault === undefined ? { valid: true, declarations } : { valid: false, reason: fault }; +} + +/** + * JSON Schema keywords whose subschemas the SEP-2243 static-reachability + * constraint excludes from the `properties`-only chain. An `x-mcp-header` + * found under any of these invalidates the tool definition. + */ +const NON_REACHABLE_SUBSCHEMA_KEYWORDS = [ + 'items', + 'prefixItems', + 'contains', + 'additionalProperties', + 'unevaluatedProperties', + 'unevaluatedItems', + 'propertyNames', + 'patternProperties', + 'dependentSchemas', + 'oneOf', + 'anyOf', + 'allOf', + 'not', + 'if', + 'then', + 'else', + '$defs', + 'definitions' +] as const; + +/** + * Subschema-carrying keywords whose value is a `name → subschema` object + * (not a single subschema or array of subschemas). The visit branches over + * `Object.values()` for these. + */ +const OBJECT_VALUED_SUBSCHEMA_KEYWORDS: ReadonlySet = new Set(['patternProperties', 'dependentSchemas', '$defs', 'definitions']); + +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..df8d3d8073 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -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. @@ -1430,7 +1430,7 @@ export abstract class Protocol { this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); - this._transport.send(outbound, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { + this._transport.send(outbound, { relatedRequestId, resumptionToken, onresumptiontoken, headers }).catch(error => { this._progressHandlers.delete(messageId); reject(error); }); diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index c9be6ee56c..fdb6fc3dd4 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. 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..4e76ff0ee6 --- /dev/null +++ b/packages/core/test/shared/mcpParamHeaders.test.ts @@ -0,0 +1,330 @@ +/** + * 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('CRLF header-injection: encode produces a sentinel value with no CR/LF and round-trips intact', () => { + // Mcp-Param-* and Mcp-Name share this encoder; an attacker-controlled + // value with CR/LF MUST encode to a header-safe form (RFC 9110 token + // alphabet for the sentinel framing, RFC 4648 §4 alphabet for the + // payload — neither contains CR/LF) so it cannot inject a header. + const injection = 'foo\r\nX-Injected: bar'; + const encoded = encodeMcpParamValue(injection); + expect(encoded.startsWith('=?base64?')).toBe(true); + expect(encoded).not.toMatch(/[\r\n]/); + expect(decodeMcpParamValue(encoded)).toBe(injection); + // The Mcp-Name encoding path is the same encodeMcpParamValue call + // (`_applyBodyDerivedHeaders` in the client transport); pin the + // header-safety property here so a future encoder change cannot + // regress it silently. + expect(() => new Headers().set('mcp-name', encoded)).not.toThrow(); + }); + + 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/); + }); + + // Static-reachability MUST: an x-mcp-header anywhere outside the + // properties-only chain invalidates the tool definition. + const REACHABILITY_CASES: ReadonlyArray<[label: string, schema: unknown]> = [ + ['root schema', { type: 'object', [X_MCP_HEADER_KEY]: 'Root' }], + ['under items', { type: 'object', properties: { a: { type: 'array', items: { type: 'string', [X_MCP_HEADER_KEY]: 'Elem' } } } }], + [ + 'under additionalProperties', + { type: 'object', properties: {}, additionalProperties: { type: 'string', [X_MCP_HEADER_KEY]: 'Extra' } } + ], + [ + 'under oneOf', + { type: 'object', oneOf: [{ type: 'object', properties: { a: { type: 'string', [X_MCP_HEADER_KEY]: 'Branch' } } }] } + ], + ['under anyOf', { type: 'object', anyOf: [{ type: 'string', [X_MCP_HEADER_KEY]: 'Branch' }] }], + ['under allOf', { type: 'object', allOf: [{ type: 'string', [X_MCP_HEADER_KEY]: 'Branch' }] }], + ['under not', { type: 'object', not: { type: 'string', [X_MCP_HEADER_KEY]: 'Neg' } }], + ['under if/then/else', { type: 'object', if: {}, then: { type: 'string', [X_MCP_HEADER_KEY]: 'Cond' } }], + [ + 'under $defs (a $ref-within-$defs target)', + { type: 'object', properties: { a: { $ref: '#/$defs/R' } }, $defs: { R: { type: 'string', [X_MCP_HEADER_KEY]: 'Ref' } } } + ], + [ + "under draft-07 'definitions' (legacy alias of $defs)", + { + type: 'object', + properties: { a: { $ref: '#/definitions/R' } }, + definitions: { R: { type: 'string', [X_MCP_HEADER_KEY]: 'Ref' } } + } + ], + [ + 'under dependentSchemas', + { + type: 'object', + dependentSchemas: { foo: { type: 'object', properties: { bar: { type: 'string', [X_MCP_HEADER_KEY]: 'Dep' } } } } + } + ], + ['under unevaluatedProperties', { type: 'object', unevaluatedProperties: { type: 'string', [X_MCP_HEADER_KEY]: 'Unev' } }], + [ + 'under unevaluatedItems', + { type: 'object', properties: { a: { type: 'array', unevaluatedItems: { type: 'string', [X_MCP_HEADER_KEY]: 'Unev' } } } } + ], + ['under propertyNames', { type: 'object', propertyNames: { type: 'string', [X_MCP_HEADER_KEY]: 'PNames' } }], + [ + 'nested: properties → items → properties (the chain passes through items)', + { + type: 'object', + properties: { + a: { type: 'array', items: { type: 'object', properties: { b: { type: 'string', [X_MCP_HEADER_KEY]: 'Deep' } } } } + } + } + ] + ]; + for (const [label, schema] of REACHABILITY_CASES) { + test(`x-mcp-header on a non-statically-reachable position is rejected: ${label}`, () => { + expect(invalid(schema)).toMatch(/statically reachable/); + }); + } + + 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/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 3afd69fbf1..5d02871a6e 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -46,11 +46,13 @@ import { modernOnlyStrictRejection, requestMetaOf, requiredClientCapabilitiesForRequest, + scanXMcpHeaderDeclarations, SdkError, SdkErrorCode, setNegotiatedProtocolVersion, SUPPORTED_MODERN_PROTOCOL_VERSIONS, - UnsupportedProtocolVersionError + UnsupportedProtocolVersionError, + validateMcpParamHeaders } from '@modelcontextprotocol/core'; import { invoke } from './invoke.js'; @@ -693,6 +695,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..7a2bf55953 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,44 @@ 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`). + // A successful re-derive is memoized so the per-request-factory + // `createMcpHandler` model does not re-convert on every call. + try { + const json = standardSchemaToJsonSchema(tool.inputSchema, 'input'); + this._toolInputSchemaJson[name] = json; + return json; + } catch { + return undefined; + } + } constructor(serverInfo: Implementation, options?: ServerOptions) { this.server = new Server(serverInfo, options); @@ -745,6 +784,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; @@ -763,12 +829,27 @@ export class McpServer { enable: () => registeredTool.update({ enabled: true }), remove: () => registeredTool.update({ name: null }), update: updates => { + // The closure's `name` tracks the CURRENT registry key, not + // the original registration name — renaming reassigns it so + // subsequent paramsSchema/rename invalidations evict the live + // `_toolInputSchemaJson` slot rather than the original. if (updates.name !== undefined && updates.name !== name) { if (typeof updates.name === 'string') { validateAndWarnToolName(updates.name); } delete this._registeredTools[name]; - if (updates.name) this._registeredTools[updates.name] = registeredTool; + delete this._toolInputSchemaJson[name]; + if (updates.name) { + // The TARGET key may already be occupied by another + // tool (rename has no duplicate-name guard) — drop + // its memo too, otherwise `toolInputSchemaJson()` + // returns the displaced tool's converted schema and + // the SEP-2243 pre-dispatch validation runs against + // the wrong schema for this name. + delete this._toolInputSchemaJson[updates.name]; + this._registeredTools[updates.name] = registeredTool; + name = updates.name; + } } if (updates.title !== undefined) registeredTool.title = updates.title; if (updates.description !== undefined) registeredTool.description = updates.description; @@ -777,6 +858,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/test/server/mcpParamValidation.test.ts b/packages/server/test/server/mcpParamValidation.test.ts new file mode 100644 index 0000000000..015d63203d --- /dev/null +++ b/packages/server/test/server/mcpParamValidation.test.ts @@ -0,0 +1,136 @@ +/** + * 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', + ...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/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index 5c30ea0ade..dc675aff4d 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. diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index c5ab22325a..3a178dcf9c 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. 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 6fff6c79d6..7f58a901da 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2595,6 +2595,15 @@ 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.' + }, // 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/sep2243.test.ts b/test/e2e/scenarios/sep2243.test.ts new file mode 100644 index 0000000000..60beb935e5 --- /dev/null +++ b/test/e2e/scenarios/sep2243.test.ts @@ -0,0 +1,60 @@ +/** + * 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 { 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() auto-aggregates and writes the response cache; callTool + // reads it directly and 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' }]); +});