Skip to content

Commit b04cf03

Browse files
committed
Look the tool schema up by name for Mcp-Param-* validation
On the 2026-07-28 Streamable HTTP path, every tools/call with arguments ran the server's whole tools/list handler, page by page, to find the called tool's input schema for Mcp-Param-* header validation. A server with an expensive listing paid for it on every call. MCPServer now looks the called tool up by name among its registered tools. Middleware no longer sees a tools/list for each tools/call, and the schema validated is the registered one, whatever middleware does to the listing. The low-level Server takes a new optional get_tool_input_schema keyword: a function from tool name to input schema, or None when there is nothing to validate. When it is set it is called instead of the tools/list handler; if it raises, the error is logged and the call is served unvalidated, as for a failed listing. A server that does not set it keeps resolving the schema through tools/list. The check itself and the 400 / -32020 rejection are unchanged. Fixes #3565
1 parent cafa33b commit b04cf03

10 files changed

Lines changed: 227 additions & 16 deletions

File tree

‎docs/advanced/header-parameters.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,23 @@ There you write `input_schema` by hand, so the key goes straight in:
3737

3838
* Nothing checks the annotation for you: an invalid one is served, and `2026-07-28` clients leave the tool out of their listing.
3939

40+
### Schemas by name
41+
42+
To check the header, the SDK needs the tool's input schema before it dispatches the call. Without `get_tool_input_schema` it gets it by running your `on_list_tools` handler on every call that carries arguments, whether or not any tool is marked.
43+
44+
```python title="server.py" hl_lines="26 39-41 48"
45+
--8<-- "docs_src/header_parameters/tutorial003.py"
46+
```
47+
48+
* Pass the function to answer from what you already have.
49+
* Return `None` for a tool with nothing to check.
50+
4051
## Recap
4152

4253
* `x-mcp-header` on a tool argument makes `2026-07-28` clients repeat it as an `Mcp-Param-*` HTTP header.
4354
* The server rejects a call whose header and body disagree.
4455
* Only `str`, `int` and `bool` arguments can be marked. `MCPServer` raises `InvalidSignature` for anything else.
4556
* The low-level `Server` checks nothing, and clients drop a tool whose annotation is invalid.
57+
* `get_tool_input_schema` keeps the low-level `Server` from running `on_list_tools` on every call.
4658

4759
The rest of the hand-written `Server` API is **[The low-level Server](low-level-server.md)**.

‎docs/advanced/low-level-server.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ Each of these is one idea you now have the vocabulary for; each has its own page
204204
* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](../handlers/multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**).
205205
* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives.
206206
* `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass a `ListenHandler` built over a `SubscriptionBus` and publish events to the bus from your other handlers; see **[Subscriptions](../handlers/subscriptions.md)** for the full composition.
207+
* `get_tool_input_schema` keeps `on_list_tools` off the call path; see **[Header parameters](header-parameters.md#schemas-by-name)**.
207208
* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story.
208209

209210
## Recap

‎docs/migration.md‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2849,14 +2849,6 @@ On a 2026-07-28 connection, `notifications/tools/list_changed`, `notifications/p
28492849

28502850
Migrate to publishing on the subscription bus, which stamps and filters per stream: `await ctx.notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`, and `notify_resource_updated(uri)` on `MCPServer`'s `Context`, or `await bus.publish(...)` on a low-level `Server`'s own `SubscriptionBus` — see [Subscriptions](handlers/subscriptions.md). A stream only ever receives the kinds and URIs the server acknowledged for it; to gate per caller which subscriptions may be opened, refuse `subscriptions/listen` in a middleware (`MCPServer(middleware=[...])`), covered on the same page.
28512851

2852-
### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))
2853-
2854-
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. On the client side, `ClientSession.call_tool` emits these headers automatically for annotated arguments of any tool it has listed; list the tool first, and note that pre-2026 connections and non-HTTP transports never emit them.
2855-
2856-
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.
2857-
2858-
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.
2859-
28602852
## Need Help?
28612853

