Repository navigation
Look the tool schema up by name for Mcp-Param-* validation instead of running tools/list #3630
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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? | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Why this was flaggedThe diff deletes the section "Servers validate Verification: /home/claude/python-sdk/AGENTS.md:19-20 reads " |
||
|
|
||
| If you encounter issues during migration: | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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. | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents
Suggested change
|
||||||
| * **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. | ||||||
|
|
||||||
| 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() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -143,6 +143,7 @@ def __init__( | |
| [Server[LifespanResultT]], | ||
| AbstractAsyncContextManager[LifespanResultT], | ||
| ] = lifespan, | ||
| get_tool_input_schema: Callable[[str], Mapping[str, Any] | None] | None = None, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 (optional) A low-level server whose lookup returns a non-dict Why this was flaggedA gateway author passes Verification: |
||
| # Request handlers | ||
| on_list_tools: Callable[ | ||
| [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], | ||
|
|
@@ -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], | ||
|
|
@@ -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], | ||
|
|
@@ -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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -218,6 +218,7 @@ def __init__( | |
| icons=icons, | ||
| version=version, | ||
| cache_hints=cache_hints, | ||
| get_tool_input_schema=self._tool_input_schema, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Why this was flaggedNothing fails at runtime. The instruction guards the 2.x compatibility contract: a user whose middleware filters 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, | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Clients of an Why this was flaggedAn Verification: |
||
|
|
||
| async def _handle_call_tool( | ||
| self, ctx: ServerRequestContext[LifespanResultT], params: CallToolRequestParams | ||
| ) -> CallToolResult | InputRequiredResult: | ||
|
|
||
There was a problem hiding this comment.
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
ServervalidateMcp-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 thatget_tool_input_schemaenables request validation.Prompt for AI agents