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)
Chưa có ai nhận issue này.
- Ngôn ngữ chính
- Python
- Star
- 24.3k
- Fork
- 4k
- Merge trung bình
- 1 ngày 1 giờ
- Pull request đã merge (30 ngày)
- 31
Mô tả
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
Hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Hướng nghiên cứu
Bắt đầu trong mcp/client/session.py tại ClientSession._resolve_param_headers và lần theo _absorb_tool_listing; so sánh điều này với cách xử lý HEADER_MISMATCH ở phía máy chủ trong mcp/server/_streamable_http_modern.py và mcp/shared/inbound.py. Chạy bản tái hiện được cung cấp, sau đó xác định phạm vi cho hành vi preload, số lần thử lại có giới hạn hoặc cảnh báo, và xác minh hành vi đã chọn bằng các bài kiểm thử client cùng bản tái hiện.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Đánh giá
- Công nghệ
- python
- Lĩnh vực
- api, backend
- Loại issue
- Tính năng
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức độ hoạt động
- Sôi nổi
- Độ rõ ràng
- Khá rõ ràng
- Mức phù hợp với người mới
- 52/100