Skip to content

Commit 93fc2ee

Browse files
committed
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
1 parent a4f4ccd commit 93fc2ee

2 files changed

Lines changed: 36 additions & 6 deletions

File tree

src/mcp/shared/inbound.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -385,9 +385,10 @@ def classify_inbound_request(
385385
2. When `headers` is given, `MCP-Protocol-Version` equals the envelope's
386386
protocol version, `Mcp-Method` equals `body.method`, and — for the
387387
methods in :data:`NAME_BEARING_METHODS` — `Mcp-Name` equals the named
388-
body param → else :data:`~mcp_types.jsonrpc.HEADER_MISMATCH`. Runs
389-
before the supported-version rung so a client that disagrees with itself
390-
is told so, rather than told the body's version is unsupported.
388+
body param, and is absent when that param is → else
389+
:data:`~mcp_types.jsonrpc.HEADER_MISMATCH`. Runs before the
390+
supported-version rung so a client that disagrees with itself is told
391+
so, rather than told the body's version is unsupported.
391392
3. The envelope's protocol version is a string in
392393
`supported_modern_versions` → non-string values are
393394
:data:`~mcp_types.jsonrpc.INVALID_PARAMS` (a shape defect, not a
@@ -444,7 +445,21 @@ def classify_inbound_request(
444445
if name_key is not None:
445446
# Rung 1 already proved body["params"] is a mapping (its `_meta` is one).
446447
body_value = cast("Mapping[str, Any]", body["params"]).get(name_key)
447-
if body_value is not None and decode_header_value(headers.get(MCP_NAME_HEADER)) != body_value:
448+
name_header = headers.get(MCP_NAME_HEADER)
449+
if body_value is None:
450+
# An orphan header claiming a route the body never carried is the
451+
# same spoofing risk validate_mcp_param_headers rejects for
452+
# Mcp-Param-*: a conforming client never emits Mcp-Name unless the
453+
# named param is present (see `emit`/matching_headers), so a header
454+
# here with no matching body value did not come from this request's
455+
# own body. The param's absence is INVALID_PARAMS elsewhere; that is
456+
# orthogonal to whether a present header is trustworthy.
457+
if name_header is not None:
458+
return InboundLadderRejection(
459+
code=HEADER_MISMATCH,
460+
message=f"{MCP_NAME_HEADER} header is present but the body's {name_key!r} parameter is absent",
461+
)
462+
elif decode_header_value(name_header) != body_value:
448463
return InboundLadderRejection(
449464
code=HEADER_MISMATCH,
450465
message=f"{MCP_NAME_HEADER} header does not match the request body's {name_key!r} parameter",

tests/shared/test_inbound.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,13 +311,28 @@ def test_header_rung_does_not_require_name_header_for_non_name_bearing_method()
311311

312312

313313
def test_header_rung_does_not_require_name_header_when_body_omits_the_named_param() -> None:
314-
"""SDK-defined: a name-bearing method whose body lacks the named param skips the `Mcp-Name`
315-
check — the param's absence is INVALID_PARAMS later, not HEADER_MISMATCH here."""
314+
"""SDK-defined: a name-bearing method whose body lacks the named param, and whose headers
315+
carry no `Mcp-Name` either, skips the check — the param's absence is INVALID_PARAMS later,
316+
not HEADER_MISMATCH here."""
316317
body = envelope("tools/call")
317318
result = classify_inbound_request(body, headers=matching_headers(body))
318319
assert isinstance(result, InboundModernRoute)
319320

320321

322+
@pytest.mark.parametrize(
323+
("method", "name_key"),
324+
[(m, k) for m, k in NAME_BEARING_METHODS.items()],
325+
)
326+
def test_header_rung_rejects_orphan_name_header_when_body_omits_the_named_param(method: str, name_key: str) -> None:
327+
"""Regression for the asymmetry with `validate_mcp_param_headers`: a `Mcp-Name` header
328+
claiming a route the body never carried is a spoofing risk, not a value with nothing to
329+
compare against — mirrors the `Mcp-Param-*` "header present but argument absent" rejection.
330+
"""
331+
body = envelope(method)
332+
headers = matching_headers(body) | {MCP_NAME_HEADER: encode_header_value("someone-elses-tool")}
333+
assert_rejected(classify_inbound_request(body, headers=headers), HEADER_MISMATCH)
334+
335+
321336
# --- all rungs pass ------------------------------------------------------------
322337

323338

0 commit comments

Comments
 (0)