Skip to content

Commit 48ef569

Browse files
authored
Validate Mcp-Param-* headers server-side on the 2026-07-28 HTTP path (SEP-2243) (#3033)
1 parent 4df6091 commit 48ef569

8 files changed

Lines changed: 971 additions & 37 deletions

File tree

.github/actions/conformance/expected-failures.2026-07-28.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,3 @@ server:
2626
# SEP-2575 subscriptions/listen is not implemented yet; see the matching
2727
# entry in expected-failures.yml for the full rationale.
2828
- server-stateless
29-
# SEP-2243 Mcp-Param-* server-side validation is not implemented yet; see
30-
# the matching entry in expected-failures.yml for the full rationale.
31-
- http-custom-header-server-validation

.github/actions/conformance/expected-failures.yml

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

docs/migration.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,15 @@ On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return th
425425

426426
### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))
427427

428-
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.
428+
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.
429+
430+
### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))
431+
432+
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.
433+
434+
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.
435+
436+
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.
429437

430438
### `Client` verbs may serve cached responses ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549))
431439

src/mcp/server/_streamable_http_modern.py

Lines changed: 146 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,18 @@
2222
import logging
2323
from collections.abc import Awaitable, Mapping
2424
from dataclasses import dataclass, field
25-
from typing import TYPE_CHECKING, Any, Final, TypeVar
25+
from typing import TYPE_CHECKING, Any, Final, TypeVar, cast
2626

2727
import anyio
2828
from anyio.streams.memory import MemoryObjectSendStream
2929
from mcp_types import (
30+
CLIENT_CAPABILITIES_META_KEY,
31+
CLIENT_INFO_META_KEY,
32+
HEADER_MISMATCH,
3033
INTERNAL_ERROR,
3134
INVALID_REQUEST,
3235
PARSE_ERROR,
36+
PROTOCOL_VERSION_META_KEY,
3337
ClientCapabilities,
3438
ErrorData,
3539
Implementation,
@@ -40,6 +44,7 @@
4044
ProgressToken,
4145
RequestId,
4246
)
47+
from mcp_types import methods as _methods
4348
from pydantic import BaseModel, ValidationError
4449
from starlette.requests import Request
4550
from starlette.responses import Response
@@ -53,8 +58,12 @@
5358
from mcp.shared.exceptions import NoBackChannelError
5459
from mcp.shared.inbound import (
5560
ERROR_CODE_HTTP_STATUS,
61+
MCP_PARAM_HEADER_PREFIX,
5662
InboundLadderRejection,
63+
InboundModernRoute,
5764
classify_inbound_request,
65+
find_duplicated_routing_header,
66+
validate_mcp_param_headers,
5867
)
5968
from mcp.shared.jsonrpc_dispatcher import handler_exception_to_error_data, progress_token_from_params
6069
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
@@ -172,6 +181,22 @@ def _sse_event(msg: JSONRPCResponse | JSONRPCError | JSONRPCNotification) -> byt
172181
return f"event: message\r\ndata: {data}\r\n\r\n".encode()
173182

174183

184+
async def _write_rejection(
185+
rejection: InboundLadderRejection,
186+
request_id: RequestId,
187+
scope: Scope,
188+
receive: Receive,
189+
send: Send,
190+
) -> None:
191+
"""Send a ladder rejection as its JSON-RPC error with the table-mapped HTTP status."""
192+
rej = JSONRPCError(
193+
jsonrpc="2.0",
194+
id=request_id,
195+
error=ErrorData(code=rejection.code, message=rejection.message, data=rejection.data),
196+
)
197+
await _write(rej, scope, receive, send)
198+
199+
175200
async def _write(
176201
msg: JSONRPCResponse | JSONRPCError,
177202
scope: Scope,
@@ -192,6 +217,111 @@ async def _write(
192217
)(scope, receive, send)
193218

194219

220+
_MCP_PARAM_PREFIX_LOWER: Final = MCP_PARAM_HEADER_PREFIX.lower()
221+
222+
_MCP_PARAM_LIST_PAGE_CAP: Final = 100
223+
"""Page cap for the schema-resolving tools/list walk: a buggy paginator degrades to a logged skip, not a hang."""
224+
225+
226+
async def _tool_input_schema(
227+
app: Server[Any],
228+
request: Request,
229+
request_id: RequestId,
230+
verdict: InboundModernRoute,
231+
lifespan_state: Any,
232+
name: str,
233+
) -> Any | None:
234+
"""Resolve `name`'s inputSchema from the server's own registered `tools/list` handler.
235+
236+
The listing runs through the normal `serve_one` path, so a visibility-scoped
237+
catalog yields exactly what *this* caller was advertised. Returns None
238+
(caller skips validation) when the listing fails or never advertises the tool.
239+
"""
240+
meta = {
241+
PROTOCOL_VERSION_META_KEY: verdict.protocol_version,
242+
CLIENT_INFO_META_KEY: verdict.client_info,
243+
CLIENT_CAPABILITIES_META_KEY: verdict.client_capabilities,
244+
}
245+
list_params: dict[str, Any] = {"_meta": meta}
246+
try:
247+
_methods.validate_client_request("tools/list", verdict.protocol_version, list_params)
248+
except ValidationError:
249+
# Client-fault envelope: the real dispatch produces the INVALID_PARAMS
250+
# reply, and anything above a debug line would let clients flood the log.
251+
logger.debug("Mcp-Param header validation skipped: the request envelope fails tools/list validation")
252+
return None
253+
seen_cursors: set[str] = set()
254+
client_info = _typed(Implementation, verdict.client_info)
255+
client_capabilities = _typed(ClientCapabilities, verdict.client_capabilities)
256+
dctx = _SingleExchangeDispatchContext(
257+
transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers),
258+
request_id=request_id,
259+
message_metadata=ServerMessageMetadata(request_context=request),
260+
)
261+
for _ in range(_MCP_PARAM_LIST_PAGE_CAP):
262+
# Fresh Connection per page: serve_one tears down the connection's exit stack on the way out.
263+
connection = Connection.from_envelope(verdict.protocol_version, client_info, client_capabilities)
264+
try:
265+
result = await serve_one(
266+
app, dctx, "tools/list", list_params, connection=connection, lifespan_state=lifespan_state
267+
)
268+
for tool in result.get("tools", []):
269+
if tool.get("name") == name:
270+
return tool.get("inputSchema")
271+
cursor = result.get("nextCursor")
272+
except Exception:
273+
# Fail-open boundary by design: header validation must never break a
274+
# working call path. Loud, precisely because the skip is fail-open.
275+
logger.exception("Mcp-Param header validation skipped: the tools/list listing failed")
276+
return None
277+
if not isinstance(cursor, str):
278+
# Listing exhausted without advertising `name`; dispatch owns rejecting an unknown tool.
279+
return None
280+
if cursor in seen_cursors:
281+
logger.warning("Mcp-Param header validation skipped: the tools/list handler returned a cursor cycle")
282+
return None
283+
seen_cursors.add(cursor)
284+
list_params = {"_meta": meta, "cursor": cursor}
285+
logger.warning(
286+
"Mcp-Param header validation skipped: tools/list pagination did not terminate within %d pages",
287+
_MCP_PARAM_LIST_PAGE_CAP,
288+
)
289+
return None
290+
291+
292+
async def _mcp_param_rejection(
293+
app: Server[Any],
294+
request: Request,
295+
req: JSONRPCRequest,
296+
verdict: InboundModernRoute,
297+
lifespan_state: Any,
298+
) -> InboundLadderRejection | None:
299+
"""Validate a `tools/call` request's `Mcp-Param-*` headers against the called tool's schema.
300+
301+
Runs pre-dispatch, before any SSE machinery, so a rejection is always a
302+
plain `application/json` 400 (the spec's MUST). With no `tools/list` handler
303+
the catalog is undiscoverable and there is no recognized header to validate.
304+
"""
305+
if req.method != "tools/call" or app.get_request_handler("tools/list") is None:
306+
return None
307+
params = req.params or {}
308+
name = params.get("name")
309+
if not isinstance(name, str):
310+
return None
311+
raw_arguments = params.get("arguments")
312+
if raw_arguments is not None and not isinstance(raw_arguments, Mapping):
313+
return None
314+
arguments: Mapping[str, Any] = cast("Mapping[str, Any]", raw_arguments) if raw_arguments is not None else {}
315+
# ASGI guarantees lowercase header names, so no case-folding here.
316+
if not arguments and not any(header.startswith(_MCP_PARAM_PREFIX_LOWER) for header in request.headers):
317+
# No argument values and no `Mcp-Param-*` headers: no declaration can be violated either way.
318+
return None
319+
input_schema = await _tool_input_schema(app, request, req.id, verdict, lifespan_state, name)
320+
if input_schema is None:
321+
return None
322+
return validate_mcp_param_headers(input_schema, arguments, request.headers)
323+
324+
195325
async def handle_modern_request(
196326
app: Server[Any],
197327
security_settings: TransportSecuritySettings | None,
@@ -230,7 +360,8 @@ async def handle_modern_request(
230360
body = await request.body()
231361
try:
232362
decoded = json.loads(body)
233-
except json.JSONDecodeError:
363+
except (ValueError, RecursionError):
364+
# Not just JSONDecodeError: oversized integer literals raise bare ValueError, deep nesting RecursionError.
234365
rej = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error"))
235366
await _write(rej, scope, receive, send)
236367
return
@@ -252,12 +383,21 @@ async def handle_modern_request(
252383
await _write(rej, scope, receive, send)
253384
return
254385

386+
duplicated = find_duplicated_routing_header(request.headers.items())
387+
if duplicated is not None:
388+
# The raw carrier is the only place duplicates are visible; the classifier sees a folded mapping.
389+
rejection = InboundLadderRejection(code=HEADER_MISMATCH, message=f"{duplicated} header appears more than once")
390+
await _write_rejection(rejection, req.id, scope, receive, send)
391+
return
392+
255393
verdict = classify_inbound_request(decoded, headers=dict(request.headers))
256394
if isinstance(verdict, InboundLadderRejection):
257-
rej = JSONRPCError(
258-
jsonrpc="2.0", id=req.id, error=ErrorData(code=verdict.code, message=verdict.message, data=verdict.data)
259-
)
260-
await _write(rej, scope, receive, send)
395+
await _write_rejection(verdict, req.id, scope, receive, send)
396+
return
397+
398+
mcp_param_rejection = await _mcp_param_rejection(app, request, req, verdict, lifespan_state)
399+
if mcp_param_rejection is not None:
400+
await _write_rejection(mcp_param_rejection, req.id, scope, receive, send)
261401
return
262402

263403
connection = Connection.from_envelope(

0 commit comments

Comments
 (0)