From 93fc2ee227b31502e810568c67577bc28215a445 Mon Sep 17 00:00:00 2001 From: hamodywe Date: Sat, 8 Aug 2026 17:42:30 +0300 Subject: [PATCH] Reject an orphan Mcp-Name header when the body omits the named param classify_inbound_request's Mcp-Name check only ran when the body carried the name-bearing method's param (name_key). When the body omitted it, a present Mcp-Name header went unvalidated entirely -- an intermediary or client could set it to claim a different tool/prompt/resource than the request body actually names, with no rejection. validate_mcp_param_headers already treats this shape -- a header present with no matching body value -- as a rejection for Mcp-Param-* headers, on the same reasoning: a conforming client never emits the header unless the body value is present, so a header with nothing to match against did not come from this request. Mcp-Name had no equivalent check. Mirrors that handling: when the named param is absent, an absent header still passes (the param's own absence is INVALID_PARAMS elsewhere, not this rung's concern), but a present header is now rejected HEADER_MISMATCH. Adds test_header_rung_rejects_orphan_name_header_when_body_omits_the_named_param, parametrized over all of NAME_BEARING_METHODS. Confirmed it fails against the unpatched code (reverting just inbound.py, keeping the test) and passes with the fix. AI assistance disclosure: I used Claude to help investigate this issue and implement/test the fix; I've reviewed the change and reasoning above and can answer questions about it. Fixes #3269 --- src/mcp/shared/inbound.py | 23 +++++++++++++++++++---- tests/shared/test_inbound.py | 19 +++++++++++++++++-- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/mcp/shared/inbound.py b/src/mcp/shared/inbound.py index c28aa7fb71..0cf9981c0c 100644 --- a/src/mcp/shared/inbound.py +++ b/src/mcp/shared/inbound.py @@ -385,9 +385,10 @@ def classify_inbound_request( 2. When `headers` is given, `MCP-Protocol-Version` equals the envelope's protocol version, `Mcp-Method` equals `body.method`, and — for the methods in :data:`NAME_BEARING_METHODS` — `Mcp-Name` equals the named - body param → else :data:`~mcp_types.jsonrpc.HEADER_MISMATCH`. Runs - before the supported-version rung so a client that disagrees with itself - is told so, rather than told the body's version is unsupported. + body param, and is absent when that param is → else + :data:`~mcp_types.jsonrpc.HEADER_MISMATCH`. Runs before the + supported-version rung so a client that disagrees with itself is told + so, rather than told the body's version is unsupported. 3. The envelope's protocol version is a string in `supported_modern_versions` → non-string values are :data:`~mcp_types.jsonrpc.INVALID_PARAMS` (a shape defect, not a @@ -444,7 +445,21 @@ def classify_inbound_request( if name_key is not None: # Rung 1 already proved body["params"] is a mapping (its `_meta` is one). 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: + name_header = headers.get(MCP_NAME_HEADER) + if body_value is None: + # An orphan header claiming a route the body never carried is the + # same spoofing risk validate_mcp_param_headers rejects for + # Mcp-Param-*: a conforming client never emits Mcp-Name unless the + # named param is present (see `emit`/matching_headers), so a header + # here with no matching body value did not come from this request's + # own body. The param's absence is INVALID_PARAMS elsewhere; that is + # orthogonal to whether a present header is trustworthy. + if name_header is not None: + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{MCP_NAME_HEADER} header is present but the body's {name_key!r} parameter is absent", + ) + elif decode_header_value(name_header) != body_value: return InboundLadderRejection( code=HEADER_MISMATCH, message=f"{MCP_NAME_HEADER} header does not match the request body's {name_key!r} parameter", diff --git a/tests/shared/test_inbound.py b/tests/shared/test_inbound.py index 7712e73e5e..0f352a94ac 100644 --- a/tests/shared/test_inbound.py +++ b/tests/shared/test_inbound.py @@ -311,13 +311,28 @@ def test_header_rung_does_not_require_name_header_for_non_name_bearing_method() def test_header_rung_does_not_require_name_header_when_body_omits_the_named_param() -> None: - """SDK-defined: a name-bearing method whose body lacks the named param skips the `Mcp-Name` - check — the param's absence is INVALID_PARAMS later, not HEADER_MISMATCH here.""" + """SDK-defined: a name-bearing method whose body lacks the named param, and whose headers + carry no `Mcp-Name` either, skips the check — the param's absence is INVALID_PARAMS later, + not HEADER_MISMATCH here.""" body = envelope("tools/call") result = classify_inbound_request(body, headers=matching_headers(body)) assert isinstance(result, InboundModernRoute) +@pytest.mark.parametrize( + ("method", "name_key"), + [(m, k) for m, k in NAME_BEARING_METHODS.items()], +) +def test_header_rung_rejects_orphan_name_header_when_body_omits_the_named_param(method: str, name_key: str) -> None: + """Regression for the asymmetry with `validate_mcp_param_headers`: a `Mcp-Name` header + claiming a route the body never carried is a spoofing risk, not a value with nothing to + compare against — mirrors the `Mcp-Param-*` "header present but argument absent" rejection. + """ + body = envelope(method) + headers = matching_headers(body) | {MCP_NAME_HEADER: encode_header_value("someone-elses-tool")} + assert_rejected(classify_inbound_request(body, headers=headers), HEADER_MISMATCH) + + # --- all rungs pass ------------------------------------------------------------