Skip to content

Retry a tool call once after a HeaderMismatch rejection - #3627

Merged
maxisbey merged 4 commits into
mainfrom
3483-retry-after-header-mismatch
Oct 2, 2026
Merged

maxisbey merged 4 commits into
mainfrom
3483-retry-after-header-mismatch

Conversation

@maxisbey

@maxisbey maxisbey commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #3483.

What was wrong

On a 2026-07-28 Streamable HTTP connection, Client.call_tool for a tool with x-mcp-header arguments 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/list and retrying the request. The client raised instead. The TypeScript SDK already retries.

What changes

Client.call_tool now performs that recovery. The signature is unchanged and there are no new public names.

  • On an MCPError with code -32020, on a 2026-07-28 connection:
    • the tool listing is refetched from the server, following cursors until a page lists the tool
    • the same call is resent once, with the Mcp-Param-* headers the current schema asks for
  • A second rejection is raised. There is no second retry.
  • One timeout bounds the whole re-list, not each page: the read_timeout_seconds passed to call_tool, or the client's own read_timeout_seconds when none is passed.
  • If the re-list fails, the original -32020 is raised with that failure as its cause, and the call is not resent. That covers:
    • an error response, or any other MCPError
    • a tools/list page that fails validation (pydantic.ValidationError)
    • the timeout elapsing (TimeoutError)

What users will notice

  • A call that used to raise -32020 because the tool was not listed, or because its schema had changed, now succeeds.
    • On the wire: tools/call (rejected), tools/list, tools/call.
  • A -32020 that a re-list cannot cure is still raised, after those extra requests.
  • The cost of a recovery is one tools/list request per page, up to the page that lists the tool, plus one resend.
    • For a single-page catalog that is the whole listing, once per recovered call.
    • A client that lists tools before calling them never pays it.
  • The cursor walk stops after 100 pages, the same cap the server uses when it resolves a tool's schema for this validation. A listing whose cursors never end cannot hang a call: after 100 pages the call is resent once anyway.
  • The read timeout in effect (per call, or the client's default) applies separately to the rejected call, to the re-list as a whole, and to the resend, so a recovered call can take up to three times that value.
    • With neither set there is no time bound on the re-list, as for any other request; the 100-page cap still applies.
  • An except MCPError around call_tool sees the -32020 in each of those re-list failures; the reason is on __cause__.
  • Cancelling the caller during a recovery still cancels it.
  • The re-list replaces the client's cached tool listing, so a later list_tools() served from the cache returns the current schema.
  • -32020 is a rejection before dispatch, so the resend does not run a tool twice on a conforming server. A tool handler that raises -32020 itself is run twice on a 2026-07-28 connection.
  • Unchanged:
    • connections on earlier protocol versions, where a -32020 is raised as it arrives
    • ClientSession.call_tool, which still sends once and leaves the recovery to its caller
    • every call that does not fail with -32020

Docs

  • The Header parameters page gains one line saying that a call for a tool the client has not listed is rejected, and that Client then lists the tools and resends it once.
  • The migration guide's section on 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.
  • The translated pages are not regenerated here; their What's new pages still carry the old link until the next translation run.

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

  • Ten tests in 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:
    • a never-listed tool: tools/call, tools/list, tools/call with the header, and the call succeeds (this replaces the test that pinned "one request, raises")
    • a tool that gains an annotation after it was listed recovers the same way, and the cached listing is replaced
    • a mismatch the re-list cannot cure raises -32020 after exactly one retry
    • a tool on page two: the re-list follows the cursor and stops at that page
    • a listing whose cursors never end: 100 tools/list requests, one resend, then -32020
    • a re-list the server refuses: the original -32020 is raised, caused by that error, and nothing is resent
    • a re-list that returns a malformed page: the same, caused by the ValidationError
    • a re-list that never answers, with read_timeout_seconds set: the same, caused by the TimeoutError (run on a virtual clock, so it takes no real time)
    • a listing whose cursors never end, each page answered well inside the client's default timeout and no per-call timeout: the re-list is cut off when that one timeout elapses, after three pages, and the original -32020 is raised (also on a virtual clock)
    • a legacy connection: -32020 is raised with no re-list and no resend
  • The nine 2026-07-28 tests fail on main and pass with the change. The legacy test passes on both.
  • One test in tests/docs_src/test_header_parameters.py for the new docs line: the page's own tutorial server, called without listing first, answers 400 to the first tools/call, then 200 to tools/list and to the resent call with Mcp-Param-Region. It fails on main.
  • The reproduction from the issue now returns the tool result.
  • ./scripts/test passes with 100% coverage; ruff and pyright are clean.
  • The English docs build passes in strict mode, with the render-order and cross-reference checks; markdownlint is clean on the changed pages.
  • The conformance suite has no scenario for this SHOULD yet, so the coverage is the SDK's own tests.

AI Disclaimer

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
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3627.mcp-python-docs.pages.dev
Deployment https://795b230a.mcp-python-docs.pages.dev
Commit 64fab09
Triggered by @maxisbey
Updated 2026-10-02 13:58:39 UTC

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/mcp/client/client.py
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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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."

Comment thread src/mcp/client/client.py
Comment thread src/mcp/client/client.py Outdated
Comment thread src/mcp/client/client.py
Comment on lines +825 to +832
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (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.

Comment thread docs/migration.md
Comment on lines 2867 to 2868

### 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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/mcp/client/client.py
Comment on lines +821 to +832
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: None is a no-deadline scope, so callers without a timeout keep today's behaviour; only the scope's own expiry becomes TimeoutError, an outer cancellation still propagates as cancellation.
  • _relist_tool exits: page lists the tool, next_cursor is None, or the 100-page cap, each exercised by a test; cache_mode="refresh" replaces the cached listing.
  • The trio MockClock test shadows the module lease fixture exactly as tests/conftest.py documents; trio is 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.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new blocking issues. 1 optional suggestion (a nit or a note on pre-existing code) was found and not posted. Nothing in this review needs a push before merging.

Still open from earlier reviews (3):

  • Unresolved: 3 minor or pre-existing.

@maxisbey
maxisbey merged commit 9afccae into main Oct 2, 2026
41 checks passed
@maxisbey
maxisbey deleted the 3483-retry-after-header-mismatch branch October 2, 2026 14:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant