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
8 changed files with 971 additions and 37 deletions

View file

@ -26,6 +26,3 @@ server:
# SEP-2575 subscriptions/listen is not implemented yet; see the matching
# entry in expected-failures.yml for the full rationale.
- server-stateless
# SEP-2243 Mcp-Param-* server-side validation is not implemented yet; see
# the matching entry in expected-failures.yml for the full rationale.
- http-custom-header-server-validation

View file

@ -23,13 +23,6 @@ server:
# failures in the scenario's other 25 (currently passing) checks — the
# baseline is per-scenario, not per-check.
- server-stateless
# SEP-2243 Mcp-Param-* server-side validation is not implemented yet. The
# everything-server's `test_x_mcp_header` tool arms these checks (without an
# x-mcp-header-annotated tool the harness skips all of them silently); the
# accept-path checks pass, the reject-path checks fail until the server
# validates Mcp-Param headers against body params. Read by the draft leg and
# the bare `--suite all` leg; the 2026-07-28 leg carries its own entry.
- http-custom-header-server-validation
# SEP-2663 (io.modelcontextprotocol/tasks): the SDK does not implement the
# tasks extension yet. These extension-tagged scenarios are selected only by
# the bare `--suite all` leg — extension scenarios never match a

View file

@ -425,7 +425,15 @@ On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return th
### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))
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.
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.
### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))
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.
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.
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.
### `Client` verbs may serve cached responses ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549))

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

View file

