Client never retries after -32020 HeaderMismatch, and there is no public way to pre-load the x-mcp-header map (SEP-2243 client SHOULD/MAY both unimplemented) · Issue #3483 · modelcontextprotocol/python-sdk · GitHub
You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Client never retries after -32020 HeaderMismatch, and there is no public way to pre-load the x-mcp-header map (SEP-2243 client SHOULD/MAY both unimplemented) #3483
At 2026-07-28, a ClientSession that has not listed a tool sends tools/call with no Mcp-Param-* headers, the server rejects it with -32020, and the client stops there. SEP-2243 says it SHOULD re-list and retry, and there is no way for an application to supply the map instead — _x_mcp_header_maps is private and _absorb_tool_listing is its only writer.
Everything below reproduces against the SDK's own client and server, no third-party server involved.
Reproduction
importanyio, httpx2, uvicornfromtypingimportAnnotatedfrompydanticimportFieldfrommcp.clientimportClientfrommcp.client.streamable_httpimportstreamable_http_clientfrommcp.server.mcpserverimportMCPServerfrommcp.shared.exceptionsimportMCPErrorserver=MCPServer("repro")
@server.tool()asyncdeffetch(
owner: Annotated[str, Field(json_schema_extra={"x-mcp-header": "owner"})],
) ->str:
"""One annotated argument, as the SEP's examples have."""returnf"fetched for {owner}"URL="http://127.0.0.1:8931/mcp"asyncdefexercise() ->None:
awaitanyio.sleep(1.5)
asyncwithhttpx2.AsyncClient() ashttp:
print("== a session that never listed ==")
asyncwithClient(streamable_http_client(URL, http_client=http), mode="auto") asclient:
print("negotiated:", client.protocol_version)
try:
r=awaitclient.call_tool("fetch", {"owner": "octocat"})
print(" result:", r.content)
exceptMCPErrorasexc:
print(f" MCPError code={exc.error.code}")
print(f" message={exc.error.message!r}")
print("== a session that listed first ==")
asyncwithClient(streamable_http_client(URL, http_client=http), mode="auto") asclient:
awaitclient.list_tools()
r=awaitclient.call_tool("fetch", {"owner": "octocat"})
print(" result:", r.content)
asyncdefmain() ->None:
config=uvicorn.Config(server.streamable_http_app(), host="127.0.0.1", port=8931, log_level="error")
http_server=uvicorn.Server(config)
asyncwithanyio.create_task_group() astg:
tg.start_soon(http_server.serve)
awaitexercise()
http_server.should_exit=Trueanyio.run(main)
Output on mcp 2.1.1:
== a session that never listed ==
negotiated: 2026-07-28
MCPError code=-32020
message="Mcp-Param-owner header is missing but the request body's 'owner' argument is present"
== a session that listed first ==
result: [TextContent(type='text', text='fetched for octocat', ...)]
The -32020 arrives as HTTP 400. The client neither re-lists nor retries.
What the spec asks for
SEP-2243, Client Behavior:
Implementation Note: Clients MUST construct Mcp-Param-* headers using the most recently obtained inputSchema for the tool. A client that has never obtained the tool's inputSchema SHOULD send the request without Mcp-Param-* headers. If the server rejects the request because required Mcp-Param-* headers are missing or do not match the body, the client SHOULD call tools/list to obtain the current inputSchema, then retry the original request with the appropriate headers. Clients MAY pre-load tool definitions via other means (e.g., from a previous session or configuration) to enable header emission without a prior tools/list call.
Two mechanisms in one paragraph. The SDK implements neither.
HEADER_MISMATCH has zero readers under mcp/client/ — every occurrence is server-side (mcp/server/_streamable_http_modern.py, mcp/shared/inbound.py), producing the rejection rather than reacting to one.
Why the second half matters as much as the first
An application that opens a session per operation — for connection hygiene, or because it holds no long-lived session — has the schema in hand already, from the listing it built its tool surface with. The SEP explicitly blesses using it ("MAY pre-load tool definitions via other means"). But _x_mcp_header_maps is private, and the only writer is _absorb_tool_listing, reachable only through an in-session list_tools(). So the sanctioned cheap path is unreachable and the only route is a redundant wire request per call.
For us that request is ~1.3 s and ~122 KiB against a 44-tool server, paid on the calling session purely to repopulate state we already had.
What this cost, concretely
We hit this in production against the GitHub MCP server: 36 of its 44 tools were refused with -32020, because our client opens one session per tools/call and never lists on it. The client-side symptom was a bare -32020 from the server; nothing on our side could say why, because:
ClientSession._resolve_param_headers (mcp/client/session.py:1118) returns {} silently when the session holds no map for the tool:
def_resolve_param_headers(self, name: str, arguments: Mapping[str, Any]) ->dict[str, str]:
"""`Mcp-Param-*` headers for a `tools/call`, or empty when the tool was never listed."""header_map=self._x_mcp_header_maps.get(name)
ifheader_mapisNone:
return {}
returnmcp_param_headers(header_map, arguments)
At a modern protocol version, "this tool was never listed on this session" is a strong signal that the call is about to be refused. The listing filter one screen away already logs when it drops a tool (logger.warning("dropping tool %r: invalid x-mcp-header (%s)", ...)), so the precedent for saying something here is right there.
Suggested fixes, in the order we would value them
A public way to seed the map — e.g. ClientSession.preload_tool_listing(result), or a tools= argument to adopt(). Implements the SEP's "MAY pre-load" sentence, and lets a session-per-call client emit correct headers with no extra round trip.
The -32020 retry, bounded to one attempt: re-list, rebuild, resend. This is the SHOULD, and it is the only thing that heals a schema that gains an annotation mid-session.
A warning in _resolve_param_headers when a modern session has no map for the tool it is calling. Cheapest of the three, and the one that would have turned our incident into a log line instead of an investigation.
Happy to open a PR for any of these if the direction is agreeable — 1 and 3 look small; 2 needs a decision about where the retry lives relative to validate_tool_result.
Re-checked against the v2.2.0 tag — unchanged, so this is not stale.
HEADER_MISMATCH still has zero readers under src/mcp/client/.
ClientSession._resolve_param_headers is byte-identical (session.py:1111), still returning {} silently.
_x_mcp_header_maps is still private with _absorb_tool_listing as its only writer.
src/mcp/server/ still contains no x-mcp-header anything.
#3442 in that release does touch SEP-2243, but it adds conformance fixtures for the resource/prompt Mcp-Name headers rather than anything on this path.
I reproduced this against v2.2.0 and would like to work on it. I propose a bounded one-time retry after HEADER_MISMATCH: refresh the tool listing, rebuild the header map, and retry once, with tests preventing loops and retries for unrelated errors. Should the public preload API remain a separate follow-up?
The retry half of this is fixed in #3627. After a -32020, Client.call_tool now re-lists the tools and resends the call once. (ClientSession.call_tool still sends once.) The Header parameters page describes it.
It doesn't add the pre-load API you ranked first, or the warning, so the per-session tools/list you measured is still there. The pre-load note was dropped from the spec before release (modelcontextprotocol/modelcontextprotocol#3071), and it would be new public API here.
Thanks for the careful report and the offer to send a PR. If that cost is still a problem, a new issue for it is welcome.
Activity
atsuki-shirasawa commentedon Sep 9, 2026
Re-checked against the v2.2.0 tag — unchanged, so this is not stale.
HEADER_MISMATCHstill has zero readers undersrc/mcp/client/.ClientSession._resolve_param_headersis byte-identical (session.py:1111), still returning{}silently._x_mcp_header_mapsis still private with_absorb_tool_listingas its only writer.src/mcp/server/still contains nox-mcp-headeranything.#3442 in that release does touch SEP-2243, but it adds conformance fixtures for the resource/prompt
Mcp-Nameheaders rather than anything on this path.tahakotil commentedon Sep 11, 2026
I reproduced this against v2.2.0 and would like to work on it. I propose a bounded one-time retry after
HEADER_MISMATCH: refresh the tool listing, rebuild the header map, and retry once, with tests preventing loops and retries for unrelated errors. Should the public preload API remain a separate follow-up?maxisbey commentedon Oct 2, 2026
The retry half of this is fixed in #3627. After a
-32020,Client.call_toolnow re-lists the tools and resends the call once. (ClientSession.call_toolstill sends once.) The Header parameters page describes it.It doesn't add the pre-load API you ranked first, or the warning, so the per-session
tools/listyou measured is still there. The pre-load note was dropped from the spec before release (modelcontextprotocol/modelcontextprotocol#3071), and it would be new public API here.Thanks for the careful report and the offer to send a PR. If that cost is still a problem, a new issue for it is welcome.
AI Disclaimer