Skip to content

Commit 58b4cc1

Browse files
committed
Harden Mcp-Param validation per review: rendering as one fact, duplicate routing headers, parse-limit 500s
- A value's header rendering (or its absence) is now a single shared fact (_render_header_scalar returns None for non-primitives and integers beyond the int-to-str digit limit): the client omits such headers, the validator treats a header claiming such a value as a mismatch, and a huge-integer body can no longer raise out of the public validator. - A non-canonical-decimal header for an integer declaration falls back to the rendered comparison instead of rejecting outright, so the client's own scientific-notation rendering of large integral floats (str(1e16) == '1e+16') round-trips against this server. - Duplicated MCP-Protocol-Version / Mcp-Method / Mcp-Name raw header lines are rejected before classification (find_duplicated_routing_header) - the same first-wins/last-wins divergence the Mcp-Param duplicate rejection closes, where it matters most. - The synthetic listing's fail-open boundary now covers the result scan (a middleware short-circuiting tools/list with a mis-shaped result is a logged skip, not a 500), and a mis-shaped envelope failing the listing's surface validation logs at debug as client fault instead of an error-level traceback blaming the tools/list handler. - json.loads failures are caught as ValueError (an integer literal beyond the digit limit raises the bare parent, not JSONDecodeError), keeping unparseable bodies at 400 + PARSE_ERROR. - migration.md: handler-raise skip is logged as an error, not a warning; document the omitted-unrenderable-value and duplicate-line rules.
1 parent 7271553 commit 58b4cc1

5 files changed

Lines changed: 223 additions & 39 deletions

File tree

docs/migration.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -425,13 +425,13 @@ On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return th
425425

426426
### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))
427427

428-
For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an `x-mcp-header` annotation. When a tool the client has listed is called, each annotated argument is mirrored into an `Mcp-Param-<name>` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation.
428+
For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an `x-mcp-header` annotation. When a tool the client has listed is called, each annotated argument is mirrored into an `Mcp-Param-<name>` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments — and values with no scalar rendering, such as objects or arrays — are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation.
429429

430430
### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))
431431

432432
The server half of the same contract: on the 2026-07-28 Streamable HTTP path, a `tools/call` whose tool declares `x-mcp-header` annotations is validated before dispatch — each annotated argument and its mirroring `Mcp-Param-*` header must be present together and agree (after base64-sentinel decoding; integers compare numerically), or absent together. A violation is rejected with HTTP 400 and JSON-RPC error `-32020` (`HeaderMismatch`), as the spec requires. A client that sends an annotated argument *without* its header — for example one that never listed the tool — is therefore rejected instead of silently served; the spec's recovery is to re-list and retry.
433433

434-
There is nothing to configure. The server resolves the called tool's schema through its own registered `tools/list` handler (for `MCPServer`, the built-in one), so the validated catalog is exactly what that caller would be shown. Two consequences worth knowing: the listing runs internally on validated calls, so middleware and an expensive or paginated `tools/list` handler see extra invocations; and validation is skipped — never failing the call — when no `tools/list` handler is registered, the tool isn't in the listing, the handler raises (logged as a warning), or the call has no arguments and no `Mcp-Param-*` headers. Headers with no matching annotation are ignored; a recognized header supplied more than once is rejected. The codec and validator are public in `mcp.shared.inbound` (`decode_header_value`, `validate_mcp_param_headers`) for low-level servers hosting their own HTTP entry.
434+
There is nothing to configure. The server resolves the called tool's schema through its own registered `tools/list` handler (for `MCPServer`, the built-in one), so the validated catalog is exactly what that caller would be shown. Two consequences worth knowing: the listing runs internally on validated calls, so middleware and an expensive or paginated `tools/list` handler see extra invocations; and validation is skipped — never failing the call — when no `tools/list` handler is registered, the tool isn't in the listing, the handler raises (logged as an error), or the call has no arguments and no `Mcp-Param-*` headers. Headers with no matching annotation are ignored; a recognized header supplied more than once is rejected, as is a duplicated `MCP-Protocol-Version`, `Mcp-Method`, or `Mcp-Name` line. The codec and validator are public in `mcp.shared.inbound` (`decode_header_value`, `validate_mcp_param_headers`) for low-level servers hosting their own HTTP entry.
435435

