From fe11e39c1be92194442b43aea6cfd3677f524275 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:09:06 +0000 Subject: [PATCH 1/4] Retry a tool call once after a HeaderMismatch rejection On a 2026-07-28 connection, `Client.call_tool` now recovers from a `-32020` (`HeaderMismatch`) rejection the way the spec recommends: it refetches the tool listing, following cursors until a page lists the tool, and resends the call once with the `Mcp-Param-*` headers the current schema asks for. A second rejection is raised, and so is the first when the listing cannot be refetched. Legacy connections and `ClientSession.call_tool` are unchanged. The migration guide's section on `Mcp-Param-*` header validation is removed, with the link to it from the What's new page: the feature is new in 2026-07-28, so it is not a v1-to-v2 migration topic. Fixes #3483 --- docs/migration.md | 8 - docs/whats-new.md | 2 +- src/mcp/client/client.py | 35 ++- tests/interaction/_requirements.py | 11 + .../transports/test_hosting_http_modern.py | 279 +++++++++++++++++- 5 files changed, 310 insertions(+), 25 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 60fb440546..df01f07d46 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -2865,14 +2865,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/src/mcp/client/client.py b/src/mcp/client/client.py index f921c7e30b..884fc9e915 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -8,12 +8,13 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence from contextlib import AbstractAsyncContextManager, AsyncExitStack from dataclasses import KW_ONLY, dataclass, field -from typing import Any, Literal, TypeVar, cast +from typing import Any, Final, Literal, TypeVar, cast import anyio import anyio.lowlevel import mcp_types as types from mcp_types import ( + HEADER_MISMATCH, INVALID_PARAMS, CacheableResult, CallToolResult, @@ -79,6 +80,9 @@ initialize), or a modern protocol-version string (adopt directly). The ``str`` arm is for forward-compat; ``Client.__post_init__`` rejects anything outside that set at construction.""" +_RELIST_PAGE_CAP: Final = 100 +"""Page cap for the tools/list walk that follows a `HEADER_MISMATCH`: a paginator that never ends cannot hang a call.""" + _T = TypeVar("_T") _ResultT = TypeVar("_ResultT") _CacheableT = TypeVar("_CacheableT", bound=CacheableResult) @@ -775,6 +779,11 @@ async def call_tool( exceptions propagate as-is. To receive the claimed shape yourself, use `client.session.call_tool(..., allow_claimed=True)`. + On a 2026-07-28 connection, a call the server rejects with `HEADER_MISMATCH` + (this client has not listed the tool, or its input schema changed since) is + resent once after refetching the tool listing. A second rejection is raised, + and so is the first when the listing cannot be refetched. + Args: name: The name of the tool to call. arguments: Arguments to pass to the tool. @@ -795,7 +804,7 @@ async def call_tool( conform to the negotiated protocol version. """ - async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult | Result: + async def send(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult | Result: return await self.session.call_tool( name, arguments, @@ -809,6 +818,19 @@ async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | Inp allow_claimed=True, ) + async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult | Result: + try: + return await send(r, s) + except MCPError as mismatch: + if mismatch.code != HEADER_MISMATCH or self.protocol_version not in MODERN_PROTOCOL_VERSIONS: + raise + # The spec's recovery: the tool's listed schema is missing or stale, so re-list and resend once. + try: + await self._relist_tool(name) + except MCPError as relist_error: + raise mismatch from relist_error + return await send(r, s) + result = await self._drive_input_required(await retry(input_responses, request_state), retry) if isinstance(result, CallToolResult): return result @@ -943,6 +965,15 @@ async def list_tools( ), ) + async def _relist_tool(self, name: str) -> None: + """Refetch the tool listing from the server, page by page, until a page lists `name`.""" + cursor: str | None = None + for _ in range(_RELIST_PAGE_CAP): + page = await self.list_tools(cursor=cursor, cache_mode="refresh") + cursor = page.next_cursor + if cursor is None or any(tool.name == name for tool in page.tools): + return + @deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) async def send_roots_list_changed(self) -> None: """Send a notification that the roots list has changed.""" diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 235fb65cd4..89101ab1f4 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -3624,6 +3624,17 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="Only observable over streamable HTTP: headers are derived from the cached tool schema at the seam.", ), + "client-transport:http:header-mismatch-recovery": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#client-behavior", + behavior=( + "When the server rejects a tools/call with HeaderMismatch, the client calls tools/list for the " + "tool's current inputSchema and retries the call once with the Mcp-Param-* headers that schema " + "asks for. A second rejection is raised to the caller." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note="Client.call_tool only: ClientSession.call_tool sends once and leaves the recovery to its caller.", + ), "client-transport:http:vendor-name-param-header": Requirement( source="sdk", behavior=( diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index e26c70f3bf..807f05605d 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -520,16 +520,150 @@ async def on_request(request: httpx2.Request) -> None: ) +def _method_and_param_headers(request: httpx2.Request) -> tuple[str, dict[str, str]]: + """A POST's JSON-RPC method and the `Mcp-Param-*` headers it carries.""" + param_headers = {k: v for k, v in request.headers.items() if k.startswith("mcp-param-")} + return json.loads(request.content)["method"], param_headers + + @requirement("client-transport:http:custom-param-headers") -async def test_modern_client_emits_no_param_headers_for_an_unlisted_tool() -> None: - """A `tools/call` for a tool the client never listed carries no `Mcp-Param-*` headers. - - The spec lets a client that lacks the tool's `inputSchema` send the request without custom headers. - The call is made with no prior `list_tools`, so the first `tools/call` POST -- captured before the - implicit output-schema `list_tools` runs -- has no cached annotations and emits no `Mcp-Param-*` header. - The server validates `Mcp-Param-*` against its own catalog and rejects as the spec's scenario table - requires for an omitted header (the relist-and-retry recovery is a SHOULD the client does not implement yet). +@requirement("client-transport:http:header-mismatch-recovery") +async def test_modern_client_re_lists_and_retries_a_call_rejected_for_an_unlisted_tool() -> None: + """A `tools/call` for a tool the client never listed succeeds after one re-list and one retry. + + Spec-mandated (a SHOULD). With no cached annotations the first `tools/call` carries no `Mcp-Param-*` + header, and the server rejects it as the spec's scenario table requires for an omitted header. The + client then calls `tools/list` and resends the call with the header the schema asks for. Asserted at + the wire because the client surfaces neither the rejection nor the outgoing headers. + """ + wire: list[tuple[str, dict[str, str]]] = [] + + async def on_request(request: httpx2.Request) -> None: + wire.append(_method_and_param_headers(request)) + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + ) + with anyio.fail_after(5): + async with ( + mounted_app(_custom_header_server(), on_request=on_request) as (http, _), + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, + ): + result = await client.call_tool("run", {"region": "us-west1"}) + + assert result.content == [TextContent(text="ok")] + assert wire == snapshot([("tools/call", {}), ("tools/list", {}), ("tools/call", {"mcp-param-region": "us-west1"})]) + + +@requirement("client-transport:http:header-mismatch-recovery") +async def test_modern_client_re_lists_and_retries_when_a_listed_tool_gains_a_header_annotation() -> None: + """A tool whose schema gains an `x-mcp-header` annotation after it was listed is still called successfully. + + Spec-mandated (a SHOULD). The stale listing mirrors nothing, so the server rejects the call; the re-list + replaces the listing the client had cached, and the retry carries the header. The final `list_tools` is + served from that cache, within the server's TTL, and returns the new schema without a request. + """ + plain = {"type": "object", "properties": {"a": {"type": "string"}}} + annotated = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "Region"}}} + schemas = [plain] + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + tool = Tool(name="run", input_schema=schemas[-1]) + return ListToolsResult(tools=[tool], ttl_ms=60_000, cache_scope="public") + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + return CallToolResult(content=[TextContent(text="ok")]) + + server = Server("drift", on_list_tools=list_tools, on_call_tool=call_tool) + + wire: list[tuple[str, dict[str, str]]] = [] + + async def on_request(request: httpx2.Request) -> None: + wire.append(_method_and_param_headers(request)) + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + ) + with anyio.fail_after(5): + async with ( + mounted_app(server, on_request=on_request) as (http, _), + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, + ): + await client.list_tools() + schemas.append(annotated) + result = await client.call_tool("run", {"a": "x"}) + relisted = await client.list_tools() + + assert result.content == [TextContent(text="ok")] + assert [tool.input_schema for tool in relisted.tools] == [annotated] + assert wire == snapshot( + [("tools/list", {}), ("tools/call", {}), ("tools/list", {}), ("tools/call", {"mcp-param-region": "x"})] + ) + + +@requirement("client-transport:http:header-mismatch-recovery") +async def test_modern_client_raises_a_header_mismatch_that_survives_the_retry() -> None: + """A `HeaderMismatch` that a re-list does not cure is raised after exactly one retry. + + Spec-mandated for the single retry. An intermediary that strips `Mcp-Param-*` headers makes every + `tools/call` a mismatch, so the client re-lists, resends once with the header, and then raises the + server's rejection instead of trying again. + """ + wire: list[tuple[str, dict[str, str]]] = [] + + async def strip_param_headers(request: httpx2.Request) -> None: + wire.append(_method_and_param_headers(request)) + request.headers.pop("mcp-param-region", None) + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + ) + async with ( + mounted_app(_custom_header_server(), on_request=strip_param_headers) as (http, _), + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, + ): + with anyio.fail_after(5), pytest.raises(MCPError) as excinfo: + await client.call_tool("run", {"region": "us-west1"}) + + assert excinfo.value.error.code == HEADER_MISMATCH + assert wire == snapshot([("tools/call", {}), ("tools/list", {}), ("tools/call", {"mcp-param-region": "us-west1"})]) + + +@requirement("client-transport:http:header-mismatch-recovery") +async def test_modern_client_re_list_follows_cursors_only_as_far_as_the_page_listing_the_tool() -> None: + """The re-list walks a paginated listing up to the page that lists the tool, and no further. + + SDK-defined: the spec says to call `tools/list` for the tool's schema, and the schema of a tool on a + later page is only reached by following cursors. The server's handler fails on any cursor past page two. """ + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + if params is None or params.cursor is None: + other = Tool(name="other", input_schema={"type": "object"}) + return ListToolsResult(tools=[other], next_cursor="2", ttl_ms=0, cache_scope="public") + assert params.cursor == "2" + return ListToolsResult(tools=[_CUSTOM_HEADER_TOOL], next_cursor="3", ttl_ms=0, cache_scope="public") + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + return CallToolResult(content=[TextContent(text="ok")]) + + server = Server("paginated", on_list_tools=list_tools, on_call_tool=call_tool) + requests: list[httpx2.Request] = [] async def on_request(request: httpx2.Request) -> None: @@ -541,20 +675,137 @@ async def on_request(request: httpx2.Request) -> None: ) with anyio.fail_after(5): async with ( - mounted_app(_custom_header_server(), on_request=on_request) as (http, _), + mounted_app(server, on_request=on_request) as (http, _), Client( streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode=LATEST_MODERN_VERSION, prior_discover=discover, ) as client, ): - with pytest.raises(MCPError) as excinfo: # pragma: no branch - await client.call_tool("run", {"region": "us-west1"}) + result = await client.call_tool("run", {"region": "us-west1"}) + + assert result.content == [TextContent(text="ok")] + bodies = [json.loads(request.content) for request in requests] + assert [(body["method"], body["params"].get("cursor")) for body in bodies] == snapshot( + [("tools/call", None), ("tools/list", None), ("tools/list", "2"), ("tools/call", None)] + ) + assert requests[-1].headers["mcp-param-region"] == "us-west1" + + +@requirement("client-transport:http:header-mismatch-recovery") +async def test_modern_client_re_list_gives_up_after_100_pages_of_a_listing_that_never_ends() -> None: + """A listing whose cursors never end does not hang the recovery: the re-list stops at 100 pages. + + SDK-defined cap. An intermediary that rewrites `Mcp-Name` keeps every `tools/call` a mismatch whatever + the catalog holds, so the client walks the listing for a tool it never reaches, resends once, and raises. + """ + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[], next_cursor="more", ttl_ms=0, cache_scope="public") + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + raise NotImplementedError + + server = Server("endless", on_list_tools=list_tools, on_call_tool=call_tool) + + methods: list[str] = [] + + async def rewrite_mcp_name(request: httpx2.Request) -> None: + method = json.loads(request.content)["method"] + methods.append(method) + if method == "tools/call": + request.headers["mcp-name"] = "another-tool" + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + ) + async with ( + mounted_app(server, on_request=rewrite_mcp_name) as (http, _), + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, + ): + with anyio.fail_after(5), pytest.raises(MCPError) as excinfo: + await client.call_tool("run", {"region": "us-west1"}) + + assert excinfo.value.error.code == HEADER_MISMATCH + assert methods == ["tools/call", *["tools/list"] * 100, "tools/call"] + + +@requirement("client-transport:http:header-mismatch-recovery") +async def test_modern_client_raises_the_header_mismatch_when_the_re_list_fails() -> None: + """A re-list that fails leaves the caller with the server's `HeaderMismatch`, not the listing's error. + + SDK-defined: `call_tool` raises what a `tools/call` returned. The server has no `tools/list` handler, so + the re-list is refused; the rejection is raised with that refusal as its cause and the call is not resent. + """ + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + raise NotImplementedError + + server = Server("no-listing", on_call_tool=call_tool) + + methods: list[str] = [] + + async def rewrite_mcp_name(request: httpx2.Request) -> None: + method = json.loads(request.content)["method"] + methods.append(method) + if method == "tools/call": + request.headers["mcp-name"] = "another-tool" + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + ) + async with ( + mounted_app(server, on_request=rewrite_mcp_name) as (http, _), + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, + ): + with anyio.fail_after(5), pytest.raises(MCPError) as excinfo: + await client.call_tool("run", {"region": "us-west1"}) + + assert excinfo.value.error.code == HEADER_MISMATCH + cause = excinfo.value.__cause__ + assert isinstance(cause, MCPError) + assert cause.error.code == METHOD_NOT_FOUND + assert methods == ["tools/call", "tools/list"] + + +@requirement("client-transport:http:header-mismatch-recovery") +async def test_legacy_client_raises_a_header_mismatch_error_without_re_listing_or_retrying() -> None: + """On a pre-2026 connection a `-32020` error from a tool call is raised as it arrives. + + SDK-defined: the recovery belongs to the 2026-07-28 header contract. A legacy server never validates + `Mcp-Param-*` headers, so the code can only come from the tool's own handler, which a resend would run twice. + """ + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + raise MCPError(code=HEADER_MISMATCH, message="raised by the tool") + + server = Server("legacy", on_call_tool=call_tool) + + posted: list[str] = [] + + async def on_request(request: httpx2.Request) -> None: + if request.method == "POST": + posted.append(json.loads(request.content)["method"]) + + async with ( + mounted_app(server, on_request=on_request) as (http, _), + Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="legacy") as client, + ): + with anyio.fail_after(5), pytest.raises(MCPError) as excinfo: + await client.call_tool("run", {"region": "us-west1"}) assert excinfo.value.error.code == HEADER_MISMATCH - assert len(requests) == 1 - assert json.loads(requests[0].content)["method"] == "tools/call" - assert not any(k.startswith("mcp-param-") for k in requests[0].headers) + assert posted == snapshot(["initialize", "notifications/initialized", "tools/call"]) @requirement("client-transport:http:custom-param-headers") From 98d9dd04476fc0bff686f7aa28424ceb7f9e9313 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:43:41 +0000 Subject: [PATCH 2/4] Bound the re-list by the caller's timeout and chain every re-list failure `read_timeout_seconds` now covers the whole re-list after a `HeaderMismatch`, which otherwise ran on the session default. When it elapses, or a `tools/list` page fails validation, the original `-32020` is raised with that failure as its cause. --- src/mcp/client/client.py | 9 +- .../transports/test_hosting_http_modern.py | 108 ++++++++++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index 884fc9e915..a8beba0a2e 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -41,6 +41,7 @@ ServerCapabilities, ) from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS +from pydantic import ValidationError from typing_extensions import deprecated from mcp.client._input_required import DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, run_input_required_driver @@ -787,7 +788,8 @@ async def call_tool( Args: name: The name of the tool to call. arguments: Arguments to pass to the tool. - read_timeout_seconds: Timeout for each underlying `tools/call` round. + read_timeout_seconds: Timeout for each underlying `tools/call` round, and + for the whole re-list after a `HEADER_MISMATCH`. progress_callback: Callback for progress updates. input_responses: Responses to seed the first call with (e.g. when resuming from a persisted `InputRequiredResult`). @@ -826,8 +828,9 @@ async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | Inp raise # The spec's recovery: the tool's listed schema is missing or stale, so re-list and resend once. try: - await self._relist_tool(name) - except MCPError as relist_error: + with anyio.fail_after(read_timeout_seconds): + await self._relist_tool(name) + except (MCPError, TimeoutError, ValidationError) as relist_error: raise mismatch from relist_error return await send(r, s) diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index 807f05605d..d1e9166b8e 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -42,18 +42,26 @@ Tool, ) from mcp_types.version import LATEST_MODERN_VERSION +from pydantic import ValidationError +from trio.testing import MockClock from mcp import MCPError from mcp.client.client import Client from mcp.client.session import ClientSession from mcp.client.streamable_http import streamable_http_client from mcp.server import Server, ServerRequestContext +from mcp.server.context import CallNext, HandlerResult from tests.interaction._connect import BASE_URL, base_headers, initialize_via_http, mounted_app from tests.interaction._requirements import requirement pytestmark = pytest.mark.anyio +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + def _modern_headers(*, method: str, name: str | None = None) -> dict[str, str]: """Request headers for a 2026-07-28 POST. @@ -778,6 +786,106 @@ async def rewrite_mcp_name(request: httpx2.Request) -> None: assert methods == ["tools/call", "tools/list"] +@requirement("client-transport:http:header-mismatch-recovery") +async def test_modern_client_raises_the_header_mismatch_when_the_re_list_returns_a_malformed_page() -> None: + """A `tools/list` page that fails validation leaves the caller with the server's `HeaderMismatch`. + + SDK-defined: a caller's `except MCPError` still sees the rejection, with the `ValidationError` as its + cause, and the call is not resent. The page comes from a middleware that answers without `call_next`, + the one place the SDK server does not validate an outgoing result. + """ + + async def malformed_listing(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult: + assert ctx.method == "tools/list" + return {"tools": "not a list"} + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + raise NotImplementedError + + server = Server("malformed", on_call_tool=call_tool) + server.middleware.append(malformed_listing) + + methods: list[str] = [] + + async def rewrite_mcp_name(request: httpx2.Request) -> None: + method = json.loads(request.content)["method"] + methods.append(method) + if method == "tools/call": + request.headers["mcp-name"] = "another-tool" + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + ) + async with ( + mounted_app(server, on_request=rewrite_mcp_name) as (http, _), + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, + ): + with anyio.fail_after(5), pytest.raises(MCPError) as excinfo: + await client.call_tool("run", {"region": "us-west1"}) + + assert excinfo.value.error.code == HEADER_MISMATCH + assert isinstance(excinfo.value.__cause__, ValidationError) + assert methods == ["tools/call", "tools/list"] + + +# The timeout also governs the rejected `tools/call`, which must be answered before the re-list can +# wait it out, so any real-clock value is a bet against CI scheduler stalls. On trio's autojumping +# clock time advances only when every task is blocked: the answered call cannot time out however slow +# the runner, and once the re-list blocks the clock jumps straight to the deadline, with no real wait. +@requirement("client-transport:http:header-mismatch-recovery") +@pytest.mark.parametrize( + "anyio_backend", + [pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")], +) +async def test_modern_client_raises_the_header_mismatch_when_the_re_list_outlasts_the_read_timeout() -> None: + """The caller's `read_timeout_seconds` bounds the re-list, which otherwise has no timeout of its own. + + SDK-defined: the server rejects the call and then never answers `tools/list`. When the timeout elapses + the rejection is raised with the `TimeoutError` as its cause, and the call is not resent. + """ + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + await anyio.Event().wait() # blocks until the abandoned request's disconnect interrupts it + raise NotImplementedError # unreachable + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + raise NotImplementedError + + server = Server("stalled", on_list_tools=list_tools, on_call_tool=call_tool) + + methods: list[str] = [] + + async def rewrite_mcp_name(request: httpx2.Request) -> None: + method = json.loads(request.content)["method"] + methods.append(method) + if method == "tools/call": + request.headers["mcp-name"] = "another-tool" + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + ) + async with ( + mounted_app(server, on_request=rewrite_mcp_name) as (http, _), + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, + ): + with anyio.fail_after(5), pytest.raises(MCPError) as excinfo: + await client.call_tool("run", {"region": "us-west1"}, read_timeout_seconds=0.05) + + assert excinfo.value.error.code == HEADER_MISMATCH + assert isinstance(excinfo.value.__cause__, TimeoutError) + assert methods == ["tools/call", "tools/list"] + + @requirement("client-transport:http:header-mismatch-recovery") async def test_legacy_client_raises_a_header_mismatch_error_without_re_listing_or_retrying() -> None: """On a pre-2026 connection a `-32020` error from a tool call is raised as it arrives. From a57f2e4f7a6b6d67f530c252d1fabc379157bf72 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:50:34 +0000 Subject: [PATCH 3/4] Bound the re-list by the client's default timeout When `call_tool` is given no `read_timeout_seconds`, the client's own `read_timeout_seconds` now covers the whole re-list after a `HeaderMismatch`, instead of applying to each `tools/list` page. --- src/mcp/client/client.py | 6 ++- .../transports/test_hosting_http_modern.py | 51 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index a8beba0a2e..8c7d372431 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -789,7 +789,8 @@ async def call_tool( name: The name of the tool to call. arguments: Arguments to pass to the tool. read_timeout_seconds: Timeout for each underlying `tools/call` round, and - for the whole re-list after a `HEADER_MISMATCH`. + for the whole re-list after a `HEADER_MISMATCH`. Defaults to this + client's `read_timeout_seconds`. progress_callback: Callback for progress updates. input_responses: Responses to seed the first call with (e.g. when resuming from a persisted `InputRequiredResult`). @@ -827,8 +828,9 @@ async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | Inp if mismatch.code != HEADER_MISMATCH or self.protocol_version not in MODERN_PROTOCOL_VERSIONS: raise # The spec's recovery: the tool's listed schema is missing or stale, so re-list and resend once. + timeout = read_timeout_seconds if read_timeout_seconds is not None else self.read_timeout_seconds try: - with anyio.fail_after(read_timeout_seconds): + with anyio.fail_after(timeout): await self._relist_tool(name) except (MCPError, TimeoutError, ValidationError) as relist_error: raise mismatch from relist_error diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index d1e9166b8e..d4cee2817f 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -886,6 +886,57 @@ async def rewrite_mcp_name(request: httpx2.Request) -> None: assert methods == ["tools/call", "tools/list"] +@requirement("client-transport:http:header-mismatch-recovery") +@pytest.mark.parametrize( + "anyio_backend", + [pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")], +) +async def test_modern_client_bounds_the_whole_re_list_by_the_client_default_read_timeout() -> None: + """With no per-call timeout, `Client(read_timeout_seconds=...)` bounds the re-list as a whole, not page by page. + + SDK-defined: every page of a listing whose cursors never end arrives well inside the one-second default, + so no single request times out. The third page is still pending when the second elapses: the rejection + is raised with the `TimeoutError` as its cause, and the call is not resent. + """ + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + await anyio.sleep(0.4) # virtual time: the clock jumps, so the pages cost no real wait + return ListToolsResult(tools=[], next_cursor="more", ttl_ms=0, cache_scope="public") + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + raise NotImplementedError + + server = Server("endless", on_list_tools=list_tools, on_call_tool=call_tool) + + methods: list[str] = [] + + async def rewrite_mcp_name(request: httpx2.Request) -> None: + method = json.loads(request.content)["method"] + methods.append(method) + if method == "tools/call": + request.headers["mcp-name"] = "another-tool" + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + ) + async with ( + mounted_app(server, on_request=rewrite_mcp_name) as (http, _), + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + read_timeout_seconds=1, + ) as client, + ): + with anyio.fail_after(5), pytest.raises(MCPError) as excinfo: + await client.call_tool("run", {"region": "us-west1"}) + + assert excinfo.value.error.code == HEADER_MISMATCH + assert isinstance(excinfo.value.__cause__, TimeoutError) + assert methods == ["tools/call", "tools/list", "tools/list", "tools/list"] + + @requirement("client-transport:http:header-mismatch-recovery") async def test_legacy_client_raises_a_header_mismatch_error_without_re_listing_or_retrying() -> None: """On a pre-2026 connection a `-32020` error from a tool call is raised as it arrives. From 64fab09390af40b42c3799518ab8a88c87cf0695 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:55:29 +0000 Subject: [PATCH 4/4] Say on the Header parameters page that Client re-lists and resends One bullet under "Mark an argument", with a test that calls the tutorial's tool without listing it first. --- docs/advanced/header-parameters.md | 1 + tests/docs_src/test_header_parameters.py | 25 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/docs/advanced/header-parameters.md b/docs/advanced/header-parameters.md index a47be2f874..e2a9a5e3be 100644 --- a/docs/advanced/header-parameters.md +++ b/docs/advanced/header-parameters.md @@ -13,6 +13,7 @@ The mark is one extra key in the argument's JSON Schema. On `MCPServer`, `Field` ``` * Over Streamable HTTP on `2026-07-28`, a client that has listed the tool sends `Mcp-Param-Region` alongside the body, and the server rejects a call where the two disagree. +* A client that hasn't listed the tool yet sends no header, and the call is rejected. This SDK's `Client` then lists the tools and resends the call once, so listing first only saves a round trip. * Every other connection ignores the annotation. Your function doesn't change: `region` still arrives as an argument. diff --git a/tests/docs_src/test_header_parameters.py b/tests/docs_src/test_header_parameters.py index 5466dba9d6..d3379d2738 100644 --- a/tests/docs_src/test_header_parameters.py +++ b/tests/docs_src/test_header_parameters.py @@ -63,6 +63,31 @@ async def test_a_call_whose_header_and_body_disagree_is_rejected() -> None: assert tampered.json()["error"]["code"] == HEADER_MISMATCH +async def test_a_client_that_has_not_listed_the_tool_is_rejected_then_lists_and_resends_once() -> None: + """tutorial001: the first call has no header and is a 400; after one `tools/list` it is resent with the header.""" + app = tutorial001.mcp.streamable_http_app() + exchanges: list[tuple[str, str | None, int]] = [] + + async def record(response: httpx2.Response) -> None: + sent = response.request.headers + exchanges.append((sent["mcp-method"], sent.get("mcp-param-region"), response.status_code)) + + async with ( + app.router.lifespan_context(app), + httpx2.ASGITransport(app) as transport, + httpx2.AsyncClient(transport=transport, event_hooks={"response": [record]}) as http, + Client(streamable_http_client(URL, http_client=http)) as client, + ): + result = await client.call_tool("check_stock", ARGUMENTS) + assert result.structured_content == {"result": "Dune: 3 copies in eu."} + assert exchanges == [ + ("server/discover", None, 200), + ("tools/call", None, 400), + ("tools/list", None, 200), + ("tools/call", "eu", 200), + ] + + async def test_a_legacy_http_connection_ignores_the_annotation() -> None: """tutorial001: before 2026-07-28 the same call succeeds and carries no `Mcp-Param-*` header.""" async with check_stock_over_http(tutorial001.mcp.streamable_http_app(), mode="legacy") as (_, call):