From 77ed1bbf40d2fae12e2875aab4a714866fd4cb73 Mon Sep 17 00:00:00 2001 From: elphamale Date: Sun, 2 Aug 2026 20:25:17 +0300 Subject: [PATCH] fix(mcp): seed MCP-Protocol-Version from the handshake version, not the latest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTTP transport seeded `MCP-Protocol-Version` from LATEST_PROTOCOL_VERSION, which on mcp 2.x is 2026-07-28 — a revision that replaced the `initialize` handshake with a per-request envelope. But this transport connects through `ClientSession.initialize()`, which sends LATEST_HANDSHAKE_VERSION (2025-11-25) in the body. Header and body therefore disagreed by construction, and a conforming 2.x server honours the header: it routed the request onto its per-request-envelope ladder and rejected the legacy body with params._meta is missing the required envelope key(s): io.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientCapabilities Observed against a live MCP endpoint, and confirmed by probing the same endpoint three ways: the header at 2026-07-28 is rejected, at 2025-11-25 it succeeds, and with no header at all it succeeds. Third defect in this migration from one cause: the 2.x bump changed what an existing constant *means* without revisiting its uses. The header seed was written when LATEST_PROTOCOL_VERSION was 2025-03-26 and was correct then. Seeded from LATEST_HANDSHAKE_VERSION, imported with a fallback to LATEST_PROTOCOL_VERSION for SDKs predating the split, where the two are the same thing and header and body agree either way. An explicitly configured header still wins — that override is why servers demanding a specific revision can have one, and a test pins it. --- tests/tools/test_mcp_streamable_http_arity.py | 89 +++++++++++++++++++ tools/mcp_tool.py | 26 +++++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_mcp_streamable_http_arity.py b/tests/tools/test_mcp_streamable_http_arity.py index 49dfd96d25..2d4bc39f27 100644 --- a/tests/tools/test_mcp_streamable_http_arity.py +++ b/tests/tools/test_mcp_streamable_http_arity.py @@ -123,3 +123,92 @@ def test_the_session_streams_are_the_first_two_yielded(): asyncio.run(_drive()) assert passed["args"][:2] == (read, write) + + +def test_the_seeded_protocol_header_matches_the_handshake_the_client_sends(): + """Header and body must agree about which revision this connection speaks. + + `ClientSession.initialize()` sends `LATEST_HANDSHAKE_VERSION`; from + 2026-07-28 onward `LATEST_PROTOCOL_VERSION` names a revision that replaced + the handshake with a per-request envelope. Seeding the header from the + latter advertised a revision the body does not speak, and a conforming + server answered `params._meta is missing the required envelope key(s)` -- + observed against a live MCP endpoint, not hypothesised. + """ + from tools import mcp_tool + + try: + from mcp.client.session import LATEST_HANDSHAKE_VERSION as sdk_handshake + except ImportError: + pytest.skip("SDK predates the handshake/protocol version split") + + assert mcp_tool.LATEST_HANDSHAKE_VERSION == sdk_handshake + + +def test_the_seeded_header_is_the_handshake_version_on_the_wire(): + """Asserted through the header dict `_run_http` actually builds.""" + from unittest.mock import patch as _patch + + from tools.mcp_tool import MCPServerTask, LATEST_HANDSHAKE_VERSION + + server = MCPServerTask("remote") + seen: dict = {} + + class _CapturingAsyncClient(_DummyAsyncClient): + def __init__(self, **kwargs): + seen.update(kwargs) + super().__init__(**kwargs) + + async def _discover_tools(self): + self._shutdown_event.set() + + async def _drive(): + with _patch("tools.mcp_tool._MCP_HTTP_AVAILABLE", True), \ + _patch("tools.mcp_tool._MCP_NEW_HTTP", True), \ + _patch_sdk_async_client(_CapturingAsyncClient), \ + _patch("tools.mcp_tool.streamable_http_client", + return_value=_transport_yielding(MagicMock(), MagicMock())), \ + _patch("tools.mcp_tool.ClientSession", _DummySession), \ + _patch.object(MCPServerTask, "_discover_tools", _discover_tools): + await server._run_http({"url": "https://example.com/mcp"}) + + asyncio.run(_drive()) + + headers = {k.lower(): v for k, v in (seen.get("headers") or {}).items()} + assert headers.get("mcp-protocol-version") == LATEST_HANDSHAKE_VERSION + + +def test_an_explicit_protocol_header_still_wins(): + """The override exists so a server needing a specific revision can have it.""" + from unittest.mock import patch as _patch + + from tools.mcp_tool import MCPServerTask + + server = MCPServerTask("remote") + seen: dict = {} + + class _CapturingAsyncClient(_DummyAsyncClient): + def __init__(self, **kwargs): + seen.update(kwargs) + super().__init__(**kwargs) + + async def _discover_tools(self): + self._shutdown_event.set() + + async def _drive(): + with _patch("tools.mcp_tool._MCP_HTTP_AVAILABLE", True), \ + _patch("tools.mcp_tool._MCP_NEW_HTTP", True), \ + _patch_sdk_async_client(_CapturingAsyncClient), \ + _patch("tools.mcp_tool.streamable_http_client", + return_value=_transport_yielding(MagicMock(), MagicMock())), \ + _patch("tools.mcp_tool.ClientSession", _DummySession), \ + _patch.object(MCPServerTask, "_discover_tools", _discover_tools): + await server._run_http({ + "url": "https://example.com/mcp", + "headers": {"MCP-Protocol-Version": "2025-06-18"}, + }) + + asyncio.run(_drive()) + + headers = {k.lower(): v for k, v in (seen.get("headers") or {}).items()} + assert headers.get("mcp-protocol-version") == "2025-06-18" diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index bd418745bb..bd8b273b5e 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -222,6 +222,13 @@ sse_client = None # Streamable HTTP was introduced by 2025-03-26, so this remains valid for the # HTTP transport path even on older-but-supported SDK versions. LATEST_PROTOCOL_VERSION = "2025-03-26" +# The newest revision reachable through `ClientSession.initialize()`, which is +# NOT the newest revision the SDK knows about: from 2026-07-28 onward the +# handshake is replaced by a per-request envelope, so `initialize()` keeps +# sending `LATEST_HANDSHAKE_VERSION`. Seeding the MCP-Protocol-Version header +# from LATEST_PROTOCOL_VERSION would advertise a revision the body does not +# speak. Defaults to the handshake fallback for SDKs predating the split. +LATEST_HANDSHAKE_VERSION = LATEST_PROTOCOL_VERSION # The heavy SDK import is LAZY (see _ensure_mcp_sdk): importing `mcp` costs # ~260ms (mcp.types alone is ~60ms of pydantic model construction), which used @@ -280,7 +287,7 @@ def _ensure_mcp_sdk() -> bool: global _MCP_SDK_IMPORT_ATTEMPTED, _MCP_AVAILABLE, _MCP_HTTP_AVAILABLE global _MCP_SAMPLING_TYPES, _MCP_NOTIFICATION_TYPES, _MCP_ELICITATION_TYPES global _MCP_MESSAGE_HANDLER_SUPPORTED, _MCP_LOGGING_CALLBACK_SUPPORTED - global _MCP_NEW_HTTP, _MCP_LEGACY_HTTP, LATEST_PROTOCOL_VERSION, sse_client + global _MCP_NEW_HTTP, _MCP_LEGACY_HTTP, LATEST_PROTOCOL_VERSION, LATEST_HANDSHAKE_VERSION, sse_client global ClientSession, StdioServerParameters, stdio_client global streamablehttp_client, streamable_http_client global CreateMessageResult, CreateMessageResultWithTools, ErrorData @@ -329,6 +336,13 @@ def _ensure_mcp_sdk() -> bool: from mcp.types import LATEST_PROTOCOL_VERSION except ImportError: logger.debug("mcp.types.LATEST_PROTOCOL_VERSION not available -- using fallback protocol version") + try: + from mcp.client.session import LATEST_HANDSHAKE_VERSION + except ImportError: + # Pre-2.x SDKs make no distinction: the newest revision IS the + # newest handshake revision, so the header and the body agree + # either way. + LATEST_HANDSHAKE_VERSION = LATEST_PROTOCOL_VERSION # SSE transport client (for MCP servers using SSE transport instead of Streamable HTTP) try: from mcp.client.sse import sse_client @@ -3191,8 +3205,16 @@ class MCPServerTask: # initialize request and reject session-less POSTs otherwise. # Seed it as a client-level default, but treat user overrides as # case-insensitive so conventional casing is preserved. + # + # Seeded from the HANDSHAKE version, not the latest one: this transport + # connects via `ClientSession.initialize()`, which sends + # LATEST_HANDSHAKE_VERSION (2025-11-25) in the body. Advertising + # 2026-07-28 in the header routes the request onto the server's + # per-request-envelope ladder, which then rejects the legacy body for + # missing its required `params._meta` envelope keys. The header has to + # agree with what the body actually speaks. if not any(key.lower() == "mcp-protocol-version" for key in headers): - headers["mcp-protocol-version"] = LATEST_PROTOCOL_VERSION + headers["mcp-protocol-version"] = LATEST_HANDSHAKE_VERSION connect_timeout = config.get("connect_timeout", _DEFAULT_CONNECT_TIMEOUT) ssl_verify = config.get("ssl_verify", True) client_cert = _resolve_client_cert(self.name, config)