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)

オープン
#3,483 コメント 2 件 リアクション 0 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

spec-2026-07-28 v2
主要言語
Python
スター
24.3k
フォーク
4k
平均マージ
1日 1時間
マージ済み PR(30日)
31

説明

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

コントリビューションガイド

コントリビューションガイドを開く

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

調査の方向性

mcp/client/session.py の ClientSession._resolve_param_headers から開始し、_absorb_tool_listing を追跡します。これを mcp/server/_streamable_http_modern.py と mcp/shared/inbound.py におけるサーバー側の HEADER_MISMATCH 処理と比較します。提供された再現手順を実行し、その後、preload、回数を制限した再試行、または警告の動作の範囲を定め、選択した動作をクライアントテストと再現手順で検証します。

索引モデルが issue の本文から書いたものです。

評価

技術スタック
python
領域
api, backend
issue の種類
機能追加
難易度
4/5
見積もり時間
3〜5日
活発さ
活発
明瞭さ
おおむね明確
初心者へのやさしさ
52/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。