Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/reject-unsafe-integer-param-header.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 8 additions & 5 deletions packages/core-internal/src/shared/mcpParamHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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'`
Expand All @@ -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',
Expand Down
31 changes: 31 additions & 0 deletions packages/core-internal/test/shared/mcpParamHeaders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
35 changes: 34 additions & 1 deletion packages/server/test/server/mcpParamValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? '<none>'}` }]
}));
s.registerTool('compute', { inputSchema: fromJsonSchema<{ count?: number }>(COUNT_INPUT_SCHEMA) }, async args => ({
content: [{ type: 'text', text: `computed ${args.count}` }]
}));
return s;
};
}
Expand Down Expand Up @@ -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',
Expand Down
Loading