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
12 changes: 12 additions & 0 deletions docs/advanced/header-parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,23 @@ There you write `input_schema` by hand, so the key goes straight in:

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

### Schemas by name

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.

```python title="server.py" hl_lines="26 39-41 48"
--8<-- "docs_src/header_parameters/tutorial003.py"
```

* Pass the function to answer from what you already have.
* Return `None` for a tool with nothing to check.

## Recap

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The new callback path makes the low-level Server validate Mcp-Param-* header/body agreement, so the recap's blanket “checks nothing” claim now contradicts this section. Qualify that the low-level server does not validate annotation schemas itself, while noting that get_tool_input_schema enables request validation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/advanced/header-parameters.md, line 57:

<comment>The new callback path makes the low-level `Server` validate `Mcp-Param-*` header/body agreement, so the recap's blanket “checks nothing” claim now contradicts this section. Qualify that the low-level server does not validate annotation schemas itself, while noting that `get_tool_input_schema` enables request validation.</comment>

<file context>
@@ -37,11 +37,23 @@ There you write `input_schema` by hand, so the key goes straight in:
 * The server rejects a call whose header and body disagree.
 * Only `str`, `int` and `bool` arguments can be marked. `MCPServer` raises `InvalidSignature` for anything else.
 * The low-level `Server` checks nothing, and clients drop a tool whose annotation is invalid.
+* `get_tool_input_schema` keeps the low-level `Server` from running `on_list_tools` on every call.
 
 The rest of the hand-written `Server` API is **[The low-level Server](low-level-server.md)**.
</file context>


The rest of the hand-written `Server` API is **[The low-level Server](low-level-server.md)**.
1 change: 1 addition & 0 deletions docs/advanced/low-level-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ Each of these is one idea you now have the vocabulary for; each has its own page
* `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)**).
* `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.
* `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.
* `get_tool_input_schema` keeps `on_list_tools` off the call path; see **[Header parameters](header-parameters.md#schemas-by-name)**.
* `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.

