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:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user