Repository navigation
Look the tool schema up by name for Mcp-Param-* validation instead of running tools/list - #3630
Conversation
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
📚 Documentation preview
|
There was a problem hiding this comment.
2 issues found across 10 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. 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. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="docs/whats-new.md">
<violation number="1" location="docs/whats-new.md:202">
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.</violation>
</file>
<file name="docs/advanced/header-parameters.md">
<violation number="1" location="docs/advanced/header-parameters.md:57">
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.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| * 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. |
There was a problem hiding this comment.
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>
|
|
||
| * **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. |
There was a problem hiding this comment.
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>
| * **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. |
| 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 |
There was a problem hiding this comment.
🔴 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.
| [Server[LifespanResultT]], | ||
| AbstractAsyncContextManager[LifespanResultT], | ||
| ] = lifespan, | ||
| get_tool_input_schema: Callable[[str], Mapping[str, Any] | None] | None = None, |
There was a problem hiding this comment.
🟡 (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.
|
|
||
| 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? |
There was a problem hiding this comment.
🟡 (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.
| return region | ||
|
|
||
| body = _tool_call_body({"region": "eu"}) | ||
| async with _asgi_client(mcp._lowlevel_server) as http: |
There was a problem hiding this comment.
🟡 nit (optional): the new MCPServer validation test reaches through the private mcp._lowlevel_server attribute at tests/server/test_streamable_http_modern.py:1030 instead of the public streamable_http_app(), which the test-quality skill AGENTS.md binds reviewers to disallows. The test is the one proving MCPServer no longer lists on each call, so it should read like user code. Fix: drive the MCPServer through its public ASGI app (or a public accessor) so the test does not depend on a private attribute, and apply the same to the sibling MCPServer tests added in this diff.
Why this was flagged
AGENTS.md:68 says tests must conform to .claude/skills/test-quality/SKILL.md, whose line 45 bars private imports unless they are the documented way. The new test at tests/server/test_streamable_http_modern.py:1030 passes mcp._lowlevel_server to _asgi_client. The dismissal relies on pyright's…
Verification: nit. SKILL.md:44-45 "Tests read like real user code: ... no private imports unless that is genuinely the documented way to do the thing." The new test at tests/server/test_streamable_http_modern.py:1030 does async with _asgi_client(mcp._lowlevel_server) as http: where mcp is an MCPServer; _lowlevel_server is a private attribute (src/mcp/server/mcpserver/server.py:212). Nothing fails at runtime.
| icons=icons, | ||
| version=version, | ||
| cache_hints=cache_hints, | ||
| get_tool_input_schema=self._tool_input_schema, |
There was a problem hiding this comment.
🟡 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."
Fixes #3565.
What was wrong
On the 2026-07-28 Streamable HTTP path, a
tools/callthat carries arguments is checked against the called tool'sx-mcp-headerdeclarations before dispatch. To find that tool's input schema, the server ran its owntools/listhandler, page by page, on every such call.What changes
MCPServerlooks the called tool up by name among its registered tools. Nothing to configure.The low-level
Servertakes a new optional keyword,get_tool_input_schema=:Nonewhen there is nothing to validate.tools/listhandler.x-mcp-headeron any tool can passlambda name: None.server.get_tool_input_schema.What users will notice
MCPServer:tools/listfor eachtools/call.tools/listresults no longer affects validation.MCPServer.list_tools()to add tools outside the registry no longer gets those tools validated.Serverwithget_tool_input_schema=:tools/listhandler does not run for atools/call.tools/listhandler is registered.Nonefrom the function skips validation for that call. It does not fall back to the listing.What is unchanged
Serverwithout the keyword resolves the schema throughtools/listexactly as before.-32020error code. Apart from the cases above, the same requests are accepted and rejected.Mcp-Param-*headers is still not validated.Docs
docs/advanced/header-parameters.mdgains a short section, "Schemas by name", under "On the low-levelServer", with a runnable example (docs_src/header_parameters/tutorial003.py) and a recap line.docs/advanced/low-level-server.mdgains one bullet under "The other handlers" that points to that section.Mcp-Param-*headers against the request body" section is removed fromdocs/migration.md, along with the link to it indocs/whats-new.md. The check is new in 2026-07-28, so there is nothing to migrate from v1, and the section described the internal listing this change replaces.How it was checked
tests/server/test_streamable_http_modern.py:MCPServertool with anx-mcp-headerargument is accepted with a matching header, rejected with a mismatched or missing one, and an unregistered tool is left to dispatch. Middleware sees only the two dispatchedtools/calls. Onmainit also sees atools/listfor each request.get_tool_input_schemagets the same outcomes and itstools/listhandler never runs.get_tool_input_schemais logged and the call is served.tests/docs_src/test_header_parameters.py, over the 2026-07-28 HTTP path: the page's low-level example without the keyword dispatches atools/listbefore eachtools/call, the one with it dispatches only thetools/call, and it still rejects a mismatchedMcp-Param-Regionheader.inputSchemathattools/listreturns.http-custom-header-server-validationconformance scenario passes against the everything-server../scripts/test(100% coverage),ruffandpyrightare clean.AI Disclaimer