Skip to content

Commit b40eb78

Browse files
committed
Validate Mcp-Param-* headers server-side on the 2026-07-28 HTTP path (SEP-2243)
Servers that process the request body MUST validate that Mcp-Param-* headers match the corresponding x-mcp-header-annotated body arguments, rejecting mismatches with HTTP 400 and -32020 (HeaderMismatch). The client half shipped earlier; this adds the server half: - mcp.shared.inbound grows a pure, exported validate_mcp_param_headers built on the same walker and scalar rendering the client emit side uses, so the two halves of the mirror contract cannot drift. Presence rules follow the spec's scenario table; recognized headers supplied more than once are rejected (first-wins consumers vs last-wins validation would otherwise disagree); integer values compare numerically behind a canonical-decimal gate, exactly and in both directions; unrecognized headers stay ignored. - decode_header_value now requires canonical base64 in the sentinel (bad padding, stray characters, non-zero trailing bits, or invalid UTF-8 are malformed), which the conformance suite mandates for Mcp-Param-* and which now applies to Mcp-Name symmetrically. - The modern HTTP entry validates tools/call pre-dispatch, resolving the called tool's inputSchema through the server's own registered tools/list handler via the normal serve_one path with the caller's envelope - so a visibility-scoped catalog validates exactly what this caller was advertised, with nothing to configure on MCPServer or lowlevel servers. The listing is skipped (never failing the call) when no tools/list handler is registered, the tool is not advertised, the handler raises (logged), pagination exceeds a page cap or cycles, or the call has no arguments and no Mcp-Param-* headers. - Remove http-custom-header-server-validation from both conformance expected-failures baselines; the scenario passes 9/9 against the everything-server, with http-header-validation and server-stateless unchanged.
1 parent 9856524 commit b40eb78

8 files changed

Lines changed: 861 additions & 33 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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,14 @@ On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return th
427427

428428
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.
429429

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 a warning), 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. 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.
437+
430438
### `Client` verbs may serve cached responses ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549))
431439

