From 6b6d714c91404a2ba22120ac7359f1c61e38a775 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:14:25 +0000 Subject: [PATCH] Ignore pre-2026 protocol_version pins at the StreamableHTTP transport A stateful (pre-2026) protocol_version pin is a session-layer concern: it controls the protocolVersion field in the InitializeRequest body. #2910 threaded it into the transport unconditionally, which (a) stamped MCP-Protocol-Version on the initialize POST itself and (b) kept stamping the pinned value after the server negotiated a different version. The transport now only stores 2026-07-28+ pins (which never send initialize). For earlier versions it falls back to learning the negotiated version from the InitializeResult, restoring pre-#2910 wire behaviour for stateful sessions. Also adds a docstring caveat to Client.protocol_version noting the in-memory transport does not yet have a modern entry point. --- src/mcp/client/client.py | 4 +++- src/mcp/client/streamable_http.py | 12 ++++++----- tests/client/test_streamable_http.py | 30 +++++++++++++++++++++++++--- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index b5ae59daa3..ecb834b6b7 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -97,7 +97,9 @@ async def main(): Pinning to ``2026-07-28`` or later selects the stateless transport era: no initialize handshake is sent on the wire (the session synthesizes its `InitializeResult` locally), - and for HTTP the ``MCP-Protocol-Version`` header is set from the first request. + and for HTTP the ``MCP-Protocol-Version`` header is set from the first request. A modern + pin currently requires a URL or `Transport`; the in-memory `Server`/`MCPServer` path + does not yet have a modern entry point. Leave as ``None`` to negotiate the version via the initialize handshake. """ diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 3fa8bed1f5..fdb127ca0a 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -93,13 +93,14 @@ def __init__(self, url: str, protocol_version: str | None = None) -> None: Args: url: The endpoint URL. - protocol_version: Pin the MCP-Protocol-Version header from the first request - instead of waiting to snoop it from an InitializeResult. Required for - stateless 2026-07-28 sessions that never send initialize. + protocol_version: Pin the MCP-Protocol-Version header from the first request. + Only honoured for stateless 2026-07-28+ sessions that never send + initialize; for earlier (stateful) versions the header is populated + from the negotiated InitializeResult, so a pre-2026 value is ignored. """ self.url = url self.session_id: str | None = None - self.protocol_version: str | None = protocol_version + self.protocol_version: str | None = protocol_version if protocol_version in MODERN_PROTOCOL_VERSIONS else None def _per_message_headers(self, message: JSONRPCMessage) -> dict[str, str]: """Per-POST routing headers (Mcp-Method, Mcp-Name) for 2026-07-28+ pinned transports. @@ -158,7 +159,8 @@ def _maybe_extract_session_id_from_response(self, response: httpx.Response) -> N def _maybe_extract_protocol_version_from_message(self, message: JSONRPCMessage) -> None: """Extract protocol version from initialization response message.""" if self.protocol_version is not None: - # Constructor pin wins over snooping the InitializeResult. + # Only a modern constructor pin reaches here (pre-2026 values are dropped + # in __init__), and a modern pin never sends initialize. return if isinstance(message, JSONRPCResponse) and message.result: # pragma: no branch try: diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 0aecb971e8..bbe3e67fee 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -15,7 +15,12 @@ from inline_snapshot import snapshot from mcp.client import ClientSession -from mcp.client.streamable_http import StreamableHTTPTransport, _encode_header_value, streamable_http_client +from mcp.client.streamable_http import ( + MCP_PROTOCOL_VERSION, + StreamableHTTPTransport, + _encode_header_value, + streamable_http_client, +) from mcp.types import JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse @@ -135,8 +140,8 @@ def handler(request: httpx.Request) -> httpx.Response: assert all("mcp-session-id" not in r.headers for r in recorded) -def test_constructor_pin_is_not_overwritten_by_an_initialize_result() -> None: - """A protocol_version passed at construction wins over the InitializeResult snoop.""" +def test_modern_constructor_pin_is_not_overwritten_by_an_initialize_result() -> None: + """A 2026-07-28+ pin wins over the InitializeResult snoop (no initialize is ever sent).""" transport = StreamableHTTPTransport("http://test/mcp", protocol_version="2026-07-28") init = JSONRPCResponse( jsonrpc="2.0", @@ -149,3 +154,22 @@ def test_constructor_pin_is_not_overwritten_by_an_initialize_result() -> None: ) transport._maybe_extract_protocol_version_from_message(init) # pyright: ignore[reportPrivateUsage] assert transport.protocol_version == "2026-07-28" + + +def test_stateful_constructor_pin_is_ignored_and_the_negotiated_version_wins() -> None: + """A pre-2026 pin is a session-layer concern; the transport must not stamp it on the + initialize request and must adopt the server's negotiated version for later headers.""" + transport = StreamableHTTPTransport("http://test/mcp", protocol_version="2025-06-18") + assert MCP_PROTOCOL_VERSION not in transport._prepare_headers() # pyright: ignore[reportPrivateUsage] + init = JSONRPCResponse( + jsonrpc="2.0", + id=1, + result={ + "protocolVersion": "2025-03-26", + "capabilities": {}, + "serverInfo": {"name": "s", "version": "0"}, + }, + ) + transport._maybe_extract_protocol_version_from_message(init) # pyright: ignore[reportPrivateUsage] + assert transport.protocol_version == "2025-03-26" + assert transport._prepare_headers()[MCP_PROTOCOL_VERSION] == "2025-03-26" # pyright: ignore[reportPrivateUsage]