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
6 changes: 6 additions & 0 deletions .changeset/mcp-param-header-non-primitive-body.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/core-internal': patch
'@modelcontextprotocol/server': patch
---

Reject a `tools/call` with `400` / `-32020` (`HeaderMismatch`) when a body argument declared with `x-mcp-header` is an object or array, with or without its `Mcp-Param-*` header, and when the argument is an integer outside the safe range but the header is present. Previously the header check was skipped for these values, so the call reached the tool.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): Release-note readers are told these calls used to reach the tool, but for most tools they were answered 200 with a tool input-validation error. The last sentence of the changeset at line 6 holds only when the tool's validator accepts the value, such as z.coerce.string() given an array. Fix: state what callers see change in both cases: a call that got 200 with an isError input-validation result, or that ran the tool under a coercing schema, now gets 400 / -32020.

Why this was flagged

No runtime trigger; nothing fails at runtime, the reader of the published changelog is the one affected. .changeset/mcp-param-header-non-primitive-body.md:6 ends "Previously the header check was skipped for these values, so the call reached the tool." On the base branch an object or array for a declared argument skipped the header check, then validateToolInput at packages/server/src/server/mcp.ts:370-375 threw "Input validation error: Invalid arguments for tool ...", and the catch at packages/server/src/server/mcp.ts:320 returned it as an isError result with HTTP 200. executeToolHandler at packages/server/src/server/mcp.ts:307 never ran. The tool ran only when the schema's own validator accepted the value, for example a coercing schema, or a JSON Schema integer for a value outside the safe range. The instruction guards against release notes that understate a visible change: here a 200 tool error becomes a 400 protocol error for every tool, not only coercing ones.

Verification: The changeset's last sentence (.changeset/mcp-param-header-non-primitive-body.md:6) reads: "Previously the header check was skipped for these values, so the call reached the tool." So the caller-visible change for the common case is 200 with an isError input-validation result becoming 400 / -32020; the tool handler never ran.

Finding ID: f-a963bbc4

24 changes: 21 additions & 3 deletions packages/core-internal/src/shared/mcpParamHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,11 +356,29 @@ export function validateMcpParamHeaders(
// Server MUST NOT expect the header for a null/absent value.
continue;
}
// An object or array has no header form: reject it with or without a header.
if (typeof bodyRaw === 'object') {
return headerValue === null
? paramHeaderMismatchRejection(
'param-header-missing',
headerKey,
`the body carries a non-primitive ${pathName(decl.path)} but the ${headerKey} header is absent`
)
: paramHeaderMismatchRejection(
'param-header-mismatch',
headerKey,
`the ${headerKey} header is present but the body carries a non-primitive ${pathName(decl.path)}`
);
}
const bodyString = mcpParamPrimitiveToString(bodyRaw);
if (bodyString === undefined) {
// Body carries a non-primitive where the schema declares one;
// params validation owns that fault. Skip the header check.
continue;
// No header form (unsafe integer): an absent header is expected, a present one cannot be verified.
if (headerValue === null) continue;
Comment thread
mattzcarey marked this conversation as resolved.
Resolved
return paramHeaderMismatchRejection(
'param-header-mismatch',
headerKey,
`the ${headerKey} header cannot be compared with ${pathName(decl.path)}=${JSON.stringify(bodyRaw)}, which has no header form`
);
}
if (headerValue === null) {
return paramHeaderMismatchRejection(
Expand Down
35 changes: 35 additions & 0 deletions packages/core-internal/test/shared/mcpParamHeaders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,41 @@ describe('validateMcpParamHeaders — server-behavior table', () => {
});
});

test('header present but the body value is an object or array → reject 400/-32020', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
const headers = new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: '5' });
for (const n of [{ a: 1 }, [5]]) {
expect(validateMcpParamHeaders(intDecl, { n }, headers)).toMatchObject({
kind: 'reject',
httpStatus: 400,
code: HEADER_MISMATCH_ERROR_CODE,
cell: 'param-header-mismatch'
});
}
});

test('header absent and the body value is an object or array → reject 400/-32020 as missing', () => {
for (const region of [{ a: 'eu' }, ['eu']]) {
expect(validateMcpParamHeaders(DECLS, { region }, new Headers())).toMatchObject({
kind: 'reject',
httpStatus: 400,
code: HEADER_MISMATCH_ERROR_CODE,
cell: 'param-header-missing'
});
}
});

test('unsafe integer body value: a present header is rejected, an absent one passes', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
const n = 2 ** 53;
expect(validateMcpParamHeaders(intDecl, { n }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: '5' }))).toMatchObject({
kind: 'reject',
code: HEADER_MISMATCH_ERROR_CODE,
cell: 'param-header-mismatch'
});
expect(validateMcpParamHeaders(intDecl, { n }, new Headers())).toBeUndefined();
});

test('invalid Base64 sentinel → reject 400/-32020', () => {
const r = validateMcpParamHeaders(
DECLS,
Expand Down
7 changes: 7 additions & 0 deletions packages/server/test/server/mcpParamValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ describe('SEP-2243 Mcp-Param-* server validation (createMcpHandler, modern era)'
expect(r2.status).toBe(200);
});

it('a present header with an object body value is rejected 400/-32020 before argument validation', async () => {
const handler = createMcpHandler(makeFactory());
const response = await handler.fetch(call({ region: { nested: 'us-west1' } }, { 'Mcp-Param-Region': 'us-west1' }));
expect(response.status).toBe(400);
expect(((await response.json()) as { error: { code: number } }).error.code).toBe(-32_020);
});

it('an invalid Base64 sentinel is rejected 400/-32020', async () => {
const handler = createMcpHandler(makeFactory());
const response = await handler.fetch(call({ region: 'Hello' }, { 'Mcp-Param-Region': '=?base64?SGVsbG8?=' }));
Expand Down
Loading