Skip to content

Commit 9afccae

Browse files
authored
Retry a tool call once after a HeaderMismatch rejection (#3627)
1 parent cafa33b commit 9afccae

7 files changed

Lines changed: 501 additions & 26 deletions

File tree

‎docs/advanced/header-parameters.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The mark is one extra key in the argument's JSON Schema. On `MCPServer`, `Field`
1313
```
1414

1515
* 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.
16+
* 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.
1617
* Every other connection ignores the annotation.
1718

1819
Your function doesn't change: `region` still arrives as an argument.

‎docs/migration.md‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2849,14 +2849,6 @@ On a 2026-07-28 connection, `notifications/tools/list_changed`, `notifications/p
28492849

28502850
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.
28512851

2852-
### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))
2853-
2854-
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.
2855-
2856-
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.
2857-
2858-
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.
2859-
28602852
## Need Help?
28612853

28622854
If you encounter issues during migration:

‎docs/whats-new.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are repla
199199
### The rest, quickly
200200

201201
* **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.
202-
* **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.
202+
* **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.
203203
* **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)**.
204204
* **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)**.
205205
* **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.

‎src/mcp/client/client.py‎

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@
88
from collections.abc import Awaitable, Callable, Mapping, Sequence
99
from contextlib import AbstractAsyncContextManager, AsyncExitStack
1010
from dataclasses import KW_ONLY, dataclass, field
11-
from typing import Any, Literal, TypeVar, cast
11+
from typing import Any, Final, Literal, TypeVar, cast
1212

1313
import anyio
1414
import anyio.lowlevel
1515
import mcp_types as types
1616
from mcp_types import (
17+
HEADER_MISMATCH,
1718
INVALID_PARAMS,
1819
CacheableResult,
1920
CallToolResult,
@@ -40,6 +41,7 @@
4041
ServerCapabilities,
4142
)
4243
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
44+
from pydantic import ValidationError
4345
from typing_extensions import deprecated
4446

4547
from mcp.client._input_required import DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, run_input_required_driver
@@ -79,6 +81,9 @@
7981
initialize), or a modern protocol-version string (adopt directly). The ``str`` arm is for
8082
forward-compat; ``Client.__post_init__`` rejects anything outside that set at construction."""
8183

84+
_RELIST_PAGE_CAP: Final = 100
85+
"""Page cap for the tools/list walk that follows a `HEADER_MISMATCH`: a paginator that never ends cannot hang a call."""
86+
8287
_T = TypeVar("_T")
8388
_ResultT = TypeVar("_ResultT")
8489
_CacheableT = TypeVar("_CacheableT", bound=CacheableResult)
@@ -775,10 +780,17 @@ async def call_tool(
775780
exceptions propagate as-is. To receive the claimed shape yourself, use
776781
`client.session.call_tool(..., allow_claimed=True)`.
777782
783+
On a 2026-07-28 connection, a call the server rejects with `HEADER_MISMATCH`
784+
(this client has not listed the tool, or its input schema changed since) is
785+
resent once after refetching the tool listing. A second rejection is raised,
786+
and so is the first when the listing cannot be refetched.
787+
778788
Args:
779789
name: The name of the tool to call.
780790
arguments: Arguments to pass to the tool.
781-
read_timeout_seconds: Timeout for each underlying `tools/call` round.
791+
read_timeout_seconds: Timeout for each underlying `tools/call` round, and
792+
for the whole re-list after a `HEADER_MISMATCH`. Defaults to this
793+
client's `read_timeout_seconds`.
782794
progress_callback: Callback for progress updates.
783795
input_responses: Responses to seed the first call with (e.g. when
784796
resuming from a persisted `InputRequiredResult`).
@@ -795,7 +807,7 @@ async def call_tool(
795807
conform to the negotiated protocol version.
796808
"""
797809