## Recap
Expand Down
8 changes: 0 additions & 8 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2849,14 +2849,6 @@ On a 2026-07-28 connection, `notifications/tools/list_changed`, `notifications/p

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.

### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))

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.

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.

## Need Help?

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.

🟡 (optional) Users lose the only documentation of several Mcp-Param-* behaviours, and the closed v1->v2 migration record is edited by deleting a whole section. AGENTS.md says docs/migration.md is closed to new entries and only corrections or clarity edits are fine; the diff removes the entire SEP-2243 section before docs/migration.md:2852. Fix: keep the section and only correct the sentence about the internal listing (now get_tool_input_schema or the registry for MCPServer), or move its still-true facts to docs/advanced/header-parameters.md before removing it: strict base64 decoding, duplicate recognized header rejection, and the public decode_header_value/validate_mcp_param_headers codec in mcp.shared.inbound. [also at: docs/migration.md:2853 - nit: AGENTS.md says docs/migration.md is a closed record where only corrections and clarity edits are fine. The diff deletes the whole 'Servers validate Mcp-Param-* headers against the request body (SEP-2243)' section, including still-accurate content (client-side auto-emission, duplicate-header rejection, strict base64-sentinel decoding, the public mcp.shared.inbound codec) and drops the whats-new.md link to it.]

Why this was flagged

The diff deletes the section "Servers validate Mcp-Param-* headers against the request body (SEP-2243)" from docs/migration.md (base lines 2852-2858) and the link to it from docs/whats-new.md:202. AGENTS.md states docs/migration.md "is closed to new entries. Correcting errors or improving clarity in what's there is fine."; removing a section is neither. A grep of docs/ shows no other page mentions decode_header_value, validate_mcp_param_headers, the rejection of a recognized header supplied more than once, or the strict base64-sentinel decoding of Mcp-Name; docs/advanced/header-parameters.md does not cover them either. After merge a reader of the published docs has no page describing those behaviours, whereas on the base branch the migration guide and the whats-new link lead to them. Only one sentence of the deleted section (the internal listing) became stale with this change.

Verification: /home/claude/python-sdk/AGENTS.md:19-20 reads "docs/migration.md is the v1 → v2 record and is closed to new entries. Correcting errors or improving clarity in what's there is fine." The diff (hunk @@ -2849,14 +2849,6 @@) deletes the entire SEP-2243 section rather than correcting the one now-stale sentence. The other statements were still true and are not re-homed anywhere.


If you encounter issues during migration:
Expand Down
2 changes: 1 addition & 1 deletion docs/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are repla
### The rest, quickly

* **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.
* **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.
* **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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: This removes the What's New page's path to the detailed header-parameter rules after moving them out of the Migration Guide. Link this summary to advanced/header-parameters.md instead of dropping the documentation link.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/whats-new.md, line 202:

<comment>This removes the What's New page's path to the detailed header-parameter rules after moving them out of the Migration Guide. Link this summary to `advanced/header-parameters.md` instead of dropping the documentation link.</comment>

<file context>
@@ -199,7 +199,7 @@ At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are repla
 
 * **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.
-* **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.
+* **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.
 * **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)**.
 * **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)**.
</file context>
Suggested change
* **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.
* **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 **[Header parameters](advanced/header-parameters.md)** page has the rules.

* **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)**.
* **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)**.
* **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.
Expand Down
50 changes: 50 additions & 0 deletions docs_src/header_parameters/tutorial003.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from typing import Any

from mcp.server import Server, ServerRequestContext
from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)

CHECK_STOCK = Tool(
name="check_stock",
description="Count the copies of a book in one region's warehouses.",
input_schema={
"type": "object",
"properties": {
"title": {"type": "string"},
"region": {"type": "string", "x-mcp-header": "Region"},
},
"required": ["title", "region"],
},
)

TOOLS = {CHECK_STOCK.name: CHECK_STOCK}


async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=list(TOOLS.values()))


async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
args = params.arguments or {}
text = f"{args['title']}: 3 copies in {args['region']}."
return CallToolResult(content=[TextContent(type="text", text=text)])


def tool_input_schema(name: str) -> dict[str, Any] | None:
tool = TOOLS.get(name)
return tool.input_schema if tool else None


server = Server(
"Bookshop",
on_list_tools=list_tools,
on_call_tool=call_tool,
get_tool_input_schema=tool_input_schema,
)
app = server.streamable_http_app()
18 changes: 14 additions & 4 deletions src/mcp/server/_streamable_http_modern.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,10 +339,12 @@ async def _mcp_param_rejection(
"""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.
plain `application/json` 400 (the spec's MUST). The schema comes from the
server's `get_tool_input_schema` when set, else from its `tools/list` handler;
with neither there is no recognized header to validate.
"""
if req.method != "tools/call" or app.get_request_handler("tools/list") is None:
lookup = app.get_tool_input_schema
if req.method != "tools/call" or (lookup is None and app.get_request_handler("tools/list") is None):
return None
params = req.params or {}
name = params.get("name")
Expand All @@ -356,7 +358,15 @@ async def _mcp_param_rejection(
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 lookup is None:
input_schema = await _tool_input_schema(app, request, req.id, verdict, lifespan_state, name)
else:
try:
input_schema = lookup(name)
except Exception:
# Fail-open like a failed listing: header validation must never break a working call path.
logger.exception("Mcp-Param header validation skipped: get_tool_input_schema raised")
return None
if input_schema is None:
return None
return validate_mcp_param_headers(input_schema, arguments, request.headers)
Expand Down
11 changes: 11 additions & 0 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ def __init__(
[Server[LifespanResultT]],
AbstractAsyncContextManager[LifespanResultT],
] = lifespan,
get_tool_input_schema: Callable[[str], Mapping[str, Any] | None] | None = None,

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.

🟡 (optional) A low-level server whose lookup returns a non-dict Mapping gets no Mcp-Param-* validation at all, silently, where the base always validated. The new parameter is typed Callable[[str], Mapping[str, Any] | None] (src/mcp/server/lowlevel/server.py:146), but the validator only walks dict nodes: _walk_schema_positions skips anything that is not a dict (src/mcp/shared/inbound.py:133), so a MappingProxyType or ChainMap schema yields zero annotated positions and every mismatched header is accepted with no log line. Fix: make the accepted type and the walker agree for every lookup result, either by annotating the parameter and attribute as dict[str, Any] | None or by having the walker accept Mapping at inbound.py:133 and :138. [also at: src/mcp/server/_streamable_http_modern.py:365 - Low-level servers whose get_tool_input_schema returns a non-dict Mapping get no header check: a mismatched Mcp-Param-* header is served instead of a 400.; src/mcp/server/_streamable_http_modern.py:372 - Low-level servers whose get_tool_input_schema returns a non-dict value get no Mcp-Param-* validation and no log line, so mismatched headers are served.]

Why this was flagged

A gateway author passes get_tool_input_schema=catalog.get where the catalog stores read-only schemas as types.MappingProxyType or a ChainMap over backend catalogs; pyright accepts it because the parameter is Mapping[str, Any] | None at src/mcp/server/lowlevel/server.py:146. On a tools/call, _mcp_param_rejection calls the lookup at src/mcp/server/_streamable_http_modern.py:365 and passes the result straight to validate_mcp_param_headers at :372. find_invalid_x_mcp_header and _annotated_positions both iterate _walk_schema_positions, which at src/mcp/shared/inbound.py:133 does if not isinstance(node, dict): continue, so a non-dict Mapping root produces no positions. The validator returns None and a request whose Mcp-Param-Region disagrees with the body is served with 200 instead of the spec's 400 -32020, and nothing is logged. On the base branch the schema always came from serve_one's JSON-dumped result (tool.get("inputSchema") at :310), which is a plain dict, so this input never reached the walker.

Verification: src/mcp/server/lowlevel/server.py:146 types the parameter Callable[[str], Mapping[str, Any] | None] | None; src/mcp/server/_streamable_http_modern.py:365-372 does input_schema = lookup(name) and passes it unchanged to validate_mcp_param_headers; src/mcp/shared/inbound.py:130-134 does if not isinstance(node, dict): continue, so a MappingProxyType root is dropped before yielding anything.

# Request handlers
on_list_tools: Callable[
[ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None],
Expand Down Expand Up @@ -226,6 +227,7 @@ def __init__(
[Server[LifespanResultT]],
AbstractAsyncContextManager[LifespanResultT],
] = lifespan,
get_tool_input_schema: Callable[[str], Mapping[str, Any] | None] | None = None,
# Request handlers
on_list_tools: Callable[
[ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None],
Expand Down Expand Up @@ -318,6 +320,7 @@ def __init__(
[Server[LifespanResultT]],
AbstractAsyncContextManager[LifespanResultT],
] = lifespan,
get_tool_input_schema: Callable[[str], Mapping[str, Any] | None] | None = None,
# Request handlers
on_list_tools: Callable[
[ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None],
Expand Down Expand Up @@ -425,6 +428,14 @@ def __init__(
# after the handler returns; fields the handler set explicitly win.
self.cache_hints: dict[str, CacheHint] = validate_cache_hints(cache_hints)
self.lifespan = lifespan
self.get_tool_input_schema = get_tool_input_schema
"""Returns a tool's input schema by name, or `None` when there is nothing to validate.
When set, `Mcp-Param-*` header validation on the 2026-07-28 Streamable HTTP
path calls this instead of running the `tools/list` handler. It is called
before middleware runs, so it is not scoped to the caller. If it raises,
the error is logged and the call is served unvalidated.
"""
self._request_handlers: dict[str, HandlerEntry[LifespanResultT]] = {}
self._notification_handlers: dict[str, HandlerEntry[LifespanResultT]] = {}
self._session_manager: StreamableHTTPSessionManager | None = None
Expand Down
6 changes: 6 additions & 0 deletions src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ def __init__(
icons=icons,
version=version,
cache_hints=cache_hints,
get_tool_input_schema=self._tool_input_schema,

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.

🟡 nit (optional): AGENTS.md says any change to an existing v2 API's signature or observable behaviour is an explicit maintainer design decision to generally avoid. Wiring get_tool_input_schema=self._tool_input_schema changes what MCPServer observably does on every validated tools/call: middleware no longer sees the internal tools/list, a tool middleware hides is now validated, and a list_tools() override's extra tools are no longer validated; Server.__init__ also gains a keyword (lowlevel/server.py:146). Fix: have a maintainer sign off on the behaviour change on the linked issue, or keep the old listing path as the default and make the registry lookup opt-in.

Why this was flagged

Nothing fails at runtime. The instruction guards the 2.x compatibility contract: a user whose middleware filters tools/list to hide a tool from a caller previously saw that caller's tools/call skip Mcp-Param validation; after this change the same call is validated against the registered schema and can now be rejected with 400/-32020. A subclass overriding MCPServer.list_tools() to add tools outside the registry loses validation for those tools. The PR description lists these under 'What users will notice', so the author knows; the instruction asks that a maintainer make this decision explicitly. Cost of the old behaviour (a full listing per call) is the motivation cited in #3565.

Verification: AGENTS.md (base cafa33b) "Branching Model": "v2 is released; its public API is a compatibility contract for the 2.x line. Removals, renames, or any change to an existing API's signature or observable behaviour ... is a design decision a maintainer makes explicitly, and should generally be avoided."

on_list_tools=self._handle_list_tools,
on_call_tool=self._handle_call_tool,
on_list_resources=self._handle_list_resources,
Expand Down Expand Up @@ -428,6 +429,11 @@ async def _handle_list_tools(
) -> ListToolsResult:
return ListToolsResult(tools=await self.list_tools())

def _tool_input_schema(self, name: str) -> dict[str, Any] | None:
"""Called before middleware runs, so it also finds a tool that middleware hides from the caller."""
tool = self._tool_manager.get_tool(name)
return None if tool is None else tool.parameters
Comment on lines +432 to +435

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.

🔴 Clients of an MCPServer whose middleware rewrites tools/list schemas start getting HTTP 400 -32020 on calls the base branch served. _tool_input_schema at src/mcp/server/mcpserver/server.py:435 returns the registry schema, so a middleware that strips or renames x-mcp-header in the listing no longer affects validation; a client that listed through that middleware sends no header and is rejected pre-dispatch. Fix: validate against the schema the caller was shown, or give MCPServer an opt-out such as its own get_tool_input_schema= keyword that can be set to lambda name: None, and state the 400 consequence in the docs. The PR text calls this a noticed change; the concrete effect is a rejected call, not a skipped check. [also at: src/mcp/server/mcpserver/server.py:434 - Callers of an MCPServer tool that a middleware hides from tools/list now get HTTP 400 -32020 on calls that the base branch served.]

Why this was flagged

An MCPServer registers a tool with an x-mcp-header annotated argument and installs a middleware that rewrites tools/list results. A 2026-07-28 HTTP client lists tools through that middleware, sees no annotation, and sends tools/call with the argument in the body and no Mcp-Param-* header. On the base branch _tool_input_schema in src/mcp/server/_streamable_http_modern.py ran the server's own tools/list through middleware, found no annotation and skipped validation, so the call was dispatched. After this change _mcp_param_rejection at src/mcp/server/_streamable_http_modern.py:365 calls MCPServer._tool_input_schema (src/mcp/server/mcpserver/server.py:435), which reads self._tool_manager.get_tool(name).parameters before middleware, finds the annotation, and validate_mcp_param_headers returns a 400 with error code -32020 for the missing header. No safeguard applies: the lookup is wired unconditionally at src/mcp/server/mcpserver/server.py:221 and MCPServer exposes no way to disable or replace it.

Verification: _mcp_param_rejection now takes lookup = app.get_tool_input_schema and calls lookup(name) directly (new lines 344-369), bypassing serve_one and therefore all middleware. MCPServer._tool_input_schema (src/mcp/server/mcpserver/server.py:432-435) returns self._tool_manager.get_tool(name).parameters, the raw registry schema with the x-mcp-header annotation intact.


async def _handle_call_tool(
self, ctx: ServerRequestContext[LifespanResultT], params: CallToolRequestParams
) -> CallToolResult | InputRequiredResult:
Expand Down
37 changes: 35 additions & 2 deletions tests/docs_src/test_header_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated, Literal
from typing import Annotated, Any, Literal

import httpx2
import pytest
from mcp_types import HEADER_MISMATCH, ListToolsResult, PaginatedRequestParams
from pydantic import Field, WithJsonSchema
from starlette.applications import Starlette

from docs_src.header_parameters import tutorial001, tutorial002
from docs_src.header_parameters import tutorial001, tutorial002, tutorial003
from mcp import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server import MCPServer, Server, ServerRequestContext
from mcp.server.context import CallNext, HandlerResult
from mcp.server.mcpserver.exceptions import InvalidSignature

# See test_index.py for why this is a per-module mark and not a conftest hook.
Expand Down Expand Up @@ -140,3 +141,35 @@ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams |
async with Client(server) as modern:
assert modern.protocol_version == "2026-07-28"
assert (await modern.list_tools()).tools == []


@pytest.mark.parametrize(
("server", "expected"),
[(tutorial002.server, ["tools/list", "tools/call"]), (tutorial003.server, ["tools/call"])],
ids=["tutorial002", "tutorial003"],
)
async def test_a_call_runs_the_list_handler_unless_the_server_looks_schemas_up_by_name(
server: Server, expected: list[str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""tutorial002 and tutorial003: the client's own `tools/call`, replayed, dispatches a `tools/list` first
on the server without `get_tool_input_schema` and only itself on the server with it."""
dispatched: list[str] = []

async def record(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
dispatched.append(ctx.method)
return await call_next(ctx)

monkeypatch.setattr(server, "middleware", [*server.middleware, record])
async with check_stock_over_http(server.streamable_http_app()) as (http, call):
dispatched.clear()
replayed = await http.post(URL, content=call.content, headers=call.headers)
assert replayed.status_code == 200
assert dispatched == expected


async def test_the_schema_the_lookup_returns_is_the_one_the_header_is_checked_against() -> None:
"""tutorial003: the client's own request, replayed with a different `Mcp-Param-Region`, is a 400."""
async with check_stock_over_http(tutorial003.app) as (http, call):
tampered = await http.post(URL, content=call.content, headers={**call.headers, "mcp-param-region": "us"})
assert tampered.status_code == 400
assert tampered.json()["error"]["code"] == HEADER_MISMATCH
Loading
Loading