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)
Ninguém assumiu esta issue ainda.
- Linguagem predominante
- Python
- Estrelas
- 24.3k
- Forks
- 4k
- Merge médio
- 1d 1h
- PRs com merge (30d)
- 31
Descrição
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 obtainedinputSchemafor the tool. A client that has never obtained the tool'sinputSchemaSHOULD send the request withoutMcp-Param-*headers. If the server rejects the request because requiredMcp-Param-*headers are missing or do not match the body, the client SHOULD calltools/listto obtain the currentinputSchema, 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 priortools/listcall.
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
- A public way to seed the map — e.g.
ClientSession.preload_tool_listing(result), or atools=argument toadopt(). Implements the SEP's "MAY pre-load" sentence, and lets a session-per-call client emit correct headers with no extra round trip. - The
-32020retry, 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_headerswhen 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
mcp2.1.1,mcp-types2.1.1, Python 3.12.9- Transport: streamable HTTP,
mode="auto", negotiated2026-07-28
Guia de contribuição
Primeiros passos
- Leia a issue inteira e depois o guia de contribuição do projeto.
- Comente na issue dizendo que vai assumir — evita que duas pessoas façam o mesmo trabalho.
- Faça um fork do repositório e trabalhe em uma branch.
- Abra um pull request que referencie o número da issue.
Direção de pesquisa
Comece em mcp/client/session.py, em ClientSession._resolve_param_headers, e rastreie _absorb_tool_listing; compare isso com o tratamento de HEADER_MISMATCH no lado do servidor em mcp/server/_streamable_http_modern.py e mcp/shared/inbound.py. Execute a reprodução fornecida e, em seguida, estabeleça o escopo para o comportamento de preload, novas tentativas limitadas ou avisos, e verifique o comportamento selecionado com testes do cliente e a reprodução.
Escrita pelo modelo de indexação a partir do texto da issue.
Avaliação
- Stack de tecnologia
- python
- Domínio
- api, backend
- Tipo de issue
- Funcionalidade
- Dificuldade
- 4/5
- Tempo estimado
- 3-5 dias
- Status de atividade
- Ativa
- Clareza
- Razoavelmente clara
- Facilidade para iniciantes
- 52/100