@ -17,6 +17,7 @@ import pytest
from inline_snapshot import snapshot
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
HEADER_MISMATCH,
INTERNAL_ERROR,
INVALID_PARAMS,
METHOD_NOT_FOUND,
@ -475,12 +476,13 @@ async def test_modern_client_emits_no_param_headers_for_an_unlisted_tool() -> No
The spec lets a client that lacks the tool's `inputSchema` send the request without custom headers.
The call is made with no prior `list_tools`, so the first `tools/call` POST -- captured before the
implicit output-schema `list_tools` runs -- has no cached annotations and emits no `Mcp-Param-*` header.
The server validates `Mcp-Param-*` against its own catalog and rejects as the spec's scenario table
requires for an omitted header (the relist-and-retry recovery is a SHOULD the client does not implement yet).
"""
requests: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
if json.loads(request.content)["method"] == "tools/call":
requests.append(request)
requests.append(request)
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
@ -496,8 +498,12 @@ async def test_modern_client_emits_no_param_headers_for_an_unlisted_tool() -> No
prior_discover=discover,
) as client,
):
await client.call_tool("run", {"region": "us-west1"})
with pytest.raises(MCPError) as excinfo: # pragma: no branch
await client.call_tool("run", {"region": "us-west1"})
assert excinfo.value.error.code == HEADER_MISMATCH
assert len(requests) == 1
assert json.loads(requests[0].content)["method"] == "tools/call"
assert not any(k.startswith("mcp-param-") for k in requests[0].headers)
@ -513,11 +519,13 @@ async def test_modern_client_stops_mirroring_after_a_re_list_drops_the_tool() ->
bad_schema = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}}
valid = Tool(name="run", input_schema=schema)
invalid = Tool(name="run", input_schema=bad_schema)
# Three pages: the call after the drop re-lists once because the prune also cleared `run`'s schema entry.
listings = iter([valid, invalid, invalid])
# First listing valid, every later one invalid; the count is not pinned because the server also
# reads its own catalog on each tools/call.
listings: list[None] = []
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[next(listings)], ttl_ms=0, cache_scope="public")
listings.append(None)
return ListToolsResult(tools=[valid if len(listings) == 1 else invalid], ttl_ms=0, cache_scope="public")
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(content=[TextContent(text="ok")])

View file

@ -23,6 +23,8 @@ from mcp_types import (
METHOD_NOT_FOUND,
PARSE_ERROR,
PROTOCOL_VERSION_META_KEY,
CallToolRequestParams,
CallToolResult,
ErrorData,
JSONRPCError,
JSONRPCResponse,
@ -36,7 +38,7 @@ from mcp_types.version import LATEST_MODERN_VERSION
from starlette.types import Message, Receive, Scope, Send
from trio.testing import MockClock
from mcp.server import Server, ServerRequestContext, runner
from mcp.server import Server, ServerRequestContext, _streamable_http_modern, runner
from mcp.server._streamable_http_modern import (
_SingleExchangeDispatchContext,
_to_jsonrpc_response,
@ -647,3 +649,362 @@ async def test_disconnect_cancels_handler_and_runs_exit_stack() -> None:
await cleanup_ran.wait()
assert handler_started.is_set()
# --- Mcp-Param-* validation (SEP-2243 server half) -------------------------------
_REGION_TOOL = Tool(
name="search",
input_schema={
"type": "object",
"properties": {"region": {"type": "string", "x-mcp-header": "Region"}},
},
)
def _tool_call_body(arguments: dict[str, Any] | None = None, *, name: str | None = "search") -> dict[str, Any]:
"""A valid 2026-07-28 `tools/call` body; `name=None` omits the name entirely."""
body = _list_tools_body()
body["method"] = "tools/call"
if name is not None:
body["params"]["name"] = name
if arguments is not None:
body["params"]["arguments"] = arguments
return body
_TOOL_CALL_HEADERS = {MCP_METHOD_HEADER: "tools/call", MCP_NAME_HEADER: "search"}
async def _ok_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(content=[])
def _x_mcp_server(tools: list[Tool] | None = None) -> Server[Any]:
"""A lowlevel server whose `tools/list` handler advertises an `x-mcp-header` tool."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=tools if tools is not None else [_REGION_TOOL], ttl_ms=0, cache_scope="public")
return Server("test", on_list_tools=list_tools, on_call_tool=_ok_call_tool)
async def test_modern_tools_call_accepts_matching_mcp_param_header() -> None:
"""A `Mcp-Param-*` header that agrees with the body argument after sentinel decoding dispatches normally."""
async with _asgi_client(_x_mcp_server()) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "Tōkyō"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "=?base64?VMWNa3nFjQ==?="},
)
assert response.status_code == 200
assert response.json()["result"]["content"] == []
@pytest.mark.parametrize("json_response", [True, False])
async def test_modern_tools_call_rejects_mcp_param_mismatch_with_400_and_header_mismatch(
json_response: bool,
) -> None:
"""Spec MUST: a header/body mismatch is HTTP 400 + `HEADER_MISMATCH`, plain JSON even in SSE mode."""
async with _asgi_client(_x_mcp_server(), json_response=json_response) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "us"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 400
assert response.headers["content-type"].split(";", 1)[0] == "application/json"
error = response.json()["error"]
assert error["code"] == HEADER_MISMATCH
assert "Mcp-Param-Region" in error["message"]
async def test_modern_tools_call_rejects_missing_mcp_param_header_for_present_argument() -> None:
"""Spec table: a missing header for a present annotated argument MUST be rejected."""
async with _asgi_client(_x_mcp_server()) as http:
response = await http.post("/mcp", json=_tool_call_body({"region": "test-value"}), headers=_TOOL_CALL_HEADERS)
assert response.status_code == 400
assert response.json()["error"]["code"] == HEADER_MISMATCH
async def test_modern_tools_call_rejects_orphan_mcp_param_header() -> None:
"""SDK posture: a header for an absent argument is the routing-spoof case the gate exists to stop."""
async with _asgi_client(_x_mcp_server()) as http:
response = await http.post(
"/mcp", json=_tool_call_body({}), headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"}
)
assert response.status_code == 400
assert response.json()["error"]["code"] == HEADER_MISMATCH
async def test_modern_tools_call_skips_validation_without_a_tools_list_handler() -> None:
"""Without a `tools/list` handler no annotations were ever advertised, so `Mcp-Param-*` headers are ignored."""
server: Server[Any] = Server("test")
server.add_request_handler("tools/call", CallToolRequestParams, _ok_call_tool)
async with _asgi_client(server) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "us"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 200
assert response.json()["result"]["content"] == []
async def test_modern_tools_call_skips_validation_when_tool_not_listed_to_this_caller() -> None:
"""A tool absent from this caller's listing was never advertised to it, so its headers go unvalidated."""
other = Tool(name="other", input_schema={"type": "object"})
async with _asgi_client(_x_mcp_server(tools=[other])) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "us"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 200
async def test_modern_tools_call_skips_validation_when_list_handler_raises(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A raising `tools/list` handler fails open — validation is skipped and the skip logged at error level."""
async def broken_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
raise RuntimeError("catalog backend down")
server: Server[Any] = Server("test", on_list_tools=broken_list, on_call_tool=_ok_call_tool)
with caplog.at_level(logging.ERROR, logger=_streamable_http_modern.__name__):
async with _asgi_client(server) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "us"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 200
assert "Mcp-Param header validation skipped: the tools/list listing failed" in caplog.text
async def test_modern_tools_call_walks_pagination_to_find_the_tool() -> None:
"""The schema lookup follows `nextCursor` pages; a tool on a later page is still validated."""
cursors_seen: list[str | None] = []
async def paged_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
cursor = params.cursor if params is not None else None
cursors_seen.append(cursor)
if cursor is None:
return ListToolsResult(tools=[], next_cursor="page-2", ttl_ms=0, cache_scope="public")
return ListToolsResult(tools=[_REGION_TOOL], ttl_ms=0, cache_scope="public")
server: Server[Any] = Server("test", on_list_tools=paged_list, on_call_tool=_ok_call_tool)
async with _asgi_client(server) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "us"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 400
assert response.json()["error"]["code"] == HEADER_MISMATCH
assert cursors_seen == [None, "page-2"]
async def test_modern_tools_call_skips_validation_on_a_cursor_cycle(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A repeated cursor stops the walk with a logged skip instead of hanging the request."""
async def cycling_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[], next_cursor="loop", ttl_ms=0, cache_scope="public")
server: Server[Any] = Server("test", on_list_tools=cycling_list, on_call_tool=_ok_call_tool)
with caplog.at_level(logging.WARNING, logger=_streamable_http_modern.__name__):
async with _asgi_client(server) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "us"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 200
assert "cursor cycle" in caplog.text
async def test_modern_tools_call_skips_validation_at_the_pagination_cap(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A non-terminating cursor chain stops at the page cap: logged skip, never a hang."""
monkeypatch.setattr(_streamable_http_modern, "_MCP_PARAM_LIST_PAGE_CAP", 3)
pages = iter(range(1_000_000))
async def endless_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[], next_cursor=f"page-{next(pages)}", ttl_ms=0, cache_scope="public")
server: Server[Any] = Server("test", on_list_tools=endless_list, on_call_tool=_ok_call_tool)
with caplog.at_level(logging.WARNING, logger=_streamable_http_modern.__name__):
async with _asgi_client(server) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "us"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 200
assert "did not terminate within 3 pages" in caplog.text
async def test_modern_tools_call_threads_the_callers_envelope_into_the_synthetic_listing() -> None:
"""The synthetic `tools/list` runs as this caller, so a visibility-scoped handler produces its view."""
seen: list[Any] = []
async def recording_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
seen.append(ctx.session.client_params)
return ListToolsResult(tools=[_REGION_TOOL], ttl_ms=0, cache_scope="public")
server: Server[Any] = Server("test", on_list_tools=recording_list, on_call_tool=_ok_call_tool)
async with _asgi_client(server) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "eu"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 200
assert len(seen) == 1
assert seen[0] is not None
assert seen[0].client_info.name == "raw"
async def test_modern_tools_call_leaves_mis_shaped_name_and_arguments_to_dispatch() -> None:
"""A missing `name` or non-mapping `arguments` is dispatch's INVALID_PARAMS, never a header mismatch."""
async with _asgi_client(_x_mcp_server()) as http:
nameless = await http.post("/mcp", json=_tool_call_body(name=None), headers={MCP_METHOD_HEADER: "tools/call"})
bad_arguments_body = _tool_call_body()
bad_arguments_body["params"]["arguments"] = ["not", "a", "mapping"]
bad_arguments = await http.post(
"/mcp",
json=bad_arguments_body,
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert nameless.json()["error"]["code"] == INVALID_PARAMS
assert bad_arguments.json()["error"]["code"] == INVALID_PARAMS
async def test_modern_tools_call_rejects_a_duplicated_mcp_param_header() -> None:
"""A duplicated recognized header is rejected even if one copy matches: readers may disagree on which wins."""
# An httpx header list with a repeated name reaches the ASGI scope as two raw header lines.
duplicated = httpx.Headers(
[*_TOOL_CALL_HEADERS.items(), ("mcp-param-region", "spoofed"), ("mcp-param-region", "eu")]
)
async with _asgi_client(_x_mcp_server()) as http:
response = await http.post("/mcp", json=_tool_call_body({"region": "eu"}), headers=duplicated)
assert response.status_code == 400
error = response.json()["error"]
assert error["code"] == HEADER_MISMATCH
assert "more than once" in error["message"]
async def test_modern_synthetic_listing_does_not_replay_caller_meta_extras() -> None:
"""The rebuilt synthetic-listing envelope does not replay caller `_meta` extras like a progress token."""
seen_metas: list[Any] = []
async def recording_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
assert params is not None
seen_metas.append(params.meta)
return ListToolsResult(tools=[_REGION_TOOL], ttl_ms=0, cache_scope="public")
server: Server[Any] = Server("test", on_list_tools=recording_list, on_call_tool=_ok_call_tool)
body = _tool_call_body({"region": "eu"})
body["params"]["_meta"]["progressToken"] = "tok-1"
async with _asgi_client(server) as http:
response = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"})
assert response.status_code == 200
assert len(seen_metas) == 1
assert seen_metas[0] is not None
assert "progressToken" not in seen_metas[0]
async def test_modern_post_rejects_a_duplicated_routing_header() -> None:
"""A duplicated routing header (`Mcp-Name`) is unverifiable and rejected before the validation ladder runs."""
duplicated = httpx.Headers(
[(MCP_METHOD_HEADER, "tools/call"), (MCP_NAME_HEADER, "search"), (MCP_NAME_HEADER, "admin-tool")]
)
async with _asgi_client(_x_mcp_server()) as http:
response = await http.post("/mcp", json=_tool_call_body({"region": "eu"}), headers=duplicated)
assert response.status_code == 400
error = response.json()["error"]
assert error["code"] == HEADER_MISMATCH
assert "more than once" in error["message"]
async def test_modern_tools_call_mis_shaped_envelope_skips_validation_without_an_error_log(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A mis-shaped envelope value is a debug-logged client fault; the wire reply is dispatch's INVALID_PARAMS."""
body = _tool_call_body({"region": "eu"})
body["params"]["_meta"][CLIENT_INFO_META_KEY] = "not-an-object"
with caplog.at_level(logging.DEBUG, logger=_streamable_http_modern.__name__):
async with _asgi_client(_x_mcp_server()) as http:
response = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"})
assert response.status_code == 400
assert response.json()["error"]["code"] == INVALID_PARAMS
module_records = [r for r in caplog.records if r.name == _streamable_http_modern.__name__]
assert all(r.levelno < logging.ERROR for r in module_records)
assert any("fails tools/list validation" in r.message for r in module_records)
async def test_modern_tools_call_survives_a_middleware_short_circuit_with_a_mis_shaped_listing(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A mis-shaped `tools/list` result from a middleware short-circuit fails open instead of crashing to a 500."""
async def short_circuit(ctx: Any, call_next: Any) -> Any:
if ctx.method == "tools/list":
return {"tools": None}
return await call_next(ctx)
server = _x_mcp_server()
server.middleware = [short_circuit]
with caplog.at_level(logging.ERROR, logger=_streamable_http_modern.__name__):
async with _asgi_client(server) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "us"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 200
assert "Mcp-Param header validation skipped: the tools/list listing failed" in caplog.text
async def test_modern_post_with_an_oversized_integer_literal_is_parse_error_not_a_crash() -> None:
"""An integer past CPython's digit limit makes json.loads raise a bare ValueError — still 400 + PARSE_ERROR."""
body = b'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"a":' + b"1" * 5000 + b"}}"
async with _asgi_client(_x_mcp_server()) as http:
response = await http.post("/mcp", content=body, headers={"content-type": "application/json"})
assert response.status_code == 400
assert response.json()["error"]["code"] == PARSE_ERROR
async def test_modern_tools_call_logs_a_handler_raised_validation_error_loudly(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A ValidationError from inside the tools/list handler is a server fault: the skip is
logged at error level, not mistaken for a client-fault envelope."""
async def broken_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool.model_validate({"bogus": 1})], ttl_ms=0, cache_scope="public")
server: Server[Any] = Server("test", on_list_tools=broken_list, on_call_tool=_ok_call_tool)
with caplog.at_level(logging.ERROR, logger=_streamable_http_modern.__name__):
async with _asgi_client(server) as http:
response = await http.post(
"/mcp",
json=_tool_call_body({"region": "us"}),
headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"},
)
assert response.status_code == 200
assert "Mcp-Param header validation skipped: the tools/list listing failed" in caplog.text
async def test_modern_post_with_deeply_nested_body_is_parse_error_not_a_crash() -> None:
"""Deep nesting makes json.loads raise RecursionError; still an unparseable body: 400 + PARSE_ERROR."""
body = b"[" * 100_000 + b"]" * 100_000
async with _asgi_client(_x_mcp_server()) as http:
response = await http.post("/mcp", content=body, headers={"content-type": "application/json"})
assert response.status_code == 400
assert response.json()["error"]["code"] == PARSE_ERROR

View file

@ -6,6 +6,7 @@ or protocol-version literals — all facts are imported from their one source.
"""
import dataclasses
from collections.abc import Iterator, Mapping
from typing import Any
import pytest
@ -39,8 +40,10 @@ from mcp.shared.inbound import (
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,
)
@ -574,3 +577,273 @@ def test_mcp_param_headers_omits_when_nested_path_is_broken() -> None:
header_map = {("outer", "inner"): "Inner"}
assert mcp_param_headers(header_map, {"outer": "not-a-mapping"}) == {}
assert mcp_param_headers(header_map, {}) == {}
# --- validate_mcp_param_headers --------------------------------------------
REGION_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"region": {"type": "string", "x-mcp-header": "Region"}},
}
@pytest.mark.parametrize(
("argument", "header"),
[
pytest.param("Hello", "Hello", id="plain-literal"),
pytest.param("Hello", "=?base64?SGVsbG8=?=", id="valid-sentinel"),
pytest.param("", "=?base64??=", id="empty-sentinel"),
pytest.param("SGVsbG8=", "SGVsbG8=", id="missing-prefix-is-literal"),
pytest.param("=?base64?SGVsbG8=", "=?base64?SGVsbG8=", id="missing-suffix-is-literal"),
],
)
def test_validate_mcp_param_headers_accepts_agreeing_header_and_argument(argument: str, header: str) -> None:
"""Spec Value Encoding: a fully-wrapped sentinel decodes before comparison; anything else is a literal."""
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": argument}, {"Mcp-Param-Region": header}) is None
@pytest.mark.parametrize(
"header",
[
pytest.param("=?base64?SGVsbG8?=", id="missing-padding"),
pytest.param("=?base64?SGVs!!!bG8=?=", id="non-alphabet-chars"),
pytest.param("=?base64?SGVsbG9=?=", id="non-canonical-trailing-bits"),
pytest.param("=?base64?gA==?=", id="invalid-utf8"),
],
)
def test_validate_mcp_param_headers_rejects_malformed_sentinel(header: str) -> None:
"""Spec: servers MUST reject a recognized header whose sentinel cannot be strictly decoded — not a literal."""
rejection = assert_rejected(
validate_mcp_param_headers(REGION_SCHEMA, {"region": "Hello"}, {"Mcp-Param-Region": header}),
HEADER_MISMATCH,
)
assert "malformed base64" in rejection.message
def test_validate_mcp_param_headers_rejects_missing_header_for_present_argument() -> None:
"""Spec table: client omits the header but the value is in the body → server MUST reject."""
rejection = assert_rejected(
validate_mcp_param_headers(REGION_SCHEMA, {"region": "test-value"}, {}), HEADER_MISMATCH
)
assert "missing" in rejection.message
@pytest.mark.parametrize(
"arguments",
[pytest.param({}, id="absent"), pytest.param({"region": None}, id="null")],
)
def test_validate_mcp_param_headers_rejects_orphan_header_for_absent_or_null_argument(
arguments: dict[str, Any],
) -> None:
"""SDK-defined posture on a spec gap: an orphan header is the routing-spoof case; go rejects too, ts skips."""
rejection = assert_rejected(
validate_mcp_param_headers(REGION_SCHEMA, arguments, {"Mcp-Param-Region": "eu"}), HEADER_MISMATCH
)
assert "absent" in rejection.message
def test_validate_mcp_param_headers_rejects_value_mismatch() -> None:
rejection = assert_rejected(
validate_mcp_param_headers(REGION_SCHEMA, {"region": "us"}, {"Mcp-Param-Region": "eu"}), HEADER_MISMATCH
)
assert "does not match" in rejection.message
def test_validate_mcp_param_headers_accepts_absent_argument_with_no_header() -> None:
"""Spec table: parameter not in arguments / null → client MUST omit, server MUST NOT expect."""
assert validate_mcp_param_headers(REGION_SCHEMA, {}, {}) is None
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": None}, {}) is None
def test_validate_mcp_param_headers_matches_header_names_case_insensitively() -> None:
"""Spec Case Sensitivity: header-name comparison MUST be case-insensitive."""
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": "eu"}, {"MCP-PARAM-REGION": "eu"}) is None
rejection = validate_mcp_param_headers(REGION_SCHEMA, {"region": "us"}, {"MCP-PARAM-REGION": "eu"})
assert_rejected(rejection, HEADER_MISMATCH)
def test_validate_mcp_param_headers_ignores_undeclared_mcp_param_headers() -> None:
"""Spec: an undeclared `Mcp-Param-*` header is unrecognized — forwarded and ignored, never a failure."""
headers = {"Mcp-Param-Region": "eu", "Mcp-Param-Undeclared": "=?base64?not even base64?="}
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": "eu"}, headers) is None
def test_validate_mcp_param_headers_validates_nothing_for_an_invalid_annotation_schema() -> None:
"""Spec gives definition rejection to clients (drop the tool), so an invalid schema recognizes no headers."""
invalid = {
"type": "object",
"properties": {
"region": {"type": "string", "x-mcp-header": "Region"},
"dupe": {"type": "string", "x-mcp-header": "region"},
},
}
assert find_invalid_x_mcp_header(invalid) is not None
assert validate_mcp_param_headers(invalid, {"region": "us"}, {"Mcp-Param-Region": "eu"}) is None
def test_validate_mcp_param_headers_reads_nested_argument_paths() -> None:
"""A nested annotated property compares against the matching nested `arguments` path; a broken path is absent."""
schema = {
"type": "object",
"properties": {
"outer": {"type": "object", "properties": {"inner": {"type": "string", "x-mcp-header": "Inner"}}}
},
}
assert validate_mcp_param_headers(schema, {"outer": {"inner": "deep"}}, {"Mcp-Param-Inner": "deep"}) is None
rejection = validate_mcp_param_headers(schema, {"outer": {"inner": "deep"}}, {"Mcp-Param-Inner": "other"})
assert_rejected(rejection, HEADER_MISMATCH)
assert validate_mcp_param_headers(schema, {"outer": "not-a-mapping"}, {}) is None
def test_validate_mcp_param_headers_compares_booleans_against_true_false_rendering() -> None:
"""Booleans compare against the lowercase `true`/`false` rendering the client emits."""
schema = {"type": "object", "properties": {"flag": {"type": "boolean", "x-mcp-header": "Flag"}}}
assert validate_mcp_param_headers(schema, {"flag": True}, {"Mcp-Param-Flag": "true"}) is None
assert validate_mcp_param_headers(schema, {"flag": False}, {"Mcp-Param-Flag": "false"}) is None
rejection = validate_mcp_param_headers(schema, {"flag": True}, {"Mcp-Param-Flag": "True"})
assert_rejected(rejection, HEADER_MISMATCH)
INTEGER_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"n": {"type": "integer", "x-mcp-header": "N"}},
}
@pytest.mark.parametrize(
("body_value", "header", "matches"),
[
pytest.param(42, "42", True, id="exact"),
pytest.param(42, "42.0", True, id="trailing-zero-fraction"),
pytest.param(42, "42.000", True, id="long-zero-fraction"),
pytest.param(42, "42.5", False, id="real-fraction"),
pytest.param(42, "1e2", False, id="scientific-notation-never-numeric"),
pytest.param(42, "43", False, id="different-value"),
pytest.param(-7, "-7.0", True, id="negative"),
pytest.param(9007199254740993, "9007199254740993", True, id="beyond-ieee754-safe-range-exact"),
pytest.param(9007199254740993, "9007199254740992", False, id="beyond-ieee754-safe-range-off-by-one"),
],
)
def test_validate_mcp_param_headers_compares_integers_numerically(body_value: int, header: str, matches: bool) -> None:
"""Spec SHOULD: integers compare numerically (`42` == `42.0`) — gated to canonical decimals, compared exactly."""
result = validate_mcp_param_headers(INTEGER_SCHEMA, {"n": body_value}, {"Mcp-Param-N": header})
if matches:
assert result is None
else:
assert_rejected(result, HEADER_MISMATCH)
def test_validate_mcp_param_headers_non_primitive_body_value_rejects_only_when_a_header_claims_it() -> None:
"""A header claiming a non-primitive argument is a mismatch; without one, rejection is params validation's job."""
rejection = assert_rejected(
validate_mcp_param_headers(REGION_SCHEMA, {"region": {"k": "v"}}, {"Mcp-Param-Region": "x"}),
HEADER_MISMATCH,
)
assert "does not match" in rejection.message
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": {"k": "v"}}, {}) is None
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": [1, 2]}, {}) is None
class _RepeatedHeaders(Mapping[str, str]):
"""A header carrier whose `items()` yields duplicate names, like a raw HTTP header list."""
def __init__(self, pairs: list[tuple[str, str]]) -> None:
self._pairs = pairs
def __getitem__(self, key: str) -> str:
return next(value for name, value in self._pairs if name == key)
def __iter__(self) -> Iterator[str]:
return (name for name, _ in self._pairs)
def __len__(self) -> int:
return len(self._pairs)
def items(self) -> Any:
return list(self._pairs)
def test_validate_mcp_param_headers_rejects_a_recognized_header_supplied_more_than_once() -> None:
"""Duplicate recognized headers reject even when one matches: first-copy readers and last-wins checks diverge."""
headers = _RepeatedHeaders([("Mcp-Param-Region", "spoofed"), ("mcp-param-region", "eu")])
# The carrier behaves like a raw header list: first-wins lookup, every line iterated.
assert headers["Mcp-Param-Region"] == "spoofed"
assert len(headers) == len(list(headers)) == 2
rejection = assert_rejected(validate_mcp_param_headers(REGION_SCHEMA, {"region": "eu"}, headers), HEADER_MISMATCH)
assert "more than once" in rejection.message
noisy = _RepeatedHeaders([("Mcp-Param-Region", "eu"), ("Mcp-Param-Other", "a"), ("mcp-param-other", "b")])
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": "eu"}, noisy) is None
def test_validate_mcp_param_headers_rejects_a_header_exceeding_the_int_conversion_limit() -> None:
"""A canonical-decimal header beyond CPython's int-conversion digit limit is a clean mismatch, never an error."""
rejection = validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 1}, {"Mcp-Param-N": "1" * 5000})
assert_rejected(rejection, HEADER_MISMATCH)
def test_validate_mcp_param_headers_compares_integral_float_bodies_numerically() -> None:
"""JSON Schema admits `42.0` as an integer; the numeric SHOULD applies in both directions."""
assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42.0}, {"Mcp-Param-N": "42"}) is None
assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42.0}, {"Mcp-Param-N": "42.0"}) is None
assert_rejected(validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42.0}, {"Mcp-Param-N": "43"}), HEADER_MISMATCH)
# A genuinely fractional body value falls back to the exact string rendering.
assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42.5}, {"Mcp-Param-N": "42.5"}) is None
@pytest.mark.parametrize(
"value",
[
pytest.param("=?base64?SGVsbG9=?=", id="non-canonical-trailing-bits"),
pytest.param("=?base64?SGVsbG8?=", id="missing-padding"),
],
)
def test_decode_header_value_returns_none_for_non_canonical_base64(value: str) -> None:
"""Canonical base64 only: a payload that decodes but does not re-encode byte-identically is malformed."""
assert decode_header_value(value) is None
def test_validate_mcp_param_headers_union_typed_annotation_invalidates_the_whole_tool() -> None:
"""A union-typed annotation fails the integer/string/boolean-only rule, so the whole schema validates nothing."""
union_schema = {
"type": "object",
"properties": {"n": {"type": ["integer", "null"], "x-mcp-header": "N"}},
}
assert find_invalid_x_mcp_header(union_schema) is not None
assert validate_mcp_param_headers(union_schema, {"n": 42}, {"Mcp-Param-N": "999"}) is None
assert validate_mcp_param_headers(union_schema, {"n": 42}, {}) is None
def test_validate_mcp_param_headers_accepts_the_clients_own_rendering_of_large_integral_floats() -> None:
"""A non-canonical-decimal header falls back to rendered comparison, so the client's own mirroring round-trips."""
emitted = mcp_param_headers(x_mcp_header_map(INTEGER_SCHEMA), {"n": 1e16})
assert emitted == {"Mcp-Param-N": "1e+16"}
assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 1e16}, emitted) is None
assert_rejected(validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42}, {"Mcp-Param-N": "1e2"}), HEADER_MISMATCH)
def test_validate_mcp_param_headers_handles_unrenderable_huge_integer_bodies_without_raising() -> None:
"""An integer beyond CPython's int-to-str digit limit has no rendering: claimed → mismatch, unclaimed → fine."""
huge = 10**5000
rejection = validate_mcp_param_headers(REGION_SCHEMA, {"region": huge}, {"Mcp-Param-Region": "x"})
assert_rejected(rejection, HEADER_MISMATCH)
assert validate_mcp_param_headers(REGION_SCHEMA, {"region": huge}, {}) is None
assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": huge}, {}) is None
def test_mcp_param_headers_omits_values_with_no_scalar_rendering() -> None:
"""Objects, arrays, and over-limit integers have no scalar rendering, so the client omits the header."""
header_map = {("v",): "Val"}
assert mcp_param_headers(header_map, {"v": {"k": 1}}) == {}
assert mcp_param_headers(header_map, {"v": [1, 2]}) == {}
assert mcp_param_headers(header_map, {"v": 10**5000}) == {}
def test_find_duplicated_routing_header_detects_repeats_of_routing_headers_only() -> None:
"""Repeated routing headers report case-insensitively; `Mcp-Param-*` or unrelated repeats are ignored."""
assert find_duplicated_routing_header([("Mcp-Name", "a"), ("mcp-name", "b")]) == MCP_NAME_HEADER
assert find_duplicated_routing_header([("MCP-Protocol-Version", "x"), ("mcp-protocol-version", "x")]) == (
MCP_PROTOCOL_VERSION_HEADER
)
assert find_duplicated_routing_header([("Mcp-Method", "a"), ("Mcp-Method", "a")]) == MCP_METHOD_HEADER
assert find_duplicated_routing_header([("Mcp-Name", "a"), ("Mcp-Param-X", "1"), ("Mcp-Param-X", "2")]) is None
assert find_duplicated_routing_header([("accept", "a"), ("accept", "b")]) is None