Skip to content

Commit 12f2539

Browse files
committed
Dispatch ClientSession.protocol_version by era instead of restricting to a Literal
A pin to a stateful version now flows through initialize() as the requested version (instead of LATEST_PROTOCOL_VERSION); only a pin to a 2026-07-28+ stateless version raises. The envelope-stamp and cancel_on_abandon=False in send_request key off the same is_version_at_least gate. StatelessProtocolVersion is dropped.
1 parent 3afe0f0 commit 12f2539

4 files changed

Lines changed: 41 additions & 14 deletions

File tree

src/mcp/client/session.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from mcp.shared.message import ClientMessageMetadata, SessionMessage
2222
from mcp.shared.session import RequestResponder
2323
from mcp.shared.transport_context import TransportContext
24-
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS, StatelessProtocolVersion
24+
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS, is_version_at_least
2525
from mcp.types import (
2626
CLIENT_CAPABILITIES_META_KEY,
2727
CLIENT_INFO_META_KEY,
@@ -149,13 +149,14 @@ def __init__(
149149
message_handler: MessageHandlerFnT | None = None,
150150
client_info: types.Implementation | None = None,
151151
*,
152-
protocol_version: StatelessProtocolVersion | None = None,
152+
protocol_version: str | None = None,
153153
sampling_capabilities: types.SamplingCapability | None = None,
154154
dispatcher: Dispatcher[Any] | None = None,
155155
) -> None:
156156
self._session_read_timeout_seconds = read_timeout_seconds
157157
self._client_info = client_info or DEFAULT_CLIENT_INFO
158158
self._pinned_version = protocol_version
159+
self._stateless_pinned = protocol_version is not None and is_version_at_least(protocol_version, "2026-07-28")
159160
self._sampling_callback = sampling_callback or _default_sampling_callback
160161
self._sampling_capabilities = sampling_capabilities
161162
self._elicitation_callback = elicitation_callback or _default_elicitation_callback
@@ -229,7 +230,7 @@ async def send_request(
229230
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
230231
method: str = data["method"]
231232
opts: CallOptions = {}
232-
if self._pinned_version is not None:
233+
if self._stateless_pinned:
233234
params = data.setdefault("params", {})
234235
envelope_meta = params.setdefault("_meta", {})
235236
envelope_meta[PROTOCOL_VERSION_META_KEY] = self._pinned_version
@@ -299,13 +300,15 @@ def _build_capabilities(self) -> types.ClientCapabilities:
299300
return types.ClientCapabilities(sampling=sampling, elicitation=elicitation, experimental=None, roots=roots)
300301

301302
async def initialize(self) -> types.InitializeResult:
302-
if self._pinned_version is not None:
303+
if self._stateless_pinned:
303304
raise RuntimeError("initialize() must not be called on a session pinned to a stateless protocol version")
304305
capabilities = self._build_capabilities()
305306
result = await self.send_request(
306307
types.InitializeRequest(
307308
params=types.InitializeRequestParams(
308-
protocol_version=types.LATEST_PROTOCOL_VERSION,
309+
protocol_version=self._pinned_version
310+
if self._pinned_version is not None
311+
else types.LATEST_PROTOCOL_VERSION,
309312
capabilities=capabilities,
310313
client_info=self._client_info,
311314
),

src/mcp/client/streamable_http.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
2222
from mcp.shared._httpx_utils import create_mcp_http_client
2323
from mcp.shared.message import ClientMessageMetadata, SessionMessage
24-
from mcp.shared.version import StatelessProtocolVersion, is_version_at_least
24+
from mcp.shared.version import is_version_at_least
2525
from mcp.types import (
2626
INTERNAL_ERROR,
2727
INVALID_REQUEST,
@@ -567,7 +567,7 @@ async def streamable_http_client(
567567
*,
568568
http_client: httpx.AsyncClient | None = None,
569569
terminate_on_close: bool = True,
570-
protocol_version: StatelessProtocolVersion | None = None,
570+
protocol_version: str | None = None,
571571
) -> AsyncGenerator[TransportStreams, None]:
572572
"""Client transport for StreamableHTTP.
573573

src/mcp/shared/version.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,10 @@
77
ordering questions go through KNOWN_PROTOCOL_VERSIONS.
88
"""
99

10-
from typing import Final, Literal
10+
from typing import Final
1111

1212
from mcp.types import LATEST_PROTOCOL_VERSION
1313

14-
StatelessProtocolVersion = Literal["2026-07-28"]
15-
"""Protocol revisions that use the stateless per-request envelope (no `initialize`)."""
16-
1714
KNOWN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = (
1815
"2024-11-05",
1916
"2025-03-26",

tests/client/test_session.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,19 +1401,46 @@ async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
14011401

14021402

14031403
@pytest.mark.anyio
1404-
async def test_initialize_on_a_pinned_session_raises_before_any_frame_is_sent():
1404+
async def test_initialize_on_a_stateless_pinned_session_raises_before_any_frame_is_sent():
14051405
"""A session pinned to the 2026-07-28 stateless protocol rejects ``initialize()`` locally.
14061406
14071407
The 2026-07-28 lifecycle replaces the initialize handshake with a per-request ``_meta``
1408-
envelope, so calling ``initialize()`` on a pinned session is a programmer error and raises
1409-
immediately rather than reaching the wire.
1408+
envelope, so calling ``initialize()`` on a stateless-pinned session is a programmer error
1409+
and raises immediately rather than reaching the wire.
14101410
"""
14111411
async with raw_client_session(protocol_version="2026-07-28") as (session, _send, from_client):
14121412
with pytest.raises(RuntimeError, match="pinned to a stateless"):
14131413
await session.initialize()
14141414
assert from_client.statistics().current_buffer_used == 0
14151415

14161416

1417+
@pytest.mark.anyio
1418+
async def test_initialize_on_a_stateful_pin_requests_the_pinned_version():
1419+
"""A session pinned to a pre-2026 stateful version still runs the handshake, but the
1420+
outgoing ``initialize`` frame requests the pinned version rather than ``LATEST``."""
1421+
async with raw_client_session(protocol_version="2025-06-18") as (session, to_client, from_client):
1422+
async with anyio.create_task_group() as tg:
1423+
tg.start_soon(session.initialize)
1424+
out = await from_client.receive()
1425+
assert isinstance(out.message, JSONRPCRequest)
1426+
assert out.message.params is not None
1427+
assert out.message.params["protocolVersion"] == "2025-06-18"
1428+
result = InitializeResult(
1429+
protocol_version="2025-06-18",
1430+
capabilities=ServerCapabilities(),
1431+
server_info=Implementation(name="mock-server", version="0.1.0"),
1432+
)
1433+
await to_client.send(
1434+
SessionMessage(
1435+
JSONRPCResponse(
1436+
jsonrpc="2.0",
1437+
id=out.message.id,
1438+
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
1439+
)
1440+
)
1441+
)
1442+
1443+
14171444
@pytest.mark.anyio
14181445
async def test_send_notification_after_close_is_dropped_silently():
14191446
"""Post-close `send_notification` is fire-and-forget: the notification is dropped,

0 commit comments

Comments
 (0)