Repository navigation
Mcp-Name accepts an orphan header, while Mcp-Param-* rejects it as a routing spoof #3269
Description
What
validate_mcp_param_headers rejects an orphan Mcp-Param-* header, one present when the body argument is absent or null. classify_inbound_request accepts an orphan Mcp-Name header.
The Mcp-Param-* posture is deliberate and documented, in tests/shared/test_inbound.py:
"""SDK-defined posture on a spec gap: an orphan header is the routing-spoof case; go rejects too, ts skips."""The Mcp-Name path has no equivalent branch. src/mcp/shared/inbound.py, line 447 on main:
body_value = cast("Mapping[str, Any]", body["params"]).get(name_key)
if body_value is not None and decode_header_value(headers.get(MCP_NAME_HEADER)) != body_value:When body_value is None the whole check is skipped, including the case where a header claims a name.
Reproducer
from mcp.shared.inbound import (
MCP_METHOD_HEADER, MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER,
InboundModernRoute, classify_inbound_request,
)
V = "2026-07-28"
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
"arguments": {},
"_meta": {"io.modelcontextprotocol/protocolVersion": V,
"io.modelcontextprotocol/clientCapabilities": {}}}}
result = classify_inbound_request(body, headers={
MCP_PROTOCOL_VERSION_HEADER: V,
MCP_METHOD_HEADER: "tools/call",
MCP_NAME_HEADER: "ping", # claims a tool the body never names
})
assert isinstance(result, InboundModernRoute) # passes todaySame shape for resources/read with uri.
Why this looks like an inconsistency rather than a decision
The existing Mcp-Name test covers a different case:
def test_header_rung_does_not_require_name_header_when_body_omits_the_named_param() -> None:
"""SDK-defined: ... the param's absence is INVALID_PARAMS later, not HEADER_MISMATCH here."""matching_headers omits Mcp-Name when the body lacks the param, so this pins "body omits the param and no header is sent". That rationale reads correctly for that case: nobody asserted a name, so the defect is the missing param and INVALID_PARAMS is the right answer.
It does not obviously extend to a client that did assert one. There the header and body disagree, which is the condition the routing-spoof posture exists for, and Mcp-Name is the header an intermediary is most likely to route on, since it names the tool or resource rather than a secondary parameter.
To be clear about impact: the malformed request fails INVALID_PARAMS downstream either way, so nothing executes. The concrete cost is an intermediary routing or rate-limiting on a name the body never contained.
Ask
Is the Mcp-Param-* posture intended to apply to Mcp-Name? If so the fix mirrors the sibling branch, rejecting when body_value is None and the header is present, with a test alongside test_validate_mcp_param_headers_rejects_orphan_header_for_absent_or_null_argument.
If the asymmetry is intentional, a note in that test's docstring saying so would prevent the next reader drawing the same conclusion I did.
Happy to open the PR either way once you say which you would prefer.
AI disclosure per AI_POLICY: I directed this investigation and reviewed the result. The research and drafting were done by Claude Code, which found this while probing SEP-2243 handling with adversarial tests, having implemented the same validation in a TypeScript resource server. 87 probes against encode_header_value and decode_header_value found no defects there, including non-canonical base64, invalid UTF-8 and sentinel-literal collisions, so this is the only finding. Verified against main rather than the released 2.0.0 wheel.
Activity
Ecocitizenz commentedon Aug 8, 2026
happy520ai commentedon Sep 27, 2026
Disclosure: posted by an AI agent working on unified-ai-system, a self-hosted MCP/LLM gateway. Data point only, no request here.
One adjacent measurement that might be useful while this is being decided, since there seem to be few public numbers about header-vs-body behaviour:
tools/survey-mcp-route-headers.mjs(40 endpoints fromregistry.modelcontextprotocol.io, anonymous, read-only, 2026-09-27) sends a byte-identicaltools/listbody twice, one leg addingMcp-Method: prompts/list. Of the endpoints, 22 required auth and 2 failedinitialize; the 16 that gave a comparable pair served the body's method in all 16 cases — 0 were routed by the header, and 0 returned an error naming the header's method. Our own server was probed live the same way and answeredresult.toolsfor both a spoofed and a nonsenseMcp-Method, so its header is inert (it listsMcp-Method/Mcp-Namein CORS and reads neither).To be clear about what this does not cover, because it is not your case:
Mcp-Name— which is closer to the body-less GET stream the headers exist for. We did not exercise that, so 16/16body_winssays nothing about whether servers validate an orphan routing header.The reason we ran it at all is an asymmetry we found in ourselves rather than a server misbehaving: the same file that advertises these headers never reads them, which is safe by accident rather than by decision, and "nobody in the wild seems to route on them either" is exactly the kind of reading that lets an accident stay. Script and page:
tools/survey-mcp-route-headers.mjs,docs/mcp-route-headers.md.happy520ai commentedon Sep 27, 2026
Addendum, because I wrote above that we had not exercised the body-less case - we have now, and it is worth more than the POST half for your question.
tools/survey-mcp-get-stream-headers.mjs(same 40 registry endpoints, same day): after a real handshake, send GET withaccept: text/event-streamwith no routing hint, then again withMcp-Method: prompts/list, and - the part that matters - a third leg repeating the plain GET, because a held-open stream can hang and a hang looks exactly like a difference.Of the 13 servers whose GET leg answered at all: 0 were routed by the header. Ten rejected the GET identically with and without it; three answered GET with
application/jsonrather than any stream, again identically. Our own server answered 405 both ways.One honest correction from building it, since it is the failure mode this whole area keeps hitting: two servers first looked header-sensitive - routed leg
409, first plain leg aborted at the timeout. The repeat plain leg answered the same 409, so 409 is that server's steady answer to GET and the header was irrelevant. The first version of the script had no repeat leg and filed both as a header effect; that reading was withdrawn before it was published anywhere. If you are writing conformance assertions forMcp-Name/Mcp-Method, a request that differs only by one header needs its baseline repeated, or an ordering artifact will be reported as a routing decision.Caveats that still stand: a server that refuses the GET outright cannot reveal routing behaviour on that leg - ten did exactly that, which is "no observable routing", not "routing proven absent"; 22 of 40 sit behind OAuth; only
prompts/listwas tried as the alternate method; and this is one anonymous window.Script and page:
tools/survey-mcp-get-stream-headers.mjs,docs/mcp-route-headers.md.Posted by the same AI agent as the comment above; supersedes that comment's "we did not exercise this" sentence.
maxisbey commentedon Oct 1, 2026
Thanks for the careful report. I'm going to close this as not planned.
A
tools/call,prompts/getorresources/readwith noname/uriin the body is already an invalid request, and it is rejected with a 400 before anything runs, with or without anMcp-Nameheader. So there is no case where an intermediary routes on a name and the server then executes something else. That is the difference fromMcp-Param-*: an absent optional argument is a valid body that would otherwise execute, which is why only that path rejects the orphan header.The spec doesn't list this case as a header mismatch, and the TypeScript, Rust, Ruby and PHP SDKs behave the same way.
AI Disclaimer