28622854
If you encounter issues during migration:

‎docs/whats-new.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are repla
199199
### The rest, quickly
200200

201201
* **Identity is optional, per-message metadata.** The request-side `clientInfo` `_meta` key is optional (the required pair is `protocolVersion` + `clientCapabilities`), and `serverInfo` moved out of the `server/discover` result body: servers stamp it into every 2026-era result's `_meta` instead ([spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002)). The SDK always stamps; `client.server_info` is `None` when a server does not identify itself (for example, a middleware stripped the key). **[The low-level Server](advanced/low-level-server.md)** shows the stamp on the wire.
202-
* **Requests are routable without parsing bodies.** Modern HTTP requests carry `Mcp-Method` (and, for the three tool-ish calls, `Mcp-Name`); a tool input-schema property annotated with `x-mcp-header` is mirrored into an `Mcp-Param-*` header and cross-checked by the server ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Gateways and rate limiters can route on headers alone; the **[Migration Guide](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)** has the rules.
202+
* **Requests are routable without parsing bodies.** Modern HTTP requests carry `Mcp-Method` (and, for the three tool-ish calls, `Mcp-Name`); a tool input-schema property annotated with `x-mcp-header` is mirrored into an `Mcp-Param-*` header and cross-checked by the server ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Gateways and rate limiters can route on headers alone.
203203
* **Results carry cache hints.** List and read results declare `ttlMs` and `cacheScope` ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)); you set them per method with `cache_hints=`, and `Client` honors them with a built-in response cache. A server that sends no hints (every pre-2026 server) sees identical, uncached traffic. **[Caching hints](client/caching.md)**.
204204
* **Extensions are first class.** Servers and clients declare optional capability bundles under reverse-DNS identifiers ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)); the built-in `Apps` extension (MCP Apps) is the reference. **[Extensions](advanced/extensions.md)** and **[MCP Apps](advanced/apps.md)**.
205205
* **Error codes got standardized.** A missing resource is `-32602` with the URI in `error.data`, and the new spec-reserved codes appear as `-32020` (header mismatch), `-32021` (missing required capability), and `-32022` (unsupported protocol version). **[Troubleshooting](troubleshooting.md)** is keyed by the exact messages.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from typing import Any
2+
3+
from mcp.server import Server, ServerRequestContext
4+
from mcp.types import (
5+
CallToolRequestParams,
6+
CallToolResult,
7+
ListToolsResult,
8+
PaginatedRequestParams,
9+
TextContent,
10+
Tool,
11+
)
12+
13+
CHECK_STOCK = Tool(
14+
name="check_stock",
15+
description="Count the copies of a book in one region's warehouses.",
16+
input_schema={
17+
"type": "object",
18+
"properties": {
19+
"title": {"type": "string"},
20+
"region": {"type": "string", "x-mcp-header": "Region"},
21+
},
22+
"required": ["title", "region"],
23+
},
24+
)
25+
26+
TOOLS = {CHECK_STOCK.name: CHECK_STOCK}
27+
28+
29+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
30+
return ListToolsResult(tools=list(TOOLS.values()))
31+
32+
33+
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
34+
args = params.arguments or {}
35+
text = f"{args['title']}: 3 copies in {args['region']}."
36+
return CallToolResult(content=[TextContent(type="text", text=text)])
37+
38+
39+
def tool_input_schema(name: str) -> dict[str, Any] | None:
40+
tool = TOOLS.get(name)
41+
return tool.input_schema if tool else None
42+
43+
44+
server = Server(
45+
"Bookshop",
46+
on_list_tools=list_tools,
47+
on_call_tool=call_tool,
48+
get_tool_input_schema=tool_input_schema,
49+
)
50+
app = server.streamable_http_app()