798-
async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult | Result:
810+
async def send(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult | Result:
799811
return await self.session.call_tool(
800812
name,
801813
arguments,
@@ -809,6 +821,21 @@ async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | Inp
809821
allow_claimed=True,
810822
)
811823

824+
async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult | Result:
825+
try:
826+
return await send(r, s)
827+
except MCPError as mismatch:
828+
if mismatch.code != HEADER_MISMATCH or self.protocol_version not in MODERN_PROTOCOL_VERSIONS:
829+
raise
830+
# The spec's recovery: the tool's listed schema is missing or stale, so re-list and resend once.
831+
timeout = read_timeout_seconds if read_timeout_seconds is not None else self.read_timeout_seconds
832+
try:
833+
with anyio.fail_after(timeout):
834+
await self._relist_tool(name)
835+
except (MCPError, TimeoutError, ValidationError) as relist_error:
836+
raise mismatch from relist_error
837+
return await send(r, s)
838+
812839
result = await self._drive_input_required(await retry(input_responses, request_state), retry)
813840
if isinstance(result, CallToolResult):
814841
return result
@@ -943,6 +970,15 @@ async def list_tools(
943970
),
944971
)
945972

973+
async def _relist_tool(self, name: str) -> None:
974+
"""Refetch the tool listing from the server, page by page, until a page lists `name`."""
975+
cursor: str | None = None
976+
for _ in range(_RELIST_PAGE_CAP):
977+
page = await self.list_tools(cursor=cursor, cache_mode="refresh")
978+
cursor = page.next_cursor
979+
if cursor is None or any(tool.name == name for tool in page.tools):
980+
return
981+
946982
@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
947983
async def send_roots_list_changed(self) -> None:
948984
"""Send a notification that the roots list has changed."""

‎tests/docs_src/test_header_parameters.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,31 @@ async def test_a_call_whose_header_and_body_disagree_is_rejected() -> None:
6363
assert tampered.json()["error"]["code"] == HEADER_MISMATCH
6464

6565

66+
async def test_a_client_that_has_not_listed_the_tool_is_rejected_then_lists_and_resends_once() -> None:
67+
"""tutorial001: the first call has no header and is a 400; after one `tools/list` it is resent with the header."""
68+
app = tutorial001.mcp.streamable_http_app()
69+
exchanges: list[tuple[str, str | None, int]] = []
70+
71+
async def record(response: httpx2.Response) -> None:
72+
sent = response.request.headers
73+
exchanges.append((sent["mcp-method"], sent.get("mcp-param-region"), response.status_code))
74+
75+
async with (
76+
app.router.lifespan_context(app),
77+
httpx2.ASGITransport(app) as transport,
78+
httpx2.AsyncClient(transport=transport, event_hooks={"response": [record]}) as http,
79+
Client(streamable_http_client(URL, http_client=http)) as client,
80+
):
81+
result = await client.call_tool("check_stock", ARGUMENTS)
82+
assert result.structured_content == {"result": "Dune: 3 copies in eu."}
83+
assert exchanges == [
84+
("server/discover", None, 200),
85+
("tools/call", None, 400),
86+
("tools/list", None, 200),
87+
("tools/call", "eu", 200),
88+
]
89+
90+
6691
async def test_a_legacy_http_connection_ignores_the_annotation() -> None:
6792
"""tutorial001: before 2026-07-28 the same call succeeds and carries no `Mcp-Param-*` header."""
6893
async with check_stock_over_http(tutorial001.mcp.streamable_http_app(), mode="legacy") as (_, call):

‎tests/interaction/_requirements.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3624,6 +3624,17 @@ def __post_init__(self) -> None:
36243624
transports=("streamable-http",),
36253625
note="Only observable over streamable HTTP: headers are derived from the cached tool schema at the seam.",
36263626
),
3627+
"client-transport:http:header-mismatch-recovery": Requirement(
3628+
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#client-behavior",
3629+
behavior=(
3630+
"When the server rejects a tools/call with HeaderMismatch, the client calls tools/list for the "
3631+
"tool's current inputSchema and retries the call once with the Mcp-Param-* headers that schema "
3632+
"asks for. A second rejection is raised to the caller."
3633+
),
3634+
added_in="2026-07-28",
3635+
transports=("streamable-http",),
3636+
note="Client.call_tool only: ClientSession.call_tool sends once and leaves the recovery to its caller.",
3637+
),
36273638
"client-transport:http:vendor-name-param-header": Requirement(
36283639
source="sdk",
36293640
behavior=(

0 commit comments

Comments
 (0)