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
1 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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue