Validate Mcp-Param-* headers server-side on the 2026-07-28 HTTP path (SEP-2243) (#3033)
This commit is contained in:
parent
4df609119f
commit
48ef569f7e
2 changed files with 971 additions and 37 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue