Retry a tool call once after a HeaderMismatch rejection - #3627
Conversation
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
📚 Documentation preview
|
There was a problem hiding this comment.
1 issue found across 5 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/mcp/client/client.py">
<violation number="1" location="src/mcp/client/client.py:974">
P2: A paginated refresh that exhausts without `name` leaves the old per-tool state alive. The retry can therefore emit stale `Mcp-Param-*` headers and validate output against a stale schema; clear the named tool’s derived state when the complete walk does not find it.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| 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): |
There was a problem hiding this comment.
P2: A paginated refresh that exhausts without name leaves the old per-tool state alive. The retry can therefore emit stale Mcp-Param-* headers and validate output against a stale schema; clear the named tool’s derived state when the complete walk does not find it.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/mcp/client/client.py, line 974:
<comment>A paginated refresh that exhausts without `name` leaves the old per-tool state alive. The retry can therefore emit stale `Mcp-Param-*` headers and validate output against a stale schema; clear the named tool’s derived state when the complete walk does not find it.</comment>
<file context>
@@ -943,6 +965,15 @@ async def list_tools(
+ 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
+
</file context>
There was a problem hiding this comment.
One optional note from this repository's REVIEW.md or CLAUDE.md checks was not posted as a comment, over this review's limit for such notes; it is on this commit's check card.
Findings marked 🟡 are optional suggestions and need no follow-up push.
Additional findings (no inline location):
-
🟡
tests/interaction/transports/test_hosting_http_modern.py— nit: PR checklist — AGENTS.md requires a matching conformance-suite test for 2026-07-28 spec features; the description states the conformance suite has no scenario for this SHOULD, so the feature lands covered only by SDK tests. Fix: raise an issue on the conformance repo for the HeaderMismatch re-list-and-retry client scenario and link it from the PR, or wait for that scenario before merging.Why this was flagged
The instruction guards against 2026-07-28 behaviour shipping without cross-SDK conformance coverage. The author did disclose the gap ('The conformance suite has no scenario for this SHOULD yet, so the coverage is the SDK's own tests'), which satisfies the 'tell the user' half; what is missing is a conformance issue link or a matching scenario. Small consequence: SDK tests cover the behaviour in-process.
Verification: AGENTS.md (base 19e4f2a) Testing section: "New features from the 2026-07-28 spec must have a matching test in the conformance suite that passes against this SDK (CI runs it via
.github/workflows/conformance.yml). If no matching test exists, stop and tell the user so they can raise an issue on the conformance repo."
| 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) |
There was a problem hiding this comment.
🟡 (optional) Tool handlers that surface a -32020 error are now executed twice per call, where the base ran them once and raised. The gate at src/mcp/client/client.py:825 checks only the negotiated version, not whether the transport is HTTP, so on stdio or in-memory 2026-07-28 connections (where no Mcp-Param-* validation exists) any -32020 must come from the handler itself, yet the client still re-lists and resends at src/mcp/client/client.py:832. Fix: only recover when the rejection can be a pre-dispatch header rejection, e.g. gate on the connection being Streamable HTTP (Client already knows a URL server at client.py:396-398) or on the error being a transport-level rejection, so handler-originated -32020 propagates unchanged.
Why this was flagged
A Server on a 2026-07-28 stdio or in-memory connection whose tool handler raises MCPError with code -32020 (for example a proxy/aggregator tool that forwards an upstream HTTP server's HeaderMismatch verbatim). The client's call_tool enters retry at src/mcp/client/client.py:821-832; the guard at client.py:825 passes because self.protocol_version is modern, so _relist_tool issues tools/list and send runs the handler a second time at client.py:832. On the base branch the first -32020 was raised to the caller and the handler ran once. The SDK server emits HEADER_MISMATCH only from the HTTP ladder (src/mcp/shared/inbound.py:448-471); classify_inbound_request skips the header rung when headers is None, so on non-HTTP transports every -32020 is handler-originated and the double run is unconditional. The PR text calls this accepted, but nothing in Client distinguishes the transport even though the constructor knows a URL server from a stdio/in-memory one (client.py:396-402).
Verification: The gate at src/mcp/client/client.py:825 inspects only the negotiated version, never the transport; on passing it calls self._relist_tool(name) (line 829) and then return await send(r, s) (line 832). src/mcp/shared/inbound.py:452 only emits HEADER_MISMATCH if headers is not None, so on stdio/in-process modern connections every -32020 originates in the handler. Base behavior: the handler ran once.
|
|
||
| ### 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? |
There was a problem hiding this comment.
🟡 nit (optional): readers lose the only docs for the server-side Mcp-Param-* validation and the public mcp.shared.inbound validators, and no page describes the new re-list-and-retry. The deleted section at docs/migration.md:2868 was the sole place in docs/ naming validate_mcp_param_headers, decode_header_value, the skip-when-no-handler rule, duplicate-header rejection and strict base64-sentinel decoding. docs/advanced/header-parameters.md:15 still says only "a client that has listed the tool" sends the header. Fix: move the server-side facts onto docs/advanced/header-parameters.md and add the client recovery there (re-list, one resend, the 100-page cap), so the deletion is a relocation rather than a loss. [also at: docs/whats-new.md:202 - nit: after merging, readers of docs/ find no page describing the new re-list-and-retry behaviour nor the server-side Mcp-Param-* validation rules. docs/whats-new.md:202 drops the "has the rules" link and docs/migration.md deletes the only section that stated those rules, while no page gains the client recovery that this PR adds to Client.call_tool.; src/mcp/client/client.py:785 - nit: AGENTS.md requires the relevant docs/ page to be updated in the same PR when user-visible behaviour changes.]
Why this was flagged
The diff removes the whole "Servers validate Mcp-Param-* headers against the request body (SEP-2243)" section from docs/migration.md (old lines 2868-2874). validate_mcp_param_headers, decode_header_value, "supplied more than once" rejection and the =?base64?...?= strictness now appear only in src/ and tests/, in no page under docs/. docs/advanced/header-parameters.md is untouched and at line 15 still describes only the listed-tool case, so the new behaviour added at src/mcp/client/client.py:821-832 (an extra tools/list per page and a second tools/call after a -32020) is documented only in a docstring. AGENTS.md requires that a change affecting user-visible behaviour update the relevant docs page in the same PR and that docs/migration.md only be corrected or clarified, not have content removed. On the base branch a reader finds both the server-side rules and the "list the tool first" guidance; after merge they find neither, and nothing tells them a call now silently issues up to 100 tools/list requests before being resent.
Verification: Triggering condition: any reader of docs/ looking for how Client.call_tool behaves on -32020 or for the server-side Mcp-Param-* validation rules. The only docs edits are the deletion of the migration.md section (old lines 2868-2874) and removal of the whats-new.md link to it. docs/advanced/header-parameters.md is untouched. Harm is to documentation only; nothing fails at runtime.
There was a problem hiding this comment.
The new bullet on docs/advanced/header-parameters.md covers the client's re-list-and-resend, which closes half of this. The server-side facts that the deleted docs/migration.md section carried are still absent from every page under docs/: grep -rn "validate_mcp_param_headers\|decode_header_value" docs/ returns nothing, and nothing names the skip-when-no-tools/list-handler rule, rejection of a recognized Mcp-Param-* header supplied more than once, or the strict =?base64?...?= decoding (also applied to Mcp-Name). Either restore the section at docs/migration.md:2868 or add a short "On the server" paragraph to docs/advanced/header-parameters.md stating those rules and pointing low-level Server authors at mcp.shared.inbound.validate_mcp_param_headers / decode_header_value, so the deletion becomes a relocation.
| 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) |
There was a problem hiding this comment.
🟡 nit (optional): AGENTS.md says any change to an existing public API's observable behaviour is an explicit maintainer design decision and should generally be avoided. The new retry wrapper changes Client.call_tool's observable behaviour on 2026-07-28 connections: a -32020 that used to be raised immediately now silently issues up to 100 tools/list requests and resends the tools/call, and this also wraps every input-required resumption. Fix: have a maintainer explicitly sign off on the behaviour change on the linked issue (#3483) before merge, or gate the recovery behind an opt-in so existing callers' observable behaviour is unchanged.
Why this was flagged
The instruction guards the 2.x compatibility contract. Concretely: callers that caught -32020 themselves (the migration guide told them 'the spec's recovery is to re-list and retry') now see extra wire traffic and a second tools/call before any error; the PR notes a tool handler that itself raises -32020 is run twice on a 2026-07-28 connection. Mitigating context the maintainer may weigh: the spec's Client Behavior section says a client SHOULD do this, the TypeScript SDK already does, the PR fixes an assigned issue, signatures are unchanged, and legacy connections are untouched.
Verification: AGENTS.md at base (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."
…lure `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.
There was a problem hiding this comment.
I re-reviewed after 98d9dd0 and found no bugs; the follow-up resolves the timeout and error-chaining concerns from the first pass. Because this changes what Client.call_tool does on the wire for every 2026-07-28 connection, a maintainer's look at the behaviour change itself is still worthwhile.
What was reviewed:
anyio.fail_after(read_timeout_seconds)around_relist_tool:Noneis a no-deadline scope, so callers without a timeout keep today's behaviour; only the scope's own expiry becomesTimeoutError, an outer cancellation still propagates as cancellation._relist_toolexits: page lists the tool,next_cursorisNone, or the 100-page cap, each exercised by a test;cache_mode="refresh"replaces the cached listing.- The trio
MockClocktest shadows the module lease fixture exactly astests/conftest.pydocuments;triois already in the dev group, so no dependency change.
Extended reasoning...
The diff touches src/mcp/client/client.py (a retry wrapper around session.call_tool plus a _relist_tool helper, 40 lines), nine interaction tests over the in-process HTTP bridge, a requirements entry, and removes a migration-guide section with its what's-new link. It touches no auth, crypto or injection surface; the new code only issues additional tools/list and tools/call requests the client already sends. The second commit addressed the two substantive findings from the earlier run (unbounded re-list, non-MCPError escaping the recovery) and tests cover every branch. Deferring rather than approving because AGENTS.md treats a change to an existing public API's observable behaviour as an explicit maintainer decision, and the earlier docs-relocation and sign-off threads remain open with no independent resolution.
Still open from earlier reviews (3):
- Unresolved: 3 minor or pre-existing.
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.
One bullet under "Mark an argument", with a test that calls the tutorial's tool without listing it first.
Fixes #3483.
What was wrong
On a 2026-07-28 Streamable HTTP connection,
Client.call_toolfor a tool withx-mcp-headerarguments failed with-32020(HeaderMismatch) unless that client had already listed the tool. It also kept failing when the tool's schema gained an annotation after it was listed.The spec's Client Behavior section says a client SHOULD answer that rejection by calling
tools/listand retrying the request. The client raised instead. The TypeScript SDK already retries.What changes
Client.call_toolnow performs that recovery. The signature is unchanged and there are no new public names.MCPErrorwith code-32020, on a 2026-07-28 connection:Mcp-Param-*headers the current schema asks forread_timeout_secondspassed tocall_tool, or the client's ownread_timeout_secondswhen none is passed.-32020is raised with that failure as its cause, and the call is not resent. That covers:MCPErrortools/listpage that fails validation (pydantic.ValidationError)TimeoutError)What users will notice
-32020because the tool was not listed, or because its schema had changed, now succeeds.tools/call(rejected),tools/list,tools/call.-32020that a re-list cannot cure is still raised, after those extra requests.tools/listrequest per page, up to the page that lists the tool, plus one resend.except MCPErroraroundcall_toolsees the-32020in each of those re-list failures; the reason is on__cause__.list_tools()served from the cache returns the current schema.-32020is a rejection before dispatch, so the resend does not run a tool twice on a conforming server. A tool handler that raises-32020itself is run twice on a 2026-07-28 connection.-32020is raised as it arrivesClientSession.call_tool, which still sends once and leaves the recovery to its caller-32020Docs
Clientthen lists the tools and resends it once.Mcp-Param-*header validation is removed, along 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.Not included
#3583 moves the code in
src/mcp/client/client.py, which this changes (42 lines), so whichever lands second needs a rebase.How it was checked
tests/interaction/transports/test_hosting_http_modern.py, SDK client against SDK server over the in-process HTTP bridge, each asserting the sequence of requests on the wire:tools/call,tools/list,tools/callwith the header, and the call succeeds (this replaces the test that pinned "one request, raises")-32020after exactly one retrytools/listrequests, one resend, then-32020-32020is raised, caused by that error, and nothing is resentValidationErrorread_timeout_secondsset: the same, caused by theTimeoutError(run on a virtual clock, so it takes no real time)-32020is raised (also on a virtual clock)-32020is raised with no re-list and no resendmainand pass with the change. The legacy test passes on both.tests/docs_src/test_header_parameters.pyfor the new docs line: the page's own tutorial server, called without listing first, answers 400 to the firsttools/call, then 200 totools/listand to the resent call withMcp-Param-Region. It fails onmain../scripts/testpasses with 100% coverage; ruff and pyright are clean.AI Disclaimer