436436
Base64-sentinel decoding is strict everywhere it applies, including the `Mcp-Name` header: a `=?base64?...?=` value whose payload is not canonical base64 (wrong padding, stray characters, non-zero trailing bits) or not valid UTF-8 is rejected as malformed rather than leniently decoded.
437437

src/mcp/server/_streamable_http_modern.py

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from mcp_types import (
3030
CLIENT_CAPABILITIES_META_KEY,
3131
CLIENT_INFO_META_KEY,
32+
HEADER_MISMATCH,
3233
INTERNAL_ERROR,
3334
INVALID_REQUEST,
3435
PARSE_ERROR,
@@ -60,6 +61,7 @@
6061
InboundLadderRejection,
6162
InboundModernRoute,
6263
classify_inbound_request,
64+
find_duplicated_routing_header,
6365
validate_mcp_param_headers,
6466
)
6567
from mcp.shared.jsonrpc_dispatcher import handler_exception_to_error_data, progress_token_from_params
@@ -264,17 +266,27 @@ async def _tool_input_schema(
264266
result = await serve_one(
265267
app, dctx, "tools/list", list_params, connection=connection, lifespan_state=lifespan_state
266268
)
269+
for tool in result.get("tools", []):
270+
if tool.get("name") == name:
271+
return tool.get("inputSchema")
272+
cursor = result.get("nextCursor")
273+
except ValidationError:
274+
# Client fault: a mis-shaped envelope value the classifier admits
275+
# on key presence alone fails the kernel's surface validation here
276+
# first. The real dispatch produces the INVALID_PARAMS reply, so
277+
# this is not worth more than a debug line (an exception-level log
278+
# would let any client flood the log blaming the server).
279+
logger.debug("Mcp-Param header validation skipped: the request envelope fails tools/list validation")
280+
return None
267281
except Exception:
268282
# Boundary by design: header validation must never break a working
269-
# call path, so a raising listing skips validation for this request
270-
# — loudly, because the skip is fail-open. (A server broken here is
271-
# broken for real discovery too.)
272-
logger.exception("Mcp-Param header validation skipped: the tools/list handler raised")
283+
# call path, so a failing listing — the handler raising, or a
284+
# middleware short-circuit returning a mis-shaped result — skips
285+
# validation for this request. Loudly, because the skip is
286+
# fail-open. (A server broken here is broken for real discovery
287+
# too.)
288+
logger.exception("Mcp-Param header validation skipped: the tools/list listing failed")
273289
return None
274-
for tool in result.get("tools", []):
275-
if tool.get("name") == name:
276-
return tool.get("inputSchema")
277-
cursor = result.get("nextCursor")
278290
if not isinstance(cursor, str):
279291
# Listing exhausted without advertising `name`: nothing was
280292
# declared to this caller, so there is nothing to validate —
@@ -369,7 +381,10 @@ async def handle_modern_request(
369381
body = await request.body()
370382
try:
371383
decoded = json.loads(body)
372-
except json.JSONDecodeError:
384+
except ValueError:
385+
# ValueError, not its JSONDecodeError subclass: an integer literal
386+
# beyond CPython's int-conversion digit limit raises the bare parent
387+
# and is just as unparseable.
373388
rej = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error"))
374389
await _write(rej, scope, receive, send)
375390
return
@@ -391,6 +406,16 @@ async def handle_modern_request(
391406
await _write(rej, scope, receive, send)
392407
return
393408

409+
duplicated = find_duplicated_routing_header(request.headers.items())
410+
if duplicated is not None:
411+
# The classifier receives a folded mapping that can no longer show
412+
# duplicates, so the raw-carrier check lives here: a routing header
413+
# supplied twice is unverifiable (a consumer reading one copy and the
414+
# validator the other would disagree).
415+
rejection = InboundLadderRejection(code=HEADER_MISMATCH, message=f"{duplicated} header appears more than once")
416+
await _write_rejection(rejection, req.id, scope, receive, send)
417+
return
418+
394419
verdict = classify_inbound_request(decoded, headers=dict(request.headers))
395420
if isinstance(verdict, InboundLadderRejection):
396421
await _write_rejection(verdict, req.id, scope, receive, send)

src/mcp/shared/inbound.py

Lines changed: 75 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import base64
1515
import binascii
1616
import re
17-
from collections.abc import Iterator, Mapping, Sequence
17+
from collections.abc import Iterable, Iterator, Mapping, Sequence
1818
from dataclasses import dataclass
1919
from types import MappingProxyType
2020
from typing import Any, Final, cast
@@ -49,6 +49,7 @@
4949
"classify_inbound_request",
5050
"decode_header_value",
5151
"encode_header_value",
52+
"find_duplicated_routing_header",
5253
"find_invalid_x_mcp_header",
5354
"mcp_param_headers",
5455
"validate_mcp_param_headers",
@@ -260,14 +261,26 @@ def _annotated_positions(input_schema: Any) -> Iterator[tuple[tuple[str, ...], s
260261
yield path, token, schema
261262

262263

263-
def _render_header_scalar(value: Any) -> str:
264-
"""Render an argument value the way the client mirrors it into a header.
264+
def _render_header_scalar(value: Any) -> str | None:
265+
"""Render an argument value the way the client mirrors it into a header, or `None`
266+
when no header rendering exists.
265267
266-
Shared by emit (:func:`mcp_param_headers`) and validate
267-
(:func:`validate_mcp_param_headers`) so the comparison is the exact
268-
inverse of the rendering by construction.
268+
The single source of the "mirrorable value" fact, shared by emit
269+
(:func:`mcp_param_headers`, which omits the header) and validate
270+
(:func:`validate_mcp_param_headers`, for which a header claiming an
271+
unmirrorable value can never match) so the two sides agree by
272+
construction. Unmirrorable: non-primitive values — the spec defines
273+
rendering for string/integer/boolean only — and integers beyond CPython's
274+
int-to-str conversion limit, which no JSON peer can express anyway.
269275
"""
270-
return ("true" if value else "false") if isinstance(value, bool) else str(value)
276+
if isinstance(value, bool):
277+
return "true" if value else "false"
278+
if not isinstance(value, str | int | float):
279+
return None
280+
try:
281+
return str(value)
282+
except ValueError:
283+
return None
271284

272285

273286
def mcp_param_headers(header_map: Mapping[tuple[str, ...], str], arguments: Mapping[str, Any]) -> dict[str, str]:
@@ -278,14 +291,15 @@ def mcp_param_headers(header_map: Mapping[tuple[str, ...], str], arguments: Mapp
278291
`Mcp-Param-<token>` carrying it: `bool` as `true`/`false`, other scalars via
279292
`str`, each passed through :func:`encode_header_value` so a non-token value
280293
is base64-wrapped. A path that hits a missing key or a non-mapping node is
281-
skipped, matching the spec's "omit the header when no value is present".
294+
skipped, matching the spec's "omit the header when no value is present" —
295+
as is a value with no header rendering (see :func:`_render_header_scalar`).
282296
"""
283297
headers: dict[str, str] = {}
284298
for path, token in header_map.items():
285299
value = _value_at_path(arguments, path)
286-
if value is None:
300+
if value is None or (rendered := _render_header_scalar(value)) is None:
287301
continue
288-
headers[f"{MCP_PARAM_HEADER_PREFIX}{token}"] = encode_header_value(_render_header_scalar(value))
302+
headers[f"{MCP_PARAM_HEADER_PREFIX}{token}"] = encode_header_value(rendered)
289303
return headers
290304

291305

@@ -343,6 +357,33 @@ class InboundLadderRejection:
343357
data: Any = None
344358

345359

360+
_ROUTING_HEADER_NAMES: Final = frozenset({MCP_PROTOCOL_VERSION_HEADER, MCP_METHOD_HEADER, MCP_NAME_HEADER})
361+
362+
363+
def find_duplicated_routing_header(headers: Iterable[tuple[str, str]]) -> str | None:
364+
"""Name of a routing header supplied more than once in raw header lines, or `None`.
365+
366+
`headers` is the carrier's raw `(name, value)` pairs (e.g.
367+
`request.headers.items()`), which — unlike a folded mapping — still shows
368+
duplicates. The routing headers (`MCP-Protocol-Version`, `Mcp-Method`,
369+
`Mcp-Name`) are always recognized, so a duplicate is always unverifiable:
370+
a consumer reading one copy and a validator reading the other would
371+
disagree, the divergence header validation exists to prevent. Callers
372+
reject with `HEADER_MISMATCH` before running the validation ladder.
373+
`Mcp-Param-*` duplicates are not this function's job:
374+
:func:`validate_mcp_param_headers` rejects recognized ones and the spec's
375+
forward-and-ignore rule covers unrecognized ones.
376+
"""
377+
seen: set[str] = set()
378+
for name, _ in headers:
379+
key = name.lower()
380+
if key in _ROUTING_HEADER_NAMES:
381+
if key in seen:
382+
return key
383+
seen.add(key)
384+
return None
385+
386+
346387
def classify_inbound_request(
347388
body: Mapping[str, Any],
348389
*,
@@ -434,31 +475,35 @@ def classify_inbound_request(
434475
_CANONICAL_DECIMAL = re.compile(r"^-?[0-9]+(\.[0-9]+)?$")
435476

436477

437-
def _mcp_param_value_matches(prop_type: Any, value: Any, decoded: str) -> bool:
478+
def _mcp_param_value_matches(prop_type: Any, value: Any, rendered: str, decoded: str) -> bool:
438479
"""True when a decoded `Mcp-Param-*` header value agrees with the body argument.
439480
440-
The exact inverse of :func:`_render_header_scalar`: booleans compare
441-
against `true`/`false`; an integer-typed declaration with an integral body
442-
value (`int`, or a `float` like `42.0` that JSON Schema also admits as an
443-
integer) compares numerically (`42` matches `42.0`, the spec's SHOULD) but
444-
only for canonical-decimal headers, and exactly — no float round-trip, so
445-
values outside the IEEE754 safe range still compare correctly. A header
446-
whose integer part overflows CPython's int-conversion digit limit can
447-
never name a JSON-expressible value, so it simply does not match.
481+
`rendered` is the argument's own header rendering, so the fallback
482+
comparison is the exact inverse of the emit side by construction. An
483+
integer-typed declaration with an integral body value (`int`, or a `float`
484+
like `42.0` that JSON Schema also admits as an integer; `bool` excluded —
485+
it renders `true`/`false`) compares numerically (`42` matches `42.0`, the
486+
spec's SHOULD) but only for canonical-decimal headers, and exactly — no
487+
float round-trip, so values outside the IEEE754 safe range still compare
488+
correctly. A non-canonical header (e.g. scientific notation) falls back to
489+
the rendered comparison, so the client's own rendering of any value always
490+
matches; a header whose integer part overflows CPython's int-conversion
491+
digit limit can never name a JSON-expressible value, so it does not match.
448492
"""
449-
if isinstance(value, bool):
450-
return decoded == _render_header_scalar(value)
451-
if prop_type == "integer" and (isinstance(value, int) or (isinstance(value, float) and value.is_integer())):
452-
if _CANONICAL_DECIMAL.fullmatch(decoded) is None:
453-
return False
493+
if (
494+
prop_type == "integer"
495+
and not isinstance(value, bool)
496+
and (isinstance(value, int) or (isinstance(value, float) and value.is_integer()))
497+
and _CANONICAL_DECIMAL.fullmatch(decoded) is not None
498+
):
454499
whole, _, fraction = decoded.partition(".")
455500
if fraction and set(fraction) != {"0"}:
456501
return False
457502
try:
458503
return int(whole) == int(value)
459504
except ValueError:
460505
return False
461-
return decoded == _render_header_scalar(value)
506+
return decoded == rendered
462507

463508

464509
def validate_mcp_param_headers(
@@ -519,7 +564,10 @@ def validate_mcp_param_headers(
519564
message=f"{header_name} header is present but the request body's {argument!r} argument is absent",
520565
)
521566
continue
522-
if not isinstance(value, str | int | float):
567+
rendered = _render_header_scalar(value)
568+
if rendered is None:
569+
# No header rendering exists for this value, so a conforming
570+
# client omitted the header; one claiming it can never match.
523571
if raw is not None:
524572
return InboundLadderRejection(
525573
code=HEADER_MISMATCH,
@@ -537,7 +585,7 @@ def validate_mcp_param_headers(
537585
code=HEADER_MISMATCH,
538586
message=f"{header_name} header carries a malformed base64 sentinel value",
539587
)
540-
if not _mcp_param_value_matches(schema.get("type"), value, decoded):
588+
if not _mcp_param_value_matches(schema.get("type"), value, rendered, decoded):
541589
return InboundLadderRejection(
542590
code=HEADER_MISMATCH,
543591
message=f"{header_name} header does not match the request body's {argument!r} argument",

0 commit comments

Comments
 (0)