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
- 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.
Environment
mcp 2.1.1, mcp-types 2.1.1, Python 3.12.9
- Transport: streamable HTTP,
mode="auto", negotiated 2026-07-28
Summary
At
2026-07-28, aClientSessionthat has not listed a tool sendstools/callwith noMcp-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_mapsis private and_absorb_tool_listingis its only writer.Everything below reproduces against the SDK's own client and server, no third-party server involved.
Reproduction
Output on
mcp2.1.1:The
-32020arrives as HTTP 400. The client neither re-lists nor retries.What the spec asks for
SEP-2243, Client Behavior:
Two mechanisms in one paragraph. The SDK implements neither.
HEADER_MISMATCHhas zero readers undermcp/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_mapsis private, and the only writer is_absorb_tool_listing, reachable only through an in-sessionlist_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 pertools/calland never lists on it. The client-side symptom was a bare-32020from 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: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
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.-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._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.9mode="auto", negotiated2026-07-28