Validate Mcp-Param-* headers server-side on the 2026-07-28 HTTP path (SEP-2243) (#3033)

This commit is contained in:
Max 2026-06-30 21:39:32 +01:00 • committed by GitHub
commit 48ef569f7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 971 additions and 37 deletions

View file

@ -22,14 +22,18 @@ import json
import logging
from collections.abc import Awaitable, Mapping
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Final, TypeVar
from typing import TYPE_CHECKING, Any, Final, TypeVar, cast
import anyio
from anyio.streams.memory import MemoryObjectSendStream
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
HEADER_MISMATCH,
INTERNAL_ERROR,
INVALID_REQUEST,
PARSE_ERROR,
PROTOCOL_VERSION_META_KEY,
ClientCapabilities,
ErrorData,
Implementation,
@ -40,6 +44,7 @@ from mcp_types import (
ProgressToken,
RequestId,
)
from mcp_types import methods as _methods
from pydantic import BaseModel, ValidationError
from starlette.requests import Request
from starlette.responses import Response
@ -53,8 +58,12 @@ from mcp.shared.dispatcher import CallOptions
from mcp.shared.exceptions import NoBackChannelError
from mcp.shared.inbound import (
ERROR_CODE_HTTP_STATUS,
MCP_PARAM_HEADER_PREFIX,
InboundLadderRejection,
InboundModernRoute,
classify_inbound_request,
find_duplicated_routing_header,
validate_mcp_param_headers,
)
from mcp.shared.jsonrpc_dispatcher import handler_exception_to_error_data, progress_token_from_params
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
@ -172,6 +181,22 @@ def _sse_event(msg: JSONRPCResponse | JSONRPCError | JSONRPCNotification) -> byt
return f"event: message\r\ndata: {data}\r\n\r\n".encode()
async def _write_rejection(
rejection: InboundLadderRejection,
request_id: RequestId,
scope: Scope,
receive: Receive,
send: Send,
) -> None:
"""Send a ladder rejection as its JSON-RPC error with the table-mapped HTTP status."""
rej = JSONRPCError(
jsonrpc="2.0",
id=request_id,
error=ErrorData(code=rejection.code, message=rejection.message, data=rejection.data),
)
await _write(rej, scope, receive, send)
async def _write(
msg: JSONRPCResponse | JSONRPCError,
scope: Scope,
@ -192,6 +217,111 @@ async def _write(
)(scope, receive, send)
_MCP_PARAM_PREFIX_LOWER: Final = MCP_PARAM_HEADER_PREFIX.lower()
_MCP_PARAM_LIST_PAGE_CAP: Final = 100
"""Page cap for the schema-resolving tools/list walk: a buggy paginator degrades to a logged skip, not a hang."""
async def _tool_input_schema(
app: Server[Any],
request: Request,
request_id: RequestId,
verdict: InboundModernRoute,
lifespan_state: Any,
name: str,
) -> Any | None:
"""Resolve `name`'s inputSchema from the server's own registered `tools/list` handler.
The listing runs through the normal `serve_one` path, so a visibility-scoped
catalog yields exactly what *this* caller was advertised. Returns None
(caller skips validation) when the listing fails or never advertises the tool.
"""
meta = {
PROTOCOL_VERSION_META_KEY: verdict.protocol_version,
CLIENT_INFO_META_KEY: verdict.client_info,
CLIENT_CAPABILITIES_META_KEY: verdict.client_capabilities,
}
list_params: dict[str, Any] = {"_meta": meta}
try:
_methods.validate_client_request("tools/list", verdict.protocol_version, list_params)
except ValidationError:
# Client-fault envelope: the real dispatch produces the INVALID_PARAMS
# reply, and anything above a debug line would let clients flood the log.
logger.debug("Mcp-Param header validation skipped: the request envelope fails tools/list validation")
return None
seen_cursors: set[str] = set()
client_info = _typed(Implementation, verdict.client_info)
client_capabilities = _typed(ClientCapabilities, verdict.client_capabilities)
dctx = _SingleExchangeDispatchContext(
transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers),
request_id=request_id,
message_metadata=ServerMessageMetadata(request_context=request),
)
for _ in range(_MCP_PARAM_LIST_PAGE_CAP):
# Fresh Connection per page: serve_one tears down the connection's exit stack on the way out.
connection = Connection.from_envelope(verdict.protocol_version, client_info, client_capabilities)
try:
result = await serve_one(
app, dctx, "tools/list", list_params, connection=connection, lifespan_state=lifespan_state
)
for tool in result.get("tools", []):
if tool.get("name") == name:
return tool.get("inputSchema")
cursor = result.get("nextCursor")
except Exception:
# Fail-open boundary by design: header validation must never break a
# working call path. Loud, precisely because the skip is fail-open.
logger.exception("Mcp-Param header validation skipped: the tools/list listing failed")
return None
if not isinstance(cursor, str):
# Listing exhausted without advertising `name`; dispatch owns rejecting an unknown tool.
return None
if cursor in seen_cursors:
logger.warning("Mcp-Param header validation skipped: the tools/list handler returned a cursor cycle")
return None
seen_cursors.add(cursor)
list_params = {"_meta": meta, "cursor": cursor}
logger.warning(
"Mcp-Param header validation skipped: tools/list pagination did not terminate within %d pages",
_MCP_PARAM_LIST_PAGE_CAP,
)
return None
async def _mcp_param_rejection(
app: Server[Any],
request: Request,
req: JSONRPCRequest,
verdict: InboundModernRoute,
lifespan_state: Any,
) -> InboundLadderRejection | None:
"""Validate a `tools/call` request's `Mcp-Param-*` headers against the called tool's schema.
Runs pre-dispatch, before any SSE machinery, so a rejection is always a
plain `application/json` 400 (the spec's MUST). With no `tools/list` handler
the catalog is undiscoverable and there is no recognized header to validate.
"""
if req.method != "tools/call" or app.get_request_handler("tools/list") is None:
return None
params = req.params or {}
name = params.get("name")
if not isinstance(name, str):
return None
raw_arguments = params.get("arguments")
if raw_arguments is not None and not isinstance(raw_arguments, Mapping):
return None
arguments: Mapping[str, Any] = cast("Mapping[str, Any]", raw_arguments) if raw_arguments is not None else {}
# ASGI guarantees lowercase header names, so no case-folding here.
if not arguments and not any(header.startswith(_MCP_PARAM_PREFIX_LOWER) for header in request.headers):
# No argument values and no `Mcp-Param-*` headers: no declaration can be violated either way.
return None
input_schema = await _tool_input_schema(app, request, req.id, verdict, lifespan_state, name)
if input_schema is None:
return None
return validate_mcp_param_headers(input_schema, arguments, request.headers)
async def handle_modern_request(
app: Server[Any],
security_settings: TransportSecuritySettings | None,
@ -230,7 +360,8 @@ async def handle_modern_request(
body = await request.body()
try:
decoded = json.loads(body)
except json.JSONDecodeError:
except (ValueError, RecursionError):
# Not just JSONDecodeError: oversized integer literals raise bare ValueError, deep nesting RecursionError.
rej = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error"))
await _write(rej, scope, receive, send)
return
@ -252,12 +383,21 @@ async def handle_modern_request(
await _write(rej, scope, receive, send)
return
duplicated = find_duplicated_routing_header(request.headers.items())
if duplicated is not None:
# The raw carrier is the only place duplicates are visible; the classifier sees a folded mapping.
rejection = InboundLadderRejection(code=HEADER_MISMATCH, message=f"{duplicated} header appears more than once")
await _write_rejection(rejection, req.id, scope, receive, send)
return
verdict = classify_inbound_request(decoded, headers=dict(request.headers))
if isinstance(verdict, InboundLadderRejection):
rej = JSONRPCError(
jsonrpc="2.0", id=req.id, error=ErrorData(code=verdict.code, message=verdict.message, data=verdict.data)
)
await _write(rej, scope, receive, send)
await _write_rejection(verdict, req.id, scope, receive, send)
return
mcp_param_rejection = await _mcp_param_rejection(app, request, req, verdict, lifespan_state)
if mcp_param_rejection is not None:
await _write_rejection(mcp_param_rejection, req.id, scope, receive, send)
return
connection = Connection.from_envelope(

View file

@ -14,7 +14,7 @@ validator so client emit and server validate read the same source of truth.
import base64
import binascii
import re
from collections.abc import Iterator, Mapping, Sequence
from collections.abc import Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any, Final, cast
@ -49,8 +49,10 @@ __all__ = [
"classify_inbound_request",
"decode_header_value",
"encode_header_value",
"find_duplicated_routing_header",
"find_invalid_x_mcp_header",
"mcp_param_headers",
"validate_mcp_param_headers",
"x_mcp_header_map",
]
@ -161,18 +163,27 @@ def decode_header_value(value: str | None) -> str | None:
Returns the value verbatim unless it carries the `=?base64?...?=` sentinel,
in which case the payload is decoded as UTF-8. A malformed sentinel (bad
base64 or bad UTF-8) yields `None` so a corrupt header never matches a body
value by accident. `None` in → `None` out so callers can pass
`headers.get(...)` directly.
base64, non-canonical base64, or bad UTF-8) yields `None` so a corrupt
header never matches a body value by accident. `None` in → `None` out so
callers can pass `headers.get(...)` directly.
"""
if value is None:
return None
m = _B64_SENTINEL.fullmatch(value)
if m is None:
return value
payload = m.group("payload")
try:
return base64.b64decode(m.group("payload"), validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError):
decoded = base64.b64decode(payload, validate=True)
except binascii.Error:
return None
# Reject non-canonical base64 (e.g. non-zero trailing bits), which
# `validate=True` tolerates; the encoder only ever emits canonical form.
if base64.b64encode(decoded).decode("ascii") != payload:
return None
try:
return decoded.decode("utf-8")
except UnicodeDecodeError:
return None
@ -232,11 +243,33 @@ def x_mcp_header_map(input_schema: Any) -> dict[tuple[str, ...], str]:
:func:`find_invalid_x_mcp_header` accepts; an invalid schema yields an
undefined subset.
"""
mapping: dict[tuple[str, ...], str] = {}
return {path: token for path, token, _ in _annotated_positions(input_schema)}
def _annotated_positions(input_schema: Any) -> Iterator[tuple[tuple[str, ...], str, dict[str, Any]]]:
"""Yield `(path, token, schema)` for every statically-reachable `x-mcp-header` annotation.
Shared by client emit and server validate so both ends agree on what counts as a declared header.
"""
for path, schema in _walk_schema_positions(input_schema):
if path and isinstance(header := schema.get(X_MCP_HEADER_KEY), str):
mapping[path] = header
return mapping
if path and isinstance(token := schema.get(X_MCP_HEADER_KEY), str):
yield path, token, schema
def _render_header_scalar(value: Any) -> str | None:
"""Render `value` the way the client mirrors it into a header, or `None` when no rendering exists.
Shared by emit and validate so both sides agree on what is mirrorable:
non-primitives and ints beyond CPython's int-to-str digit limit are not.
"""
if isinstance(value, bool):
return "true" if value else "false"
if not isinstance(value, str | int | float):
return None
try:
return str(value)
except ValueError:
return None
def mcp_param_headers(header_map: Mapping[tuple[str, ...], str], arguments: Mapping[str, Any]) -> dict[str, str]:
@ -247,14 +280,14 @@ def mcp_param_headers(header_map: Mapping[tuple[str, ...], str], arguments: Mapp
`Mcp-Param-<token>` carrying it: `bool` as `true`/`false`, other scalars via
`str`, each passed through :func:`encode_header_value` so a non-token value
is base64-wrapped. A path that hits a missing key or a non-mapping node is
skipped, matching the spec's "omit the header when no value is present".
skipped, matching the spec's "omit the header when no value is present",
as is a value with no header rendering.
"""
headers: dict[str, str] = {}
for path, token in header_map.items():
value = _value_at_path(arguments, path)
if value is None:
if value is None or (rendered := _render_header_scalar(value)) is None:
continue
rendered = ("true" if value else "false") if isinstance(value, bool) else str(value)
headers[f"{MCP_PARAM_HEADER_PREFIX}{token}"] = encode_header_value(rendered)
return headers
@ -313,6 +346,26 @@ class InboundLadderRejection:
data: Any = None
_ROUTING_HEADER_NAMES: Final = frozenset({MCP_PROTOCOL_VERSION_HEADER, MCP_METHOD_HEADER, MCP_NAME_HEADER})
def find_duplicated_routing_header(headers: Iterable[tuple[str, str]]) -> str | None:
"""Name of a routing header supplied more than once in raw header lines, or `None`.
Takes raw `(name, value)` pairs — a folded mapping hides duplicates. A
duplicate is rejected because first-copy and last-copy readers would
disagree. `Mcp-Param-*` duplicates are :func:`validate_mcp_param_headers`'s job.
"""
seen: set[str] = set()
for name, _ in headers:
key = name.lower()
if key in _ROUTING_HEADER_NAMES:
if key in seen:
return key
seen.add(key)
return None
def classify_inbound_request(
body: Mapping[str, Any],
*,
@ -395,3 +448,104 @@ def classify_inbound_request(
client_info=client_info,
client_capabilities=client_capabilities,
)
# Header values eligible for the spec's numeric-comparison SHOULD; scientific
# notation never compares numerically (matching the typescript-sdk's gate).
_CANONICAL_DECIMAL = re.compile(r"^-?[0-9]+(\.[0-9]+)?$")
def _mcp_param_value_matches(prop_type: Any, value: Any, rendered: str, decoded: str) -> bool:
"""True when a decoded `Mcp-Param-*` header value agrees with the body argument.
Integer-typed declarations with an integral body value compare numerically
(`42` matches `42.0`, the spec's SHOULD) for canonical-decimal headers —
exact, no float round-trip, so values beyond the IEEE754 safe range still
compare. Anything else compares against `rendered`, the emit-side rendering.
"""
if (
prop_type == "integer"
and not isinstance(value, bool)
and (isinstance(value, int) or (isinstance(value, float) and value.is_integer()))
and _CANONICAL_DECIMAL.fullmatch(decoded) is not None
):
whole, _, fraction = decoded.partition(".")
if fraction and set(fraction) != {"0"}:
return False
try:
return int(whole) == int(value)
except ValueError:
return False
return decoded == rendered
def validate_mcp_param_headers(
input_schema: Any,
arguments: Mapping[str, Any],
headers: Mapping[str, str],
) -> InboundLadderRejection | None:
"""Compare a `tools/call` request's `Mcp-Param-*` headers against its body arguments.
Each annotated property's header and argument must agree: present together
and equal after sentinel decoding, or absent together (`null` counts as
absent). Returns the first failure as a `HEADER_MISMATCH` rejection, else `None`.
A header whose argument is absent or unrenderable is deliberately rejected:
the spec's purpose clause is exactly an intermediary routing on a value the
body never carried. A duplicated recognized header is rejected — first-copy
and last-copy readers would disagree. A schema :func:`find_invalid_x_mcp_header`
rejects validates nothing: conforming clients drop the tool and emit no headers.
"""
if find_invalid_x_mcp_header(input_schema) is not None:
return None
folded: dict[str, str] = {}
duplicated: set[str] = set()
for name, value in headers.items():
key = name.lower()
if key in folded:
duplicated.add(key)
folded[key] = value
for path, token, schema in _annotated_positions(input_schema):
header_name = f"{MCP_PARAM_HEADER_PREFIX}{token}"
key = header_name.lower()
raw = folded.get(key)
value = _value_at_path(arguments, path)
argument = ".".join(path)
if raw is not None and key in duplicated:
return InboundLadderRejection(
code=HEADER_MISMATCH,
message=f"{header_name} header appears more than once",
)
if value is None:
if raw is not None:
return InboundLadderRejection(
code=HEADER_MISMATCH,
message=f"{header_name} header is present but the request body's {argument!r} argument is absent",
)
continue
rendered = _render_header_scalar(value)
if rendered is None:
# Unrenderable value: a conforming client omitted the header, so one claiming it can never match.
if raw is not None:
return InboundLadderRejection(
code=HEADER_MISMATCH,
message=f"{header_name} header does not match the request body's {argument!r} argument",
)
continue
if raw is None:
return InboundLadderRejection(
code=HEADER_MISMATCH,
message=f"{header_name} header is missing but the request body's {argument!r} argument is present",
)
decoded = decode_header_value(raw)
if decoded is None:
return InboundLadderRejection(
code=HEADER_MISMATCH,
message=f"{header_name} header carries a malformed base64 sentinel value",
)
if not _mcp_param_value_matches(schema.get("type"), value, rendered, decoded):
return InboundLadderRejection(
code=HEADER_MISMATCH,
message=f"{header_name} header does not match the request body's {argument!r} argument",
)
return None