From 0c9a9f8c86174d4e767abc1d51dadd4f6de11f68 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:38:16 +0000 Subject: [PATCH 1/3] fix(server): keep the Mcp-Param check on a tool with a number-typed header #2762 makes the declaration scan report a tool as invalid when it puts `x-mcp-header` on a `number`-typed parameter, as the 2026-07-28 spec requires of clients. `createMcpHandler` compared the `Mcp-Param-*` headers of a `tools/call` with the body only when the scan was valid. So for such a tool the whole check went away, its valid string, integer and boolean parameters included: a call whose header disagrees with the body was answered 200 where 2.3.0 answers 400 with -32020. The scan now keeps walking after a `number` fault. When those are the only faults it returns the declarations next to the reason, and the handler checks whatever declarations the scan returns. The result is still invalid, so a client still drops the tool from `tools/list` and the server still warns when tools are listed. The server then checks exactly the calls 2.3.0 checks. The header of the number-typed parameter itself is still required and compared as a number. A tool with another fault as well was not checked in 2.3.0 and is not checked now. `registerTool` does not throw for such a tool, because that would stop a server at startup after an upgrade. Also here: one sentence in the 2026-07-28 migration page on the tool no longer being listed, a server test for the check, a client test that `listTools()` drops the tool (only a unit test of the scan covered #2762), and three comments in mcpParamHeaders.ts that no longer matched the code. --- .../mcp-param-check-number-declaration.md | 5 ++++ docs/migration/support-2026-07-28.md | 6 +++++ .../test/client/mcpParamMirroring.test.ts | 17 +++++++++++++ .../src/shared/mcpParamHeaders.ts | 22 ++++++++++------ .../server/src/server/createMcpHandler.ts | 3 ++- .../test/server/mcpParamValidation.test.ts | 25 +++++++++++++++++++ 6 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 .changeset/mcp-param-check-number-declaration.md diff --git a/.changeset/mcp-param-check-number-declaration.md b/.changeset/mcp-param-check-number-declaration.md new file mode 100644 index 0000000000..917e7136c6 --- /dev/null +++ b/.changeset/mcp-param-check-number-declaration.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +A server whose tool puts `x-mcp-header` on a `number`-typed parameter still serves that tool and keeps checking its `Mcp-Param-*` headers on `tools/call` as before: a header that is missing for a value in the body, or that disagrees with the body, is answered `400` with JSON-RPC error `-32020`. The server logs a warning for such a tool each time tools are listed, because clients on a 2026-07-28 HTTP connection no longer list it. Use an integer (`z.int()`) or remove the annotation. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index c7b087a3ef..7ab51b1a19 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -657,6 +657,12 @@ than served, even though the body claim alone still determines the era — so a client that relied on the body envelope without sending the header must add it. Clients built with this SDK send all three already. +A tool that puts `x-mcp-header` on a `number`-typed parameter (zod: `z.number()`) is no +longer listed by `Client.listTools()` on a 2026-07-28 connection over Streamable HTTP, +because the specification permits the annotation only on `string`, `integer` and +`boolean` parameters and requires clients to exclude a tool that breaks this: use an +integer (`z.int()`) or remove the annotation. + Notification POSTs are exempt from the presence half: this revision defines no client-to-server notifications over Streamable HTTP, and states that header requirements for notification POSTs are not defined by it — so a modern-enveloped notification is diff --git a/packages/client/test/client/mcpParamMirroring.test.ts b/packages/client/test/client/mcpParamMirroring.test.ts index 97a20d41c2..b0959663ed 100644 --- a/packages/client/test/client/mcpParamMirroring.test.ts +++ b/packages/client/test/client/mcpParamMirroring.test.ts @@ -37,6 +37,11 @@ const INVALID_TOOL: Tool = { inputSchema: { type: 'object', properties: { a: { type: 'object', 'x-mcp-header': 'Data' } } } }; +const NUMBER_TOOL: Tool = { + name: 'score', + inputSchema: { type: 'object', properties: { score: { type: 'number', 'x-mcp-header': 'Score' } } } +}; + interface Scripted { clientTx: InMemoryTransport; serverTx: InMemoryTransport; @@ -142,6 +147,18 @@ describe('SEP-2243 Mcp-Param-* mirroring (modern era)', () => { warn.mockRestore(); }); + it('listTools() excludes a tool that puts x-mcp-header on a number-typed parameter and warns', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { clientTx } = await scriptedModernServer([[REGION_TOOL, NUMBER_TOOL]]); + const client = modernClient(); + await client.connect(clientTx); + + const { tools } = await client.listTools(); + expect(tools.map(t => t.name)).toEqual(['route']); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("excluding tool 'score'")); + 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(); diff --git a/packages/core-internal/src/shared/mcpParamHeaders.ts b/packages/core-internal/src/shared/mcpParamHeaders.ts index 5a49a92dd3..26631c76f7 100644 --- a/packages/core-internal/src/shared/mcpParamHeaders.ts +++ b/packages/core-internal/src/shared/mcpParamHeaders.ts @@ -48,8 +48,10 @@ export interface XMcpHeaderDeclaration { 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 }; +/** The result of scanning a tool's `inputSchema`; an invalid one carries `declarations` when `number` types are its only fault. */ +export type XMcpHeaderScanResult = + | { valid: true; declarations: readonly XMcpHeaderDeclaration[] } + | { valid: false; reason: string; declarations?: readonly XMcpHeaderDeclaration[] }; /** * RFC 9110 §5.1 `token` syntax (`1*tchar`). Rejects empty, space, control @@ -61,7 +63,7 @@ const RFC9110_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; * JSON Schema `type` values the spec admits on an `x-mcp-header` property: * `integer`, `string` and `boolean`. `number` is explicitly not permitted * (2026-07-28 Streamable HTTP, "Schema Extension"), so a tool that annotates a - * `number`-typed property is rejected like any other invalid declaration. + * `number`-typed property is invalid (the server still checks its headers). * Everything else (`object`, `array`, `null`, absent) is rejected too. */ const PERMITTED_X_MCP_HEADER_TYPES: ReadonlySet = new Set(['string', 'integer', 'boolean']); @@ -69,7 +71,8 @@ const PERMITTED_X_MCP_HEADER_TYPES: ReadonlySet = new Set(['string', 'in /** * 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. + * collected declarations (possibly empty) or the first violated constraint + * (with the declarations too, when `number`-typed declarations are the only fault). * * The walk descends through `properties` at any depth (the spec's "any nesting * depth" clause). The static-reachability MUST is enforced as a structural @@ -82,6 +85,7 @@ const PERMITTED_X_MCP_HEADER_TYPES: ReadonlySet = new Set(['string', 'in export function scanXMcpHeaderDeclarations(inputSchema: unknown): XMcpHeaderScanResult { const declarations: XMcpHeaderDeclaration[] = []; const seenLower = new Map(); + let numberFault: string | undefined; const visit = (node: unknown, path: readonly string[], reachable: boolean): string | undefined => { if (node === null || typeof node !== 'object') return undefined; @@ -100,7 +104,10 @@ export function scanXMcpHeaderDeclarations(inputSchema: unknown): XMcpHeaderScan } 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 fault = `${pathName(path)}: x-mcp-header is only permitted on primitive-typed properties (string, integer, boolean); got ${type ?? ''}`; + // A `number` type is a fault, but the walk goes on so the server can still check the tool's headers. + if (type !== 'number') return fault; + numberFault ??= fault; } const lower = raw.toLowerCase(); const prior = seenLower.get(lower); @@ -139,7 +146,8 @@ export function scanXMcpHeaderDeclarations(inputSchema: unknown): XMcpHeaderScan }; const fault = visit(inputSchema, [], true); - return fault === undefined ? { valid: true, declarations } : { valid: false, reason: fault }; + if (fault !== undefined) return { valid: false, reason: numberFault ?? fault }; + return numberFault === undefined ? { valid: true, declarations } : { valid: false, reason: numberFault, declarations }; } /** @@ -325,7 +333,7 @@ export function buildMcpParamHeaders( * 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 — + * Integer- and number-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 diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index b7d27662c9..91bca56517 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -829,7 +829,8 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa const inputSchema = toolName === undefined ? undefined : product.toolInputSchemaJson(toolName); if (inputSchema !== undefined) { const scan = scanXMcpHeaderDeclarations(inputSchema); - if (scan.valid && scan.declarations.length > 0) { + // A tool that is invalid only for a `number`-typed declaration is still checked. + if (scan.declarations !== undefined && scan.declarations.length > 0) { const rejection = validateMcpParamHeaders(scan.declarations, callParams?.arguments, request.headers); if (rejection !== undefined) { void product.close().catch(reportError); diff --git a/packages/server/test/server/mcpParamValidation.test.ts b/packages/server/test/server/mcpParamValidation.test.ts index 7800a9d01a..15f78c32eb 100644 --- a/packages/server/test/server/mcpParamValidation.test.ts +++ b/packages/server/test/server/mcpParamValidation.test.ts @@ -35,6 +35,11 @@ const REGION_INPUT_SCHEMA = { properties: { region: { type: 'string', 'x-mcp-header': 'Region' }, query: { type: 'string' } } } as const; +const SCORE_INPUT_SCHEMA = { + type: 'object', + properties: { score: { type: 'number', 'x-mcp-header': 'Score' }, region: { type: 'string', 'x-mcp-header': 'Region' } } +} as const; + function makeFactory(): () => McpServer { return () => { const s = new McpServer({ name: 'param-server', version: '1.0.0' }); @@ -116,6 +121,26 @@ describe('SEP-2243 Mcp-Param-* server validation (createMcpHandler, modern era)' expect(response.status).toBe(400); expect(((await response.json()) as { error: { code: number } }).error.code).toBe(-32_020); }); + + it('a number-typed declaration does not switch the check off: its header and a valid sibling header are still compared', async () => { + const handler = createMcpHandler(() => { + const s = new McpServer({ name: 'param-server', version: '1.0.0' }); + s.registerTool('route', { inputSchema: fromJsonSchema<{ score?: number; region?: string }>(SCORE_INPUT_SCHEMA) }, async () => ({ + content: [] + })); + return s; + }); + const outcome = async (paramHeaders: Record) => { + const response = await handler.fetch(call({ score: 1.5, region: 'us-west1' }, paramHeaders)); + const body = (await response.json()) as { error?: { code: number; data?: { mismatch?: { header?: string } } } }; + return [response.status, body.error?.code, body.error?.data?.mismatch?.header]; + }; + expect(await outcome({ 'Mcp-Param-Score': '1.5', 'Mcp-Param-Region': 'us-west1' })).toEqual([200, undefined, undefined]); + expect(await outcome({ 'Mcp-Param-Score': '1.5', 'Mcp-Param-Region': 'eu' })).toEqual([400, -32_020, 'Mcp-Param-Region']); + expect(await outcome({ 'Mcp-Param-Score': '9.9', 'Mcp-Param-Region': 'us-west1' })).toEqual([400, -32_020, 'Mcp-Param-Score']); + expect(await outcome({ 'Mcp-Param-Region': 'us-west1' })).toEqual([400, -32_020, 'Mcp-Param-Score']); + expect(await outcome({})).toEqual([400, -32_020, 'Mcp-Param-Score']); + }); }); describe('SEP-2243 registerTool declaration-validity check', () => { From 4daab1f966f1b80844e4f3bee91680b840d36abb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 15:34:33 +0000 Subject: [PATCH 2/3] docs: say that calls to such a tool are rejected Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012VRbFCp41otcScXE1YY3es --- .changeset/mcp-param-check-number-declaration.md | 2 +- docs/migration/support-2026-07-28.md | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.changeset/mcp-param-check-number-declaration.md b/.changeset/mcp-param-check-number-declaration.md index 917e7136c6..082e3c6280 100644 --- a/.changeset/mcp-param-check-number-declaration.md +++ b/.changeset/mcp-param-check-number-declaration.md @@ -2,4 +2,4 @@ '@modelcontextprotocol/server': patch --- -A server whose tool puts `x-mcp-header` on a `number`-typed parameter still serves that tool and keeps checking its `Mcp-Param-*` headers on `tools/call` as before: a header that is missing for a value in the body, or that disagrees with the body, is answered `400` with JSON-RPC error `-32020`. The server logs a warning for such a tool each time tools are listed, because clients on a 2026-07-28 HTTP connection no longer list it. Use an integer (`z.int()`) or remove the annotation. +A server whose tool puts `x-mcp-header` on a `number`-typed parameter keeps checking that tool's `Mcp-Param-*` headers on `tools/call`, as 2.3.0 does: a header that is missing for a value in the body, or that disagrees with the body, is answered `400` with JSON-RPC error `-32020`. Clients on a 2026-07-28 connection no longer list such a tool and send no `Mcp-Param-*` headers for it, so their calls to it are rejected the same way; the server logs a warning each time tools are listed. Use an integer (`z.int()`) or remove the annotation. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 7ab51b1a19..4f4ee49727 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -658,10 +658,11 @@ client that relied on the body envelope without sending the header must add it. built with this SDK send all three already. A tool that puts `x-mcp-header` on a `number`-typed parameter (zod: `z.number()`) is no -longer listed by `Client.listTools()` on a 2026-07-28 connection over Streamable HTTP, -because the specification permits the annotation only on `string`, `integer` and -`boolean` parameters and requires clients to exclude a tool that breaks this: use an -integer (`z.int()`) or remove the annotation. +longer listed by `Client.listTools()` on a 2026-07-28 connection (stdio excepted), because +the specification permits the annotation only on `string`, `integer` and `boolean` +parameters and requires clients to exclude a tool that breaks this. A call to it by name +is rejected by the server with `400` and `-32020`, since the client sends no `Mcp-Param-*` +headers for it. Use an integer (`z.int()`) or remove the annotation. Notification POSTs are exempt from the presence half: this revision defines no client-to-server notifications over Streamable HTTP, and states that header requirements From 5f3706421756c2c15227433eaf67a2c020aa634f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 12:57:33 +0000 Subject: [PATCH 3/3] docs: scope the changeset sentence to connections other than stdio Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012VRbFCp41otcScXE1YY3es --- .changeset/mcp-param-check-number-declaration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/mcp-param-check-number-declaration.md b/.changeset/mcp-param-check-number-declaration.md index 082e3c6280..2cc8fdc427 100644 --- a/.changeset/mcp-param-check-number-declaration.md +++ b/.changeset/mcp-param-check-number-declaration.md @@ -2,4 +2,4 @@ '@modelcontextprotocol/server': patch --- -A server whose tool puts `x-mcp-header` on a `number`-typed parameter keeps checking that tool's `Mcp-Param-*` headers on `tools/call`, as 2.3.0 does: a header that is missing for a value in the body, or that disagrees with the body, is answered `400` with JSON-RPC error `-32020`. Clients on a 2026-07-28 connection no longer list such a tool and send no `Mcp-Param-*` headers for it, so their calls to it are rejected the same way; the server logs a warning each time tools are listed. Use an integer (`z.int()`) or remove the annotation. +A server whose tool puts `x-mcp-header` on a `number`-typed parameter keeps checking that tool's `Mcp-Param-*` headers on `tools/call`, as 2.3.0 does: a header that is missing for a value in the body, or that disagrees with the body, is answered `400` with JSON-RPC error `-32020`. Clients on a 2026-07-28 connection other than stdio no longer list such a tool and send no `Mcp-Param-*` headers for it, so a call that carries a value for a declared parameter is rejected the same way; the server logs a warning each time tools are listed. Use an integer (`z.int()`) or remove the annotation.