Repository navigation
Conversation
## 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
force-pushed
the
validate_mcp_param_headers_against_the_tool_call_body
branch
from
November 3, 2026 16:33
12aea98 to
5d54cee
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation and Context
SEP-2243 (MCP 2026-07-28) lets a tool mark parameters with
x-mcp-headerso that clients mirror their values intoMcp-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'shttp-custom-header-server-validationscenario reported its checks as untestable. The TypeScript, Python, Go, Rust, C#, and Java SDKs all validate on the server and arm a fixture tool.StreamableHTTPTransportnow checks a moderntools/callbefore it is dispatched: for eachx-mcp-headerparameter of the called tool, theMcp-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 toMCP::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:
nullin 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.42.0agrees with42, 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.contentSchemaas a position thepropertieschain cannot pass through, as the Python SDK does, so an annotation there invalidates the tool like one underitems.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 aregionparameter mirrored intoMcp-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-validationreported its four checks as untestable, since no tool declared anx-mcp-headerparameter; it now passes 10/10.Breaking Changes
None.
MCP::Client::McpParamHeadersstays as an alias ofMCP::McpParamHeaders, so code that referenced the old name keeps working, and requests of the handshake lifecycle are unaffected.Types of changes
Checklist