Skip to content

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

Closed

Description

@atsuki-shirasawa

Summary

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

import anyio, httpx2, uvicorn
from typing import Annotated
from pydantic import Field
from mcp.client import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server.mcpserver import MCPServer
from mcp.shared.exceptions import MCPError

server = MCPServer("repro")

@server.tool()
async def fetch(
    owner: Annotated[str, Field(json_schema_extra={"x-mcp-header": "owner"})],
) -> str:
    """One annotated argument, as the SEP's examples have."""
    return f"fetched for {owner}"

URL = "http://127.0.0.1:8931/mcp"

async def exercise() -> None:
    await anyio.sleep(1.5)
    async with httpx2.AsyncClient() as http:
        print("== a session that never listed ==")
        async with Client(streamable_http_client(URL, http_client=http), mode="auto") as client:
            print("negotiated:", client.protocol_version)
            try:
                r = await client.call_tool("fetch", {"owner": "octocat"})
                print("   result:", r.content)
            except MCPError as exc:
                print(f"   MCPError code={exc.error.code}")
                print(f"   message={exc.error.message!r}")

        print("== a session that listed first ==")
        async with Client(streamable_http_client(URL, http_client=http), mode="auto") as client:
            await client.list_tools()
            r = await client.call_tool("fetch", {"owner": "octocat"})
            print("   result:", r.content)

async def main() -> None:
    config = uvicorn.Config(server.streamable_http_app(), host="127.0.0.1", port=8931, log_level="error")
    http_server = uvicorn.Server(config)
    async with anyio.create_task_group() as tg:
        tg.start_soon(http_server.serve)
        await exercise()
        http_server.should_exit = True

anyio.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)
    if header_map is None:
        return {}
    return mcp_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

  1. 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.
  2. 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.
  3. 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.

Environment

  • mcp 2.1.1, mcp-types 2.1.1, Python 3.12.9
  • Transport: streamable HTTP, mode="auto", negotiated 2026-07-28

Activity

  1. atsuki-shirasawa commented on Sep 9, 2026

    @atsuki-shirasawa
    Author

    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.

  2. Ben-Home commented on Sep 9, 2026

    @Ben-Home

    The v2.2.0 recheck narrows this to a spec compliance gap with two independently shippable halves, matching SEP-2243's SHOULD (re-list and retry) and MAY (pre-load) language.

    For the SHOULD path: the retry is only safe when it is bounded and knowable. A -32020 HeaderMismatch on a session that never listed means the client cannot yet compute Mcp-Param-* headers, because the annotations arrive inside the tool schema from tools/list itself (the same reason _absorb_tool_listing is the sole writer of _x_mcp_header_maps today). So the minimal correct flow is: on the first HeaderMismatch with no absorbed listing, issue tools/list once, absorb it, recompute the map, and retry the call exactly once. Bound it to a single retry so a server that rejects even with correct headers cannot cause a loop. The dangerous case is the reverse: listing already absorbed and the server still rejects. That is a permanent mismatch (server side validation of values the client cannot see) and should surface as a distinct error with the computed header map attached, not as a silent retry.

    The silent {} from _resolve_param_headers (session.py:1111) is the trap underneath all of this. Returning an empty map without any signal makes the client look compliant while stripping every declared header. A logged warning naming the tool and the expected headers would turn the opaque -32020 into a debuggable contract mismatch, and it costs nothing when the map is genuinely empty by design.

    For the MAY path: exposing an injectable map would unblock apps that already know their tool schemas up front (static registries, generated clients). Making the header map public via a constructor kwarg or a validated setter, with a read-through fallback to the listing path, gives those apps the pre-load without paying a list-on-first-call round trip. Validation against the negotiated tool schema at call time keeps the injection honest.

    #3484 is the server side sibling (no declaration mechanism, no validation of what is served). A fix that pairs the client retry here with server side annotation validation closes the loop end to end; the #3442 conformance fixtures cover the resource and prompt Mcp-Name headers but not this call path, so a fixture for tools/call without a prior listing would pin the regression. We run 36 connectors through the MCP SDK at corpusiq.io and this exact class (a strict server rejecting a header-less first call) is why auto re-list on first call matters in production, not just in conformance.

  3. added
    v2Affects the v2 line (2.x on main)
    spec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revision
    on Sep 10, 2026
  4. tahakotil commented on Sep 11, 2026

    @tahakotil

    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?

  5. maxisbey commented on Oct 2, 2026

    @maxisbey
    Contributor

    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.

    AI Disclaimer

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    spec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revisionv2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions