Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-param-check-number-declaration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@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 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.
7 changes: 7 additions & 0 deletions docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,13 @@ 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 (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
for notification POSTs are not defined by it — so a modern-enveloped notification is
Expand Down
17 changes: 17 additions & 0 deletions packages/client/test/client/mcpParamMirroring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
22 changes: 15 additions & 7 deletions packages/core-internal/src/shared/mcpParamHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -61,15 +63,16 @@ 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<string> = new Set(['string', 'integer', 'boolean']);

/**
* 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
Expand All @@ -82,6 +85,7 @@ const PERMITTED_X_MCP_HEADER_TYPES: ReadonlySet<string> = new Set(['string', 'in
export function scanXMcpHeaderDeclarations(inputSchema: unknown): XMcpHeaderScanResult {
const declarations: XMcpHeaderDeclaration[] = [];
const seenLower = new Map<string, string>();
let numberFault: string | undefined;

const visit = (node: unknown, path: readonly string[], reachable: boolean): string | undefined => {
if (node === null || typeof node !== 'object') return undefined;
Expand All @@ -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 ?? '<none>'}`;
const fault = `${pathName(path)}: x-mcp-header is only permitted on primitive-typed properties (string, integer, boolean); got ${type ?? '<none>'}`;
// 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);
Expand Down Expand Up @@ -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 };
}

/**
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/server/src/server/createMcpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
claude[bot] marked this conversation as resolved.
const rejection = validateMcpParamHeaders(scan.declarations, callParams?.arguments, request.headers);
if (rejection !== undefined) {
void product.close().catch(reportError);
Expand Down
25 changes: 25 additions & 0 deletions packages/server/test/server/mcpParamValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down Expand Up @@ -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<string, string>) => {
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', () => {
Expand Down
Loading