Skip to content

Commit 5a3412d

Browse files
authored
Ignore pre-2026 protocol_version pins at the StreamableHTTP transport (#2923)
1 parent fda4c54 commit 5a3412d

3 files changed

Lines changed: 37 additions & 9 deletions

File tree

src/mcp/client/client.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,9 @@ async def main():
9797
9898
Pinning to ``2026-07-28`` or later selects the stateless transport era: no initialize
9999
handshake is sent on the wire (the session synthesizes its `InitializeResult` locally),
100-
and for HTTP the ``MCP-Protocol-Version`` header is set from the first request.
100+
and for HTTP the ``MCP-Protocol-Version`` header is set from the first request. A modern
101+
pin currently requires a URL or `Transport`; the in-memory `Server`/`MCPServer` path
102+
does not yet have a modern entry point.
101103
Leave as ``None`` to negotiate the version via the initialize handshake.
102104
"""
103105

src/mcp/client/streamable_http.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,14 @@ def __init__(self, url: str, protocol_version: str | None = None) -> None:
9393
9494
Args:
9595
url: The endpoint URL.
96-
protocol_version: Pin the MCP-Protocol-Version header from the first request
97-
instead of waiting to snoop it from an InitializeResult. Required for
98-
stateless 2026-07-28 sessions that never send initialize.
96+
protocol_version: Pin the MCP-Protocol-Version header from the first request.
97+
Only honoured for stateless 2026-07-28+ sessions that never send
98+
initialize; for earlier (stateful) versions the header is populated
99+
from the negotiated InitializeResult, so a pre-2026 value is ignored.
99100
"""
100101
self.url = url
101102
self.session_id: str | None = None
102-
self.protocol_version: str | None = protocol_version
103+
self.protocol_version: str | None = protocol_version if protocol_version in MODERN_PROTOCOL_VERSIONS else None
103104

104105
def _per_message_headers(self, message: JSONRPCMessage) -> dict[str, str]:
105106
"""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
158159
def _maybe_extract_protocol_version_from_message(self, message: JSONRPCMessage) -> None:
159160
"""Extract protocol version from initialization response message."""
160161
if self.protocol_version is not None:
161-
# Constructor pin wins over snooping the InitializeResult.
162+
# Only a modern constructor pin reaches here (pre-2026 values are dropped
163+
# in __init__), and a modern pin never sends initialize.
162164
return
163165
if isinstance(message, JSONRPCResponse) and message.result: # pragma: no branch
164166
try:

tests/client/test_streamable_http.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515
from inline_snapshot import snapshot
1616

1717
from mcp.client import ClientSession
18-
from mcp.client.streamable_http import StreamableHTTPTransport, _encode_header_value, streamable_http_client
18+
from mcp.client.streamable_http import (
19+
MCP_PROTOCOL_VERSION,
20+
StreamableHTTPTransport,
21+
_encode_header_value,
22+
streamable_http_client,
23+
)
1924
from mcp.types import JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse
2025

2126

@@ -135,8 +140,8 @@ def handler(request: httpx.Request) -> httpx.Response:
135140
assert all("mcp-session-id" not in r.headers for r in recorded)
136141

137142

138-
def test_constructor_pin_is_not_overwritten_by_an_initialize_result() -> None:
139-
"""A protocol_version passed at construction wins over the InitializeResult snoop."""
143+
def test_modern_constructor_pin_is_not_overwritten_by_an_initialize_result() -> None:
144+
"""A 2026-07-28+ pin wins over the InitializeResult snoop (no initialize is ever sent)."""
140145
transport = StreamableHTTPTransport("http://test/mcp", protocol_version="2026-07-28")
141146
init = JSONRPCResponse(
142147
jsonrpc="2.0",
@@ -149,3 +154,22 @@ def test_constructor_pin_is_not_overwritten_by_an_initialize_result() -> None:
149154
)
150155
transport._maybe_extract_protocol_version_from_message(init) # pyright: ignore[reportPrivateUsage]
151156
assert transport.protocol_version == "2026-07-28"
157+
158+
159+
def test_stateful_constructor_pin_is_ignored_and_the_negotiated_version_wins() -> None:
160+
"""A pre-2026 pin is a session-layer concern; the transport must not stamp it on the
161+
initialize request and must adopt the server's negotiated version for later headers."""
162+
transport = StreamableHTTPTransport("http://test/mcp", protocol_version="2025-06-18")
163+
assert MCP_PROTOCOL_VERSION not in transport._prepare_headers() # pyright: ignore[reportPrivateUsage]
164+
init = JSONRPCResponse(
165+
jsonrpc="2.0",
166+
id=1,
167+
result={
168+
"protocolVersion": "2025-03-26",
169+
"capabilities": {},
170+
"serverInfo": {"name": "s", "version": "0"},
171+
},
172+
)
173+
transport._maybe_extract_protocol_version_from_message(init) # pyright: ignore[reportPrivateUsage]
174+
assert transport.protocol_version == "2025-03-26"
175+
assert transport._prepare_headers()[MCP_PROTOCOL_VERSION] == "2025-03-26" # pyright: ignore[reportPrivateUsage]

0 commit comments

Comments
 (0)