modelcontextprotocol / modelcontextprotocol/python-sdk

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)

Aperta
#3,483 2 commenti 0 reazioni 0 assegnatari Vedi su GitHub

Nessuno ha ancora preso questa issue.

spec-2026-07-28 v2
Lingua principale
Python
Stelle
24.3k
Fork
4k
Merge medio
1g 1h
PR unite (30g)
31

Descrizione

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

Guida per i contributori

Apri la guida per i contributori

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Direzione di ricerca

Inizia in mcp/client/session.py, da ClientSession._resolve_param_headers, e segui _absorb_tool_listing; confronta questo comportamento con la gestione lato server di HEADER_MISMATCH in mcp/server/_streamable_http_modern.py e mcp/shared/inbound.py. Esegui la riproduzione fornita, quindi definisci l'ambito per il comportamento di preload, i tentativi limitati o gli avvisi e verifica il comportamento selezionato con i test del client e la riproduzione.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Valutazione

Stack tecnologico
python
Ambito
api, backend
Tipo di issue
Funzionalità
Difficoltà
4/5
Tempo stimato
3-5 giorni
Stato di attività
Attiva
Chiarezza
Abbastanza chiara
Idoneità per principianti
52/100

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.