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

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(