‎src/mcp/server/_streamable_http_modern.py‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -339,10 +339,12 @@ async def _mcp_param_rejection(
339339
"""Validate a `tools/call` request's `Mcp-Param-*` headers against the called tool's schema.
340340
341341
Runs pre-dispatch, before any SSE machinery, so a rejection is always a
342-
plain `application/json` 400 (the spec's MUST). With no `tools/list` handler
343-
the catalog is undiscoverable and there is no recognized header to validate.
342+
plain `application/json` 400 (the spec's MUST). The schema comes from the
343+
server's `get_tool_input_schema` when set, else from its `tools/list` handler;
344+
with neither there is no recognized header to validate.
344345
"""
345-
if req.method != "tools/call" or app.get_request_handler("tools/list") is None:
346+
lookup = app.get_tool_input_schema
347+
if req.method != "tools/call" or (lookup is None and app.get_request_handler("tools/list") is None):
346348
return None
347349
params = req.params or {}
348350
name = params.get("name")
@@ -356,7 +358,15 @@ async def _mcp_param_rejection(
356358
if not arguments and not any(header.startswith(_MCP_PARAM_PREFIX_LOWER) for header in request.headers):
357359
# No argument values and no `Mcp-Param-*` headers: no declaration can be violated either way.
358360
return None
359-
input_schema = await _tool_input_schema(app, request, req.id, verdict, lifespan_state, name)
361+
if lookup is None:
362+
input_schema = await _tool_input_schema(app, request, req.id, verdict, lifespan_state, name)
363+
else:
364+
try:
365+
input_schema = lookup(name)
366+
except Exception:
367+
# Fail-open like a failed listing: header validation must never break a working call path.
368+
logger.exception("Mcp-Param header validation skipped: get_tool_input_schema raised")
369+
return None
360370
if input_schema is None:
361371
return None
362372
return validate_mcp_param_headers(input_schema, arguments, request.headers)

‎src/mcp/server/lowlevel/server.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ def __init__(
143143
[Server[LifespanResultT]],
144144
AbstractAsyncContextManager[LifespanResultT],
145145
] = lifespan,
146+
get_tool_input_schema: Callable[[str], Mapping[str, Any] | None] | None = None,
146147
# Request handlers
147148
on_list_tools: Callable[
148149
[ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None],
@@ -226,6 +227,7 @@ def __init__(
226227
[Server[LifespanResultT]],
227228
AbstractAsyncContextManager[LifespanResultT],
228229
] = lifespan,
230+
get_tool_input_schema: Callable[[str], Mapping[str, Any] | None] | None = None,
229231
# Request handlers
230232
on_list_tools: Callable[
231233
[ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None],
@@ -318,6 +320,7 @@ def __init__(
318320
[Server[LifespanResultT]],
319321
AbstractAsyncContextManager[LifespanResultT],
320322
] = lifespan,
323+
get_tool_input_schema: Callable[[str], Mapping[str, Any] | None] | None = None,
321324
# Request handlers
322325
on_list_tools: Callable[
323326
[ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None],
@@ -425,6 +428,13 @@ def __init__(
425428
# after the handler returns; fields the handler set explicitly win.
426429
self.cache_hints: dict[str, CacheHint] = validate_cache_hints(cache_hints)
427430
self.lifespan = lifespan
431+
self.get_tool_input_schema = get_tool_input_schema
432+
"""Returns a tool's input schema by name, or `None` when there is nothing to validate.
433+
434+
When set, `Mcp-Param-*` header validation on the 2026-07-28 Streamable HTTP
435+
path calls this instead of running the `tools/list` handler. If it raises,
436+
the error is logged and the call is served unvalidated.
437+
"""
428438
self._request_handlers: dict[str, HandlerEntry[LifespanResultT]] = {}
429439
self._notification_handlers: dict[str, HandlerEntry[LifespanResultT]] = {}
430440
self._session_manager: StreamableHTTPSessionManager | None = None

‎src/mcp/server/mcpserver/server.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ def __init__(
218218
icons=icons,
219219
version=version,
220220
cache_hints=cache_hints,
221+
get_tool_input_schema=self._tool_input_schema,
221222
on_list_tools=self._handle_list_tools,
222223
on_call_tool=self._handle_call_tool,
223224
on_list_resources=self._handle_list_resources,
@@ -428,6 +429,10 @@ async def _handle_list_tools(
428429
) -> ListToolsResult:
429430
return ListToolsResult(tools=await self.list_tools())
430431

432+
def _tool_input_schema(self, name: str) -> dict[str, Any] | None:
433+
tool = self._tool_manager.get_tool(name)
434+
return None if tool is None else tool.parameters
435+
431436
async def _handle_call_tool(
432437
self, ctx: ServerRequestContext[LifespanResultT], params: CallToolRequestParams
433438
) -> CallToolResult | InputRequiredResult:

‎tests/docs_src/test_header_parameters.py‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,19 @@
22

33
from collections.abc import AsyncIterator
44
from contextlib import asynccontextmanager
5-
from typing import Annotated, Literal
5+
from typing import Annotated, Any, Literal
66

77
import httpx2
88
import pytest
99
from mcp_types import HEADER_MISMATCH, ListToolsResult, PaginatedRequestParams
1010
from pydantic import Field, WithJsonSchema
1111
from starlette.applications import Starlette
1212

13-
from docs_src.header_parameters import tutorial001, tutorial002
13+
from docs_src.header_parameters import tutorial001, tutorial002, tutorial003
1414
from mcp import Client
1515
from mcp.client.streamable_http import streamable_http_client
1616
from mcp.server import MCPServer, Server, ServerRequestContext
17+
from mcp.server.context import CallNext, HandlerResult
1718
from mcp.server.mcpserver.exceptions import InvalidSignature
1819

1920
# See test_index.py for why this is a per-module mark and not a conftest hook.
@@ -140,3 +141,35 @@ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams |
140141
async with Client(server) as modern:
141142
assert modern.protocol_version == "2026-07-28"
142143
assert (await modern.list_tools()).tools == []
144+
145+
146+
@pytest.mark.parametrize(
147+
("server", "expected"),
148+
[(tutorial002.server, ["tools/list", "tools/call"]), (tutorial003.server, ["tools/call"])],
149+
ids=["tutorial002", "tutorial003"],
150+
)
151+
async def test_a_call_runs_the_list_handler_unless_the_server_looks_schemas_up_by_name(
152+
server: Server, expected: list[str], monkeypatch: pytest.MonkeyPatch
153+
) -> None:
154+
"""tutorial002 and tutorial003: the client's own `tools/call`, replayed, dispatches a `tools/list` first
155+
on the server without `get_tool_input_schema` and only itself on the server with it."""
156+
dispatched: list[str] = []
157+
158+
async def record(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
159+
dispatched.append(ctx.method)
160+
return await call_next(ctx)
161+
162+
monkeypatch.setattr(server, "middleware", [*server.middleware, record])
163+
async with check_stock_over_http(server.streamable_http_app()) as (http, call):
164+
dispatched.clear()
165+
replayed = await http.post(URL, content=call.content, headers=call.headers)
166+
assert replayed.status_code == 200
167+
assert dispatched == expected
168+
169+
170+
async def test_the_schema_the_lookup_returns_is_the_one_the_header_is_checked_against() -> None:
171+
"""tutorial003: the client's own request, replayed with a different `Mcp-Param-Region`, is a 400."""
172+
async with check_stock_over_http(tutorial003.app) as (http, call):
173+
tampered = await http.post(URL, content=call.content, headers={**call.headers, "mcp-param-region": "us"})
174+
assert tampered.status_code == 400
175+
assert tampered.json()["error"]["code"] == HEADER_MISMATCH

0 commit comments

Comments
 (0)