432440
On protocol 2026-07-28, servers attach caching hints (`ttlMs`, `cacheScope`) to the cacheable results, and `Client` now honors them: `list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, and `read_resource` may serve a cached response instead of making a round trip, for as long as the server's `ttlMs` says the result is fresh. With the default configuration, servers that send no hints, including every pre-2026 server, see identical call-for-call behavior, because hint-less results are not cached (a `CacheConfig.default_ttl_ms` above zero caches them too). Pass `Client(..., cache=False)` to disable the cache and restore v1 behavior exactly; per-call control (`cache_mode`) and configuration (`CacheConfig`) are described in [Caching hints](advanced/caching.md).

src/mcp/server/_streamable_http_modern.py

Lines changed: 146 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,17 @@
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,
3032
INTERNAL_ERROR,
3133
INVALID_REQUEST,
3234
PARSE_ERROR,
35+
PROTOCOL_VERSION_META_KEY,
3336
ClientCapabilities,
3437
ErrorData,
3538
Implementation,
@@ -53,8 +56,11 @@
5356
from mcp.shared.exceptions import NoBackChannelError
5457
from mcp.shared.inbound import (
5558
ERROR_CODE_HTTP_STATUS,
59+
MCP_PARAM_HEADER_PREFIX,
5660
InboundLadderRejection,
61+
InboundModernRoute,
5762
classify_inbound_request,
63+
validate_mcp_param_headers,
5864
)
5965
from mcp.shared.jsonrpc_dispatcher import handler_exception_to_error_data, progress_token_from_params
6066
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
@@ -172,6 +178,22 @@ def _sse_event(msg: JSONRPCResponse | JSONRPCError | JSONRPCNotification) -> byt
172178
return f"event: message\r\ndata: {data}\r\n\r\n".encode()
173179

174180

181+
async def _write_rejection(
182+
rejection: InboundLadderRejection,
183+
request_id: RequestId,
184+
scope: Scope,
185+
receive: Receive,
186+
send: Send,
187+
) -> None:
188+
"""Send a ladder rejection as its JSON-RPC error with the table-mapped HTTP status."""
189+
rej = JSONRPCError(
190+
jsonrpc="2.0",
191+
id=request_id,
192+
error=ErrorData(code=rejection.code, message=rejection.message, data=rejection.data),
193+
)
194+
await _write(rej, scope, receive, send)
195+
196+
175197
async def _write(
176198
msg: JSONRPCResponse | JSONRPCError,
177199
scope: Scope,
@@ -192,6 +214,123 @@ async def _write(
192214
)(scope, receive, send)
193215

194216

217+
_MCP_PARAM_PREFIX_LOWER: Final = MCP_PARAM_HEADER_PREFIX.lower()
218+
219+
_MCP_PARAM_LIST_PAGE_CAP: Final = 100
220+
"""Upper bound on `tools/list` pages walked while resolving a tool schema for
221+
`Mcp-Param-*` validation. A paginator bug (non-terminating or cycling cursor
222+
chain) must degrade to a logged validation skip, never a request-path hang."""
223+
224+
225+
async def _tool_input_schema(
226+
app: Server[Any],
227+
request: Request,
228+
request_id: RequestId,
229+
verdict: InboundModernRoute,
230+
lifespan_state: Any,
231+
name: str,
232+
) -> Any | None:
233+
"""Resolve `name`'s inputSchema from the server's own registered `tools/list` handler.
234+
235+
The synthetic listing runs through the normal `serve_one` path — the
236+
caller's envelope (rebuilt from the classifier verdict, so caller-side
237+
`_meta` extras like a progress token are not replayed), the middleware
238+
chain, a fresh per-page `Connection`, and an absorbing dispatch context
239+
(no SSE sink, so request-scoped notifications drop instead of raising) —
240+
so a visibility-scoped catalog yields exactly what *this* caller was
241+
advertised. Returns `None` (caller skips validation) when the listing
242+
raises, paginates past the cap or cycles, or never advertises the tool.
243+
"""
244+
meta = {
245+
PROTOCOL_VERSION_META_KEY: verdict.protocol_version,
246+
CLIENT_INFO_META_KEY: verdict.client_info,
247+
CLIENT_CAPABILITIES_META_KEY: verdict.client_capabilities,
248+
}
249+
list_params: dict[str, Any] = {"_meta": meta}
250+
seen_cursors: set[str] = set()
251+
client_info = _typed(Implementation, verdict.client_info)
252+
client_capabilities = _typed(ClientCapabilities, verdict.client_capabilities)
253+
dctx = _SingleExchangeDispatchContext(
254+
transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers),
255+
request_id=request_id,
256+
message_metadata=ServerMessageMetadata(request_context=request),
257+
)
258+
for _ in range(_MCP_PARAM_LIST_PAGE_CAP):
259+
# A fresh per-page Connection is load-bearing: serve_one tears down
260+
# the connection's exit stack on the way out. The dispatch context is
261+
# page-independent and reused.
262+
connection = Connection.from_envelope(verdict.protocol_version, client_info, client_capabilities)
263+
try:
264+
result = await serve_one(
265+
app, dctx, "tools/list", list_params, connection=connection, lifespan_state=lifespan_state
266+
)
267+
except Exception:
268+
# Boundary by design: header validation must never break a working
269+
# call path, so a raising listing skips validation for this request
270+
# — loudly, because the skip is fail-open. (A server broken here is
271+
# broken for real discovery too.)
272+
logger.exception("Mcp-Param header validation skipped: the tools/list handler raised")
273+
return None
274+
for tool in result.get("tools", []):
275+
if tool.get("name") == name:
276+
return tool.get("inputSchema")
277+
cursor = result.get("nextCursor")
278+
if not isinstance(cursor, str):
279+
# Listing exhausted without advertising `name`: nothing was
280+
# declared to this caller, so there is nothing to validate —
281+
# dispatch owns rejecting a genuinely unknown tool.
282+
return None
283+
if cursor in seen_cursors:
284+
logger.warning("Mcp-Param header validation skipped: the tools/list handler returned a cursor cycle")
285+
return None
286+
seen_cursors.add(cursor)
287+
list_params = {"_meta": meta, "cursor": cursor}
288+
logger.warning(
289+
"Mcp-Param header validation skipped: tools/list pagination did not terminate within %d pages",
290+
_MCP_PARAM_LIST_PAGE_CAP,
291+
)
292+
return None
293+
294+
295+
async def _mcp_param_rejection(
296+
app: Server[Any],
297+
request: Request,
298+
req: JSONRPCRequest,
299+
verdict: InboundModernRoute,
300+
lifespan_state: Any,
301+
) -> InboundLadderRejection | None:
302+
"""Validate a `tools/call` request's `Mcp-Param-*` headers against the called tool's schema.
303+
304+
Runs post-classification, pre-dispatch — and before any SSE machinery, so
305+
a rejection is always a plain `application/json` 400 (the spec's MUST for
306+
header-validation failures). The schema source is the registered
307+
`tools/list` handler; with none registered the catalog is undiscoverable,
308+
no client can have been told about an `x-mcp-header` annotation, and there
309+
is no recognized header to validate. A mis-shaped `name`/`arguments` is
310+
left to params validation at dispatch.
311+
"""
312+
if req.method != "tools/call" or app.get_request_handler("tools/list") is None:
313+
return None
314+
params = req.params or {}
315+
name = params.get("name")
316+
if not isinstance(name, str):
317+
return None
318+
raw_arguments = params.get("arguments")
319+
if raw_arguments is not None and not isinstance(raw_arguments, Mapping):
320+
return None
321+
arguments: Mapping[str, Any] = cast("Mapping[str, Any]", raw_arguments) if raw_arguments is not None else {}
322+
# ASGI guarantees lowercase header names, same invariant the classifier
323+
# leans on; the pure validator re-folds for arbitrary carriers.
324+
if not arguments and not any(header.startswith(_MCP_PARAM_PREFIX_LOWER) for header in request.headers):
325+
# With no argument values and no `Mcp-Param-*` headers, no declaration
326+
# could be violated in either direction — skip the listing outright.
327+
return None
328+
input_schema = await _tool_input_schema(app, request, req.id, verdict, lifespan_state, name)
329+
if input_schema is None:
330+
return None
331+
return validate_mcp_param_headers(input_schema, arguments, request.headers)
332+
333+
195334
async def handle_modern_request(
196335
app: Server[Any],
197336
security_settings: TransportSecuritySettings | None,
@@ -254,10 +393,12 @@ async def handle_modern_request(
254393

255394
verdict = classify_inbound_request(decoded, headers=dict(request.headers))
256395
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)
396+
await _write_rejection(verdict, req.id, scope, receive, send)
397+
return
398+
399+
mcp_param_rejection = await _mcp_param_rejection(app, request, req, verdict, lifespan_state)
400+
if mcp_param_rejection is not None:
401+
await _write_rejection(mcp_param_rejection, req.id, scope, receive, send)
261402
return
262403

263404
connection = Connection.from_envelope(

0 commit comments

Comments
 (0)