Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .github/actions/conformance/expected-failures.2026-07-28.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 0 additions & 7 deletions .github/actions/conformance/expected-failures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
152 changes: 146 additions & 6 deletions src/mcp/server/_streamable_http_modern.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,18 @@
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,
Expand All @@ -40,6 +44,7 @@
ProgressToken,
RequestId,
)
from mcp_types import methods as _methods
from pydantic import BaseModel, ValidationError
from starlette.requests import Request
from starlette.responses import Response
Expand All @@ -53,8 +58,12 @@
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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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

Comment on lines +395 to 399

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 In SSE mode (json_response=False), the new pre-dispatch Mcp-Param validation phase runs before the SSE deferral/keepalive machinery, so a 2026-07-28 tools/call writes no bytes to the wire while the internal tools/list schema walk runs (up to 100 paginated serve_one round trips). A deployment whose tools/list handler is slower than the upstream proxy's idle-read timeout previously worked (the keepalive committed within 15s of dispatch) but would now have every validated tools/call reset before dispatch — consider bounding the schema-resolving walk with a timeout that degrades to the existing logged fail-open skip.

Extended reasoning...

What happens. _mcp_param_rejection is awaited in handle_modern_request (src/mcp/server/_streamable_http_modern.py:395-399) before the SSE branch is reached — before send_ch/run_handler are constructed and before the anyio.move_on_after(_SSE_PING_INTERVAL) deferral windows exist. For a 2026-07-28 tools/call with non-empty arguments or any Mcp-Param-* header (the gate only skips when both are absent — it does not depend on the tool actually carrying x-mcp-header annotations), the entry awaits _tool_input_schema, which dispatches an internal tools/list through serve_one for up to _MCP_PARAM_LIST_PAGE_CAP = 100 pages, each through middleware and the user handler with a fresh Connection. Nothing is written to the wire while that walk runs.

Why this is a coverage regression rather than just extra cost. The module's own docstring states the SSE deferral exists so that "a handler that runs silent past the window commits SSE so the keepalive ping can keep the connection open behind a proxy idle-read timeout" — i.e. the design explicitly bounds the silent window at _SSE_PING_INTERVAL (15s) precisely because slow work behind proxy idle timeouts is an acknowledged deployment reality. The new validation phase sits entirely outside that guarantee: the maximum silent time before the first byte grows from ~15s to (full listing duration + 15s). docs/migration.md documents that "middleware and an expensive or paginated tools/list handler see extra invocations" — the cost — but not the loss of keepalive coverage.

Concrete walkthrough. 1) An SSE-mode (default json_response=False) deployment sits behind a proxy with a 60s idle-read timeout, and its tools/list handler walks a slow paginated catalog backend taking ~90s. 2) Pre-PR: a tools/call is dispatched immediately; within 15s the entry either replies or commits text/event-stream and starts : ping keepalives, so the proxy never sees 60s of silence — the call succeeds. 3) Post-PR: the same tools/call (it has arguments, so the gate fires) first runs the internal tools/list walk; the connection is byte-silent for ~90s; the proxy resets it at 60s; the request never reaches dispatch. Every validated tools/call to that deployment now fails the same way, and the failure is a connection reset rather than a diagnosable JSON-RPC error.

Why it is narrow. The trigger requires SSE mode plus a tools/list handler (or paginated catalog walk) slower than the proxy idle-read timeout — typically 30-60s, which is unusual; most catalogs list from memory in milliseconds, and the walk stops as soon as the called tool is found. JSON-mode deployments were never protected by a keepalive, so they are unchanged. The placement before SSE is also partly forced by the spec: a HEADER_MISMATCH rejection MUST be a plain application/json 400, which cannot be honored after SSE has committed, so simply moving the validation under the keepalive machinery is not a drop-in fix.

Suggested fix. Bound the schema-resolving walk with a wall-clock timeout (e.g. wrap the _tool_input_schema call in anyio.move_on_after(...)) that degrades to the existing logged fail-open skip — the same availability-over-strictness posture already taken for a raising handler, cursor cycle, or page cap. Alternatively, document the limitation in docs/migration.md alongside the extra-invocations note, or use it as motivation for the registry fast-path the PR description already anticipates.

connection = Connection.from_envelope(
Expand Down
Loading
Loading