Skip to content

Validate Mcp-Param-* headers against the tool call body - #654

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:validate_mcp_param_headers_against_the_tool_call_body
Open

koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:validate_mcp_param_headers_against_the_tool_call_body

Conversation

@koic

@koic koic commented Nov 3, 2026

Copy link
Copy Markdown
Member

Motivation and Context

SEP-2243 (MCP 2026-07-28) lets a tool mark parameters with x-mcp-header so that clients mirror their values into Mcp-Param-{Name} request headers, which intermediaries route on without parsing bodies, and it has the server validate those headers against the body: a routing decision taken on a header the server does not act on is the vulnerability the spec names. The Ruby SDK sent the headers as a client but did not check them as a server, and its conformance server declared no such parameter, so the referee's http-custom-header-server-validation scenario reported its checks as untestable. The TypeScript, Python, Go, Rust, C#, and Java SDKs all validate on the server and arm a fixture tool.

StreamableHTTPTransport now checks a modern tools/call before it is dispatched: for each x-mcp-header parameter of the called tool, the Mcp-Param-{Name} header has to be present exactly when the body carries a mirrorable value for it and has to agree with it once a =?base64?...?= sentinel is decoded. A disagreement is refused with HTTP 400 and -32020 (HeaderMismatch), the shape the standard-header checks already use, naming the header and, except in the two encoding refusals, the argument path, but never a value. The codec moves from the client namespace to MCP::McpParamHeaders, shared by both sides as in the TypeScript and Python SDKs, and gains the decoding and the comparison.

