Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/mcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

Expand Down
12 changes: 7 additions & 5 deletions src/mcp/client/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
30 changes: 27 additions & 3 deletions tests/client/test_streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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",
Expand All @@ -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]
Loading