fix(mcp): seed MCP-Protocol-Version from the handshake version, not the latest

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.
This commit is contained in:
elphamale
2026-08-02 20:25:17 +03:00
committed by Teknium
parent 2e1d724e3e
commit 77ed1bbf40
2 changed files with 113 additions and 2 deletions
@@ -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"
+24 -2
View File
@@ -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)