Points settled by the spec, the reference SDKs, or the risk at hand:

  • Decoding is strict: a sentinel payload with wrong padding, a character outside the alphabet, non-zero trailing bits, or bytes that are not UTF-8 is refused rather than compared, so a sentinel has one spelling for a given value (the SEP-2243 test-case table; the TypeScript and Python decoders refuse the same).
  • A header without the sentinel that holds a byte outside the field-value set (visible ASCII, space, and tab) is refused as the spec's invalid characters instead of being compared byte for byte with the body. The TypeScript and Python servers do not check this, since a conforming client wraps such a value, but a server must not act on one either way.
  • A header whose argument is absent or null in the body is refused, as the Python SDK does: a conforming client omits such a header, and one sent anyway claims a value the handler never sees. The TypeScript SDK ignores it.
  • Integer and number parameters compare numerically when the header is a canonical decimal (42.0 agrees with 42, the spec's SHOULD), exactly for integers; anything else compares the decoded header with the body value's header rendering byte for byte. Only a numeral of at most 64 bytes is read as a number, which bounds the parsing a header can ask for; a longer one is judged by byte equality alone, so it still agrees with the rendering a client mirrors and with nothing else.
  • The scanner shared with the client also treats contentSchema as a position the properties chain cannot pass through, as the Python SDK does, so an annotation there invalidates the tool like one under items.
  • Headers for parameters without a declaration are ignored, as the spec asks of a server that does not recognize them; a tool whose declarations are invalid is not validated, since conforming clients drop it and send nothing for it; an unknown tool is left to the dispatcher's own refusal. The handshake lifecycle has no custom headers to check.
  • The check runs on the schema the server serves in tools/list, before the tool runs, so a refused request never reaches a handler.

The conformance server gains test_x_mcp_header, a tool with a region parameter mirrored into Mcp-Param-Region, as the TypeScript and Python fixtures have. With the server leg of the referee selecting every scenario, the scenario's baseline entry is dropped.

How Has This Been Tested?

test/mcp/mcp_param_headers_test.rb and test/mcp/server/transports/streamable_http_mcp_param_test.rb. Before the change, npx @modelcontextprotocol/conformance@alpha server --scenario http-custom-header-server-validation reported its four checks as untestable, since no tool declared an x-mcp-header parameter; it now passes 10/10.

Breaking Changes

None. MCP::Client::McpParamHeaders stays as an alias of MCP::McpParamHeaders, so code that referenced the old name keeps working, and requests of the handshake lifecycle are unaffected.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

## Motivation and Context

SEP-2243 (MCP 2026-07-28) lets a tool mark parameters with `x-mcp-header` so that clients mirror their values into
`Mcp-Param-{Name}` request headers, which intermediaries route on without parsing bodies, and it has the server validate
those headers against the body: a routing decision taken on a header the server does not act on is the vulnerability
the spec names. The Ruby SDK sent the headers as a client but did not check them as a server, and its conformance server
declared no such parameter, so the referee's `http-custom-header-server-validation` scenario reported its checks as untestable.
The TypeScript, Python, Go, Rust, C#, and Java SDKs all validate on the server and arm a fixture tool.

`StreamableHTTPTransport` now checks a modern `tools/call` before it is dispatched: for each `x-mcp-header` parameter of
the called tool, the `Mcp-Param-{Name}` header has to be present exactly when the body carries a mirrorable value for it
and has to agree with it once a `=?base64?...?=` sentinel is decoded. A disagreement is refused with HTTP 400 and
`-32020` (`HeaderMismatch`), the shape the standard-header checks already use, naming the header and,
except in the two encoding refusals, the argument path, but never a value. The codec moves from the client namespace to
`MCP::McpParamHeaders`, shared by both sides as in the TypeScript and Python SDKs, and gains the decoding and the comparison.

Points settled by the spec, the reference SDKs, or the risk at hand:

- Decoding is strict: a sentinel payload with wrong padding, a character outside the alphabet, non-zero trailing bits,
  or bytes that are not UTF-8 is refused rather than compared, so a sentinel has one spelling for a given value
  (the SEP-2243 test-case table; the TypeScript and Python decoders refuse the same).
- A header without the sentinel that holds a byte outside the field-value set (visible ASCII, space, and tab)
  is refused as the spec's invalid characters instead of being compared byte for byte with the body.
  The TypeScript and Python servers do not check this, since a conforming client wraps such a value,
  but a server must not act on one either way.
- A header whose argument is absent or `null` in the body is refused, as the Python SDK does: a conforming client
  omits such a header, and one sent anyway claims a value the handler never sees. The TypeScript SDK ignores it.
- Integer and number parameters compare numerically when the header is a canonical decimal (`42.0` agrees with `42`,
  the spec's SHOULD), exactly for integers; anything else compares the decoded header with the body value's header
  rendering byte for byte. Only a numeral of at most 64 bytes is read as a number, which bounds the parsing
  a header can ask for; a longer one is judged by byte equality alone, so it still agrees with the rendering
  a client mirrors and with nothing else.
- The scanner shared with the client also treats `contentSchema` as a position the `properties` chain cannot pass through,
  as the Python SDK does, so an annotation there invalidates the tool like one under `items`.
- Headers for parameters without a declaration are ignored, as the spec asks of a server that does not recognize them;
  a tool whose declarations are invalid is not validated, since conforming clients drop it and send nothing for it;
  an unknown tool is left to the dispatcher's own refusal. The handshake lifecycle has no custom headers to check.
- The check runs on the schema the server serves in `tools/list`, before the tool runs, so a refused request never reaches a handler.

The conformance server gains `test_x_mcp_header`, a tool with a `region` parameter mirrored into `Mcp-Param-Region`,
as the TypeScript and Python fixtures have. With the server leg of the referee selecting every scenario,
the scenario's baseline entry is dropped.

## How Has This Been Tested?

test/mcp/mcp_param_headers_test.rb and test/mcp/server/transports/streamable_http_mcp_param_test.rb. Before the change,
`npx @modelcontextprotocol/conformance@alpha server --scenario http-custom-header-server-validation` reported its four checks as untestable,
since no tool declared an `x-mcp-header` parameter; it now passes 10/10.

## Breaking Changes

None. `MCP::Client::McpParamHeaders` stays as an alias of `MCP::McpParamHeaders`, so code that referenced the old name keeps working,
and requests of the handshake lifecycle are unaffected.
@koic
koic force-pushed the validate_mcp_param_headers_against_the_tool_call_body branch from 12aea98 to 5d54cee Compare November 3, 2026 16:33

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant