From ce53d7df465639117c2335b2a864b6a6e385293e Mon Sep 17 00:00:00 2001 From: Yudistira Putra <85178972+Yudis-bit@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:13:59 +0700 Subject: [PATCH] fix(server): reject unsafe integers in x-mcp-header parameters when header is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the Streamable HTTP specification, integer values in `x-mcp-header` fields MUST be within the JavaScript safe-integer range (−2^53+1 to 2^53−1). Previously, `validateMcpParamHeaders` executed `continue` when `mcpParamPrimitiveToString(bodyRaw)` returned `undefined`, assuming that this only occurred when the body carried a non-primitive object/array belonging to schema validation. However, `mcpParamPrimitiveToString` also returns `undefined` for unsafe integers and non-finite numbers. This caused `tools/call` requests with annotated unsafe integer arguments (such as 9007199254740992) to skip parity validation completely when the client omitted the `Mcp-Param-*` header, invoking the tool handler instead of rejecting the non-conforming request. `validateMcpParamHeaders` now: 1. Only skips parity checks for non-primitive objects and functions. 2. Checks for absent headers (`headerValue === null`) across all primitive values and rejects with 400 / -32020 (`param-header-missing`). 3. Restricts numeric equality coercion (`numericComparable`) to safe integers so unsafe integers cannot be accepted via float precision loss. Fixes #2689 --- .../reject-unsafe-integer-param-header.md | 6 ++++ .../src/shared/mcpParamHeaders.ts | 13 ++++--- .../test/shared/mcpParamHeaders.test.ts | 31 ++++++++++++++++ .../test/server/mcpParamValidation.test.ts | 35 ++++++++++++++++++- 4 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 .changeset/reject-unsafe-integer-param-header.md diff --git a/.changeset/reject-unsafe-integer-param-header.md b/.changeset/reject-unsafe-integer-param-header.md new file mode 100644 index 0000000000..6e7918a8e2 --- /dev/null +++ b/.changeset/reject-unsafe-integer-param-header.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core-internal': patch +'@modelcontextprotocol/server': patch +--- + +Reject unsafe integers in annotated `x-mcp-header` tool parameters when the mirrored header is absent: Streamable HTTP specification dictates that integer values must be within the JavaScript safe-integer range (−2^53+1 to 2^53−1). Previously, `validateMcpParamHeaders` skipped parity validation when a parameter value could not be represented as a canonical primitive string (`mcpParamPrimitiveToString` returning `undefined`), allowing unsafe integer arguments (such as `9007199254740992`) to bypass header validation and invoke handlers without the required `Mcp-Param-*` header. `validateMcpParamHeaders` now validates missing headers for all primitive values, disallows unsafe integers from numeric coercion, and returns `400 Bad Request` / `-32020 HeaderMismatch` before handler invocation. diff --git a/packages/core-internal/src/shared/mcpParamHeaders.ts b/packages/core-internal/src/shared/mcpParamHeaders.ts index 493cf50aeb..626062c552 100644 --- a/packages/core-internal/src/shared/mcpParamHeaders.ts +++ b/packages/core-internal/src/shared/mcpParamHeaders.ts @@ -351,8 +351,7 @@ export function validateMcpParamHeaders( // Server MUST NOT expect the header for a null/absent value. continue; } - const bodyString = mcpParamPrimitiveToString(bodyRaw); - if (bodyString === undefined) { + if (typeof bodyRaw === 'object' || typeof bodyRaw === 'function') { // Body carries a non-primitive where the schema declares one; // params validation owns that fault. Skip the header check. continue; @@ -372,6 +371,7 @@ export function validateMcpParamHeaders( `the ${headerKey} header carries an invalid Base64 sentinel value` ); } + const bodyString = mcpParamPrimitiveToString(bodyRaw); // Integer/number-typed declarations compare numerically (the spec's // SHOULD — `42.0` and `42` are equal). The strict-decimal gate is // applied to the *header* side only (so `'0x1a'`, `' 42 '`, `'1e3'` @@ -382,9 +382,12 @@ export function validateMcpParamHeaders( // body-vs-schema fault that params validation owns; fall back to // string comparison and let dispatch emit `-32602` instead so an // identical non-numeric pair never reports a mismatch. - const numericComparable = - (decl.type === 'integer' || decl.type === 'number') && CANONICAL_DECIMAL.test(decoded) && typeof bodyRaw === 'number'; - const equal = numericComparable ? Number(decoded) === bodyRaw : decoded === bodyString; + // Integers outside the safe-integer range cannot be compared + // numerically because double-precision floats lose integer precision. + const isSafeNumeric = + typeof bodyRaw === 'number' && Number.isFinite(bodyRaw) && (!Number.isInteger(bodyRaw) || Number.isSafeInteger(bodyRaw)); + const numericComparable = (decl.type === 'integer' || decl.type === 'number') && CANONICAL_DECIMAL.test(decoded) && isSafeNumeric; + const equal = numericComparable ? Number(decoded) === bodyRaw : bodyString !== undefined && decoded === bodyString; if (!equal) { return paramHeaderMismatchRejection( 'param-header-mismatch', diff --git a/packages/core-internal/test/shared/mcpParamHeaders.test.ts b/packages/core-internal/test/shared/mcpParamHeaders.test.ts index 13d8ea582f..ace54ae0cf 100644 --- a/packages/core-internal/test/shared/mcpParamHeaders.test.ts +++ b/packages/core-internal/test/shared/mcpParamHeaders.test.ts @@ -335,6 +335,37 @@ describe('validateMcpParamHeaders — server-behavior table', () => { const r = validateMcpParamHeaders(intDecl, { n: 'abc' }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: 'xyz' })); expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' }); }); + + test('unsafe integer in annotated field without mirrored header rejects as param-header-missing', () => { + const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const; + const r = validateMcpParamHeaders(intDecl, { n: 9_007_199_254_740_992 }, new Headers()); + expect(r).toMatchObject({ + kind: 'reject', + httpStatus: 400, + code: HEADER_MISMATCH_ERROR_CODE, + cell: 'param-header-missing' + }); + }); + + test('unsafe integer in annotated field with mirrored header rejects as param-header-mismatch', () => { + const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const; + const r = validateMcpParamHeaders( + intDecl, + { n: 9_007_199_254_740_992 }, + new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: '9007199254740992' }) + ); + expect(r).toMatchObject({ + kind: 'reject', + httpStatus: 400, + code: HEADER_MISMATCH_ERROR_CODE, + cell: 'param-header-mismatch' + }); + }); + + test('non-primitive object in annotated field skips parity check (params validation owns that fault)', () => { + const strDecl = [{ path: ['region'], headerName: 'Region', type: 'string' }] as const; + expect(validateMcpParamHeaders(strDecl, { region: { nested: 1 } }, new Headers())).toBeUndefined(); + }); }); describe('paramHeaderMismatchRejection — consumes the inbound-classifier −32020 shape verbatim', () => { diff --git a/packages/server/test/server/mcpParamValidation.test.ts b/packages/server/test/server/mcpParamValidation.test.ts index 531edf05b0..f2766f10c6 100644 --- a/packages/server/test/server/mcpParamValidation.test.ts +++ b/packages/server/test/server/mcpParamValidation.test.ts @@ -34,12 +34,20 @@ const REGION_INPUT_SCHEMA = { properties: { region: { type: 'string', 'x-mcp-header': 'Region' }, query: { type: 'string' } } } as const; +const COUNT_INPUT_SCHEMA = { + type: 'object', + properties: { count: { type: 'integer', 'x-mcp-header': 'Count' } } +} 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 ?? ''}` }] })); + s.registerTool('compute', { inputSchema: fromJsonSchema<{ count?: number }>(COUNT_INPUT_SCHEMA) }, async args => ({ + content: [{ type: 'text', text: `computed ${args.count}` }] + })); return s; }; } @@ -115,11 +123,36 @@ 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); }); + + // Issue #2689: Streamable HTTP server accepts unsafe integer in x-mcp-header field when mirrored header is absent + it('rejects unsafe integer in annotated field when mirrored header is absent (issue #2689)', async () => { + const handler = createMcpHandler(makeFactory()); + const req = new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-protocol-version': MODERN, + 'mcp-method': 'tools/call', + 'mcp-name': 'compute' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 8, + method: 'tools/call', + params: { name: 'compute', arguments: { count: 9_007_199_254_740_992 }, _meta: ENVELOPE } + }) + }); + const response = await handler.fetch(req); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: number } }; + expect(body.error.code).toBe(-32_020); + }); }); 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 warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const s = new McpServer({ name: 'warn-server', version: '1.0.0' }); s.registerTool( 'bad',