Skip to content

fix(server): enforce Mcp-Param-* parity for numbers the header codec cannot represent - #2690

Open
mrpmohiburrahman wants to merge 1 commit into
modelcontextprotocol:mainfrom
mrpmohiburrahman:fix/mcp-param-unsafe-integer-bypasses-validation
Open

fix(server): enforce Mcp-Param-* parity for numbers the header codec cannot represent#2690
mrpmohiburrahman wants to merge 1 commit into
modelcontextprotocol:mainfrom
mrpmohiburrahman:fix/mcp-param-unsafe-integer-bypasses-validation

Conversation

@mrpmohiburrahman

Copy link
Copy Markdown

Fixes #2689.

The bug

A tools/call whose x-mcp-header-annotated integer argument is 9007199254740992 (2^53), sent
with the Mcp-Param-* header omitted, answers HTTP 200 and runs the tool handler. Reproduces on
3924de99.

validateMcpParamHeaders converts each annotated body value to a header string before comparing it.
mcpParamPrimitiveToString returns undefined for two unrelated reasons, and the caller only
handled the first:

  1. the value is not a primitive (object, array), which params validation owns
  2. the value is a number the codec cannot represent, meaning non-finite or an integer outside the
    safe range

Both hit the same continue, two statements above the missing-header check, so for that declaration
the missing-header, invalid-encoding and comparison checks were all skipped.

Nothing downstream covered the second case. An unsafe integer is a valid JSON Schema integer:
Ajv's check is !(data % 1) with no range bound, and z.number() accepts it. So
validateMcpParamHeaders returned undefined, createMcpHandler fell through to dispatch, and the
handler ran.

Both values are reachable over the wire, since
JSON.parse('{"a":9007199254740993,"b":1e400}') yields 9007199254740992 and Infinity.

body Mcp-Param-N before after
9007199254740992 absent 200, handler ran 400 -32020
9007199254740992 1 200, handler ran 400 -32020
1e400 (Infinity) absent 200, handler ran 400 -32020
42 absent 400 -32020 unchanged

Row 2 goes wider than the issue reports: a flatly contradictory header was swallowed too.

The fix

One guard, gated on decl.type rather than on typeof bodyRaw alone, so a number against a
type: 'string' declaration keeps deferring to params validation:

const numericBody = typeof bodyRaw === 'number' && (decl.type === 'integer' || decl.type === 'number');
if (bodyString === undefined && !numericBody) {
    continue;
}

Opening that path needed two follow-ons at the same site. The comparison falls back to
String(bodyRaw) when bodyString is undefined, so an agreeing pair still passes when the value's
spelling is not plain decimal; 1e21 mirrored as '1e+21' fails the header-side CANONICAL_DECIMAL
gate and would otherwise be reported as disagreeing with itself. And since JSON.stringify renders
every non-finite number as null, a rejection would have announced the body carries n=null, naming
the one value these checks are documented to let through, so non-finite numbers now print as
themselves.

Existing cells and paramHeaderMismatchRejection are reused unchanged: 400, -32020,
rung: 'param-header-validation'. No new cell id, no new constructor, no ladder change, no new
dependency.

mcpParamPrimitiveToString is left alone. It is correct, it is pinned by its own tests, and
buildMcpParamHeaders shares it, where undefined correctly means "omit the header". The guard
belongs at the validate site.

Deliberately unchanged

An unsafe integer whose header matches stays accepted. Parity holds, and SEP-2243's safe-range MUST
is client-side and definition-scoped; the server's mandate is the Server-Behavior row "Client omits
header but value is in body, server MUST reject". A number against a type: 'string' declaration
still skips, and so does a genuine non-primitive.

The client is untouched. buildMcpParamHeaders still omits the header for exactly these values, so a
same-SDK call carrying an unsafe integer now costs one tools/list refetch before callTool
reissues and rethrows. Worth a follow-up, but not this PR.

docs/migration/support-2026-07-28.md already documents the fixed behaviour, so the code is being
brought up to the doc and no doc change is needed.

Tests

Eight cases in the existing validateMcpParamHeaders server-behavior block: unsafe integer and
non-finite with the header absent, a disagreeing header, a numerically matching header, both
spellings of 1e21, the non-finite rejection message, and a regression guard that a number on a
type: 'string' declaration still defers to params validation.

One end-to-end case through createMcpHandler.fetch(): an unsafe-integer body with no header is
rejected 400 -32020 and the handler never runs, asserted with a vi.fn() spy. The issue asks for
rejection before handler invocation and the status alone does not prove that. The body is built from
a raw JSON string so the value survives to the wire.

Every one of them was watched failing against unpatched source first.

Validation

pnpm run check:all exits 0. core-internal goes 1447 to 1455 tests, server 475 to 476, nothing
else moves.

Two failures remain, both verified to fail identically on unmodified 3924de99:

  • client › stdio.test.ts › should fire onerror and close when ReadBuffer overflows, a 5s timeout.
  • test/e2e › protocol.test.ts › protocol:timeout:max-total, intermittent. It drives real SSE
    delivery under vi.useFakeTimers() and asserts a loose ticks.length >= 3. Running
    scenarios/protocol.test.ts five times each way gave 1 failure in 5 with this change and 1 failure
    in 5 with it reverted.

A changeset is included (core-internal + server, patch).

@mrpmohiburrahman
mrpmohiburrahman requested a review from a team as a code owner August 20, 2026 03:01
@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4ea82c1

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/core-internal Patch
@modelcontextprotocol/server Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/client Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/codemod Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2690

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2690

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2690

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2690

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2690

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2690

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2690

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2690

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2690

commit: 4ea82c1

@claude claude Bot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Streamable HTTP server accepts unsafe integer in x-mcp-header field when mirrored header is absent

1 participant