diff --git a/docs/advanced/header-parameters.md b/docs/advanced/header-parameters.md index a47be2f874..b2722642eb 100644 --- a/docs/advanced/header-parameters.md +++ b/docs/advanced/header-parameters.md @@ -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. The rest of the hand-written `Server` API is **[The low-level Server](low-level-server.md)**. diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 5d49846b5f..111ad62799 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -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 diff --git a/docs/migration.md b/docs/migration.md index b4b95337c2..91379bd0f6 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -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? If you encounter issues during migration: diff --git a/docs/whats-new.md b/docs/whats-new.md index 6627f794cd..a43dad162f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -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. * **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. diff --git a/docs_src/header_parameters/tutorial003.py b/docs_src/header_parameters/tutorial003.py new file mode 100644 index 0000000000..a93506de19 --- /dev/null +++ b/docs_src/header_parameters/tutorial003.py @@ -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() diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index 1932a5d9d2..1a19061fcd 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -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") @@ -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) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index c276bc4c36..6ce537182e 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -143,6 +143,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], @@ -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 diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 0fceb41bf9..99b5a56c0a 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -218,6 +218,7 @@ def __init__( icons=icons, version=version, cache_hints=cache_hints, + get_tool_input_schema=self._tool_input_schema, 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 + async def _handle_call_tool( self, ctx: ServerRequestContext[LifespanResultT], params: CallToolRequestParams ) -> CallToolResult | InputRequiredResult: diff --git a/tests/docs_src/test_header_parameters.py b/tests/docs_src/test_header_parameters.py index 5466dba9d6..225c3a59f9 100644 --- a/tests/docs_src/test_header_parameters.py +++ b/tests/docs_src/test_header_parameters.py @@ -2,7 +2,7 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from typing import Annotated, Literal +from typing import Annotated, Any, Literal import httpx2 import pytest @@ -10,10 +10,11 @@ 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. @@ -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 diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 2558872545..5d473550b9 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -9,7 +9,7 @@ import json import logging from collections.abc import Callable -from typing import Any +from typing import Annotated, Any import anyio import httpx2 @@ -40,6 +40,7 @@ Tool, ) from mcp_types.version import LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS +from pydantic import Field from starlette.types import Message, Receive, Scope, Send from trio.testing import MockClock @@ -49,6 +50,8 @@ _to_jsonrpc_response, handle_modern_request, ) +from mcp.server.context import CallNext, HandlerResult +from mcp.server.mcpserver import MCPServer from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ServerEvent from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import MCPError, NoBackChannelError @@ -1007,6 +1010,101 @@ async def recording_list(ctx: ServerRequestContext, params: PaginatedRequestPara assert seen[0].client_info.name == "raw" +async def test_modern_tools_call_on_mcpserver_validates_mcp_param_headers_without_listing() -> None: + """SDK-defined: `MCPServer` reads the called tool's schema from its registry, so the spec's + `Mcp-Param-*` verdicts hold while no `tools/list` runs for a `tools/call`. Raw HTTP because + a `Client` cannot send a mismatched header and issues listings of its own.""" + dispatched: list[str] = [] + + async def record(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult: + dispatched.append(ctx.method) + return await call_next(ctx) + + mcp = MCPServer("test", middleware=[record]) + + @mcp.tool() + def search(region: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Region"})]) -> str: + return region + + body = _tool_call_body({"region": "eu"}) + async with _asgi_client(mcp._lowlevel_server) as http: + matched = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"}) + mismatched = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "us"}) + missing = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS) + unknown = await http.post( + "/mcp", + json=_tool_call_body({"region": "eu"}, name="unregistered"), + headers={MCP_METHOD_HEADER: "tools/call", MCP_NAME_HEADER: "unregistered", "mcp-param-region": "us"}, + ) + + assert matched.status_code == 200 + assert matched.json()["result"]["structuredContent"] == {"result": "eu"} + assert (mismatched.status_code, mismatched.json()["error"]["code"]) == (400, HEADER_MISMATCH) + assert (missing.status_code, missing.json()["error"]["code"]) == (400, HEADER_MISMATCH) + # An unregistered tool has no schema to validate against; dispatch owns the unknown-tool answer. + assert unknown.status_code == 200 + assert unknown.json()["result"]["isError"] is True + assert dispatched == ["tools/call", "tools/call"] + + +async def test_modern_tools_call_asks_get_tool_input_schema_instead_of_listing() -> None: + """SDK-defined: a low-level server that passes `get_tool_input_schema` is asked for the called + tool's schema by name, so the spec's `Mcp-Param-*` verdicts hold while its `tools/list` handler + never runs for a `tools/call`.""" + dispatched: list[str] = [] + + async def record(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult: + dispatched.append(ctx.method) + return await call_next(ctx) + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + raise NotImplementedError + + schemas = {_REGION_TOOL.name: _REGION_TOOL.input_schema} + server: Server[Any] = Server( + "gateway", on_list_tools=list_tools, on_call_tool=_ok_call_tool, get_tool_input_schema=schemas.get + ) + server.middleware.append(record) + + body = _tool_call_body({"region": "eu"}) + async with _asgi_client(server) as http: + matched = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"}) + mismatched = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "us"}) + missing = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS) + unknown = await http.post( + "/mcp", + json=_tool_call_body({"region": "eu"}, name="unregistered"), + headers={MCP_METHOD_HEADER: "tools/call", MCP_NAME_HEADER: "unregistered", "mcp-param-region": "us"}, + ) + + assert matched.status_code == 200 + assert (mismatched.status_code, mismatched.json()["error"]["code"]) == (400, HEADER_MISMATCH) + assert (missing.status_code, missing.json()["error"]["code"]) == (400, HEADER_MISMATCH) + # `None` from the lookup means nothing to validate; the mismatched header is ignored. + assert unknown.status_code == 200 + assert dispatched == ["tools/call", "tools/call"] + + +async def test_modern_tools_call_skips_validation_when_get_tool_input_schema_raises( + caplog: pytest.LogCaptureFixture, +) -> None: + """A raising `get_tool_input_schema` fails open like a raising listing: the call is served and the skip logged.""" + + def unavailable(name: str) -> dict[str, Any] | None: + raise RuntimeError("catalog unavailable") + + server: Server[Any] = Server("gateway", on_call_tool=_ok_call_tool, get_tool_input_schema=unavailable) + with caplog.at_level(logging.ERROR, logger=_streamable_http_modern.__name__): + async with _asgi_client(server) as http: + response = await http.post( + "/mcp", + json=_tool_call_body({"region": "us"}), + headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"}, + ) + assert response.status_code == 200 + assert "Mcp-Param header validation skipped: get_tool_input_schema raised" in caplog.text + + async def test_modern_tools_call_leaves_mis_shaped_name_and_arguments_to_dispatch() -> None: """A missing `name` or non-mapping `arguments` is dispatch's INVALID_PARAMS, never a header mismatch.""" async with _asgi_client(_x_mcp_server()) as http: