Skip to content

Commit 4954ed4

Browse files
committed
Make a pinned ClientSession born-initialized and centralize the modern-version constants
- A 2026-07-28-pinned ClientSession is born initialized: __init__ synthesizes an InitializeResult (placeholder server_info until server/discover lands) and initialize() returns it idempotently with no wire traffic. A repeat initialize() on a stateful session likewise returns the cached result. A stateful pin still requests that version on first call. - Add FIRST_MODERN_VERSION and MODERN_PROTOCOL_VERSIONS to mcp/shared/version and import them at the four call sites that previously inlined the literal. - Move the modern HTTP serving entry from _experimental/ to a private sibling module (mcp.server._streamable_http_modern); drop the empty package.
1 parent 12f2539 commit 4954ed4

9 files changed

Lines changed: 70 additions & 32 deletions

File tree

src/mcp/client/session.py

Lines changed: 24 additions & 6 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, is_version_at_least
24+
from mcp.shared.version import FIRST_MODERN_VERSION, SUPPORTED_PROTOCOL_VERSIONS, is_version_at_least
2525
from mcp.types import (
2626
CLIENT_CAPABILITIES_META_KEY,
2727
CLIENT_INFO_META_KEY,
@@ -156,15 +156,29 @@ def __init__(
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")
159+
self._stateless_pinned = protocol_version is not None and is_version_at_least(
160+
protocol_version, FIRST_MODERN_VERSION
161+
)
160162
self._sampling_callback = sampling_callback or _default_sampling_callback
161163
self._sampling_capabilities = sampling_capabilities
162164
self._elicitation_callback = elicitation_callback or _default_elicitation_callback
163165
self._list_roots_callback = list_roots_callback or _default_list_roots_callback
164166
self._logging_callback = logging_callback or _default_logging_callback
165167
self._message_handler = message_handler or _default_message_handler
166168
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
167-
self._initialize_result: types.InitializeResult | None = None
169+
self._initialize_result: types.InitializeResult | None
170+
if self._stateless_pinned:
171+
assert protocol_version is not None
172+
# A stateless-pinned session is born initialized: there is no handshake
173+
# at 2026-07-28+, so we synthesize the result locally. `server_info` is a
174+
# placeholder until `server/discover` is implemented to populate it.
175+
self._initialize_result = types.InitializeResult(
176+
protocol_version=protocol_version,
177+
capabilities=types.ServerCapabilities(),
178+
server_info=types.Implementation(name="", version=""),
179+
)
180+
else:
181+
self._initialize_result = None
168182
self._task_group: anyio.abc.TaskGroup | None = None
169183
if dispatcher is not None:
170184
if read_stream is not None or write_stream is not None:
@@ -300,8 +314,8 @@ def _build_capabilities(self) -> types.ClientCapabilities:
300314
return types.ClientCapabilities(sampling=sampling, elicitation=elicitation, experimental=None, roots=roots)
301315

302316
async def initialize(self) -> types.InitializeResult:
303-
if self._stateless_pinned:
304-
raise RuntimeError("initialize() must not be called on a session pinned to a stateless protocol version")
317+
if self._initialize_result is not None:
318+
return self._initialize_result
305319
capabilities = self._build_capabilities()
306320
result = await self.send_request(
307321
types.InitializeRequest(
@@ -329,7 +343,11 @@ async def initialize(self) -> types.InitializeResult:
329343
def initialize_result(self) -> types.InitializeResult | None:
330344
"""The server's InitializeResult. None until initialize() has been called.
331345
332-
Contains server_info, capabilities, instructions, and the negotiated protocol_version.
346+
A stateless-pinned session (protocol_version >= 2026-07-28) is born
347+
initialized: this property is populated at construction with a
348+
synthesized result and `initialize()` returns it without touching the
349+
wire. Contains server_info, capabilities, instructions, and the
350+
negotiated protocol_version.
333351
"""
334352
return self._initialize_result
335353

src/mcp/client/streamable_http.py

Lines changed: 2 additions & 3 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 is_version_at_least
24+
from mcp.shared.version import FIRST_MODERN_VERSION, is_version_at_least
2525
from mcp.types import (
2626
INTERNAL_ERROR,
2727
INVALID_REQUEST,
@@ -101,14 +101,13 @@ def __init__(self, url: str, protocol_version: str | None = None) -> None:
101101
self.session_id: str | None = None
102102
self.protocol_version: str | None = protocol_version
103103

104-
# TODO: header derivation from the pin (not body _meta) per spec; body envelope is request-only.
105104
def _per_message_headers(self, message: JSONRPCMessage) -> dict[str, str]:
106105
"""Per-POST routing headers (Mcp-Method, Mcp-Name) for 2026-07-28+ pinned transports.
107106
108107
MCP-Protocol-Version is not emitted here — `_prepare_headers()` already adds it
109108
from `self.protocol_version` for every request.
110109
"""
111-
if self.protocol_version is None or not is_version_at_least(self.protocol_version, "2026-07-28"):
110+
if self.protocol_version is None or not is_version_at_least(self.protocol_version, FIRST_MODERN_VERSION):
112111
return {}
113112
if not isinstance(message, JSONRPCRequest | JSONRPCNotification):
114113
return {}

src/mcp/server/_experimental/__init__.py

Lines changed: 0 additions & 1 deletion
This file was deleted.

src/mcp/server/_experimental/streamable_http_modern.py renamed to src/mcp/server/_streamable_http_modern.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
"""Experimental, unstable. Single-exchange HTTP serving for protocol version 2026-07-28.
1+
"""Single-exchange HTTP serving for protocol version 2026-07-28.
22
3-
No public API; everything in this module may change or vanish without
4-
deprecation. The legacy streamable-HTTP transport is untouched and remains the
5-
supported entry point.
3+
Private module — entry is via `StreamableHTTPSessionManager.handle_request`.
4+
The legacy streamable-HTTP transport is untouched and remains the supported
5+
path for earlier protocol revisions.
66
77
A 2026-07-28 request is a self-contained POST: no `initialize` handshake, no
88
`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. This
@@ -15,7 +15,7 @@
1515
import logging
1616
from collections.abc import Mapping
1717
from dataclasses import dataclass, field
18-
from typing import TYPE_CHECKING, Any, Final
18+
from typing import TYPE_CHECKING, Any
1919

2020
import anyio
2121
import anyio.abc
@@ -34,6 +34,7 @@
3434
from mcp.shared.exceptions import MCPError, NoBackChannelError
3535
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
3636
from mcp.shared.transport_context import TransportContext
37+
from mcp.shared.version import FIRST_MODERN_VERSION as MODERN_PROTOCOL_VERSION
3738
from mcp.types import (
3839
INTERNAL_ERROR,
3940
INVALID_PARAMS,
@@ -50,10 +51,6 @@
5051

5152
logger = logging.getLogger(__name__)
5253

53-
MODERN_PROTOCOL_VERSION: Final[str] = "2026-07-28"
54-
"""The protocol version this module serves. Kept local so it does not leak into
55-
`SUPPORTED_PROTOCOL_VERSIONS` or the legacy handshake."""
56-
5754

5855
@dataclass
5956
class _SingleExchangeDispatchContext:

src/mcp/server/streamable_http_manager.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from starlette.responses import Response
1515
from starlette.types import Receive, Scope, Send
1616

17-
from mcp.server._experimental.streamable_http_modern import handle_modern_request
17+
from mcp.server._streamable_http_modern import handle_modern_request
1818
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
1919
from mcp.server.streamable_http import (
2020
MCP_SESSION_ID_HEADER,
@@ -23,6 +23,7 @@
2323
)
2424
from mcp.server.transport_security import TransportSecuritySettings
2525
from mcp.shared._compat import resync_tracer
26+
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
2627
from mcp.types import INVALID_REQUEST, ErrorData, JSONRPCError
2728

2829
if TYPE_CHECKING:
@@ -154,7 +155,7 @@ async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> No
154155
# TODO: header-only routing for now; body-primary classification
155156
# (per SEP-2575) is a follow-up. 2025 paths below remain unchanged.
156157
pv = next((v.decode("latin-1") for k, v in scope["headers"] if k == b"mcp-protocol-version"), None)
157-
if pv == "2026-07-28":
158+
if pv in MODERN_PROTOCOL_VERSIONS:
158159
await handle_modern_request(self.app, self.security_settings, scope, receive, send)
159160
return
160161

src/mcp/shared/version.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020
)
2121
"""Every released protocol revision, oldest to newest."""
2222

23+
FIRST_MODERN_VERSION: Final[str] = "2026-07-28"
24+
"""First protocol revision with the stateless per-request envelope (no `initialize`)."""
25+
26+
MODERN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = ("2026-07-28",)
27+
"""Protocol revisions that use the stateless per-request envelope."""
28+
2329
SUPPORTED_PROTOCOL_VERSIONS: list[str] = ["2024-11-05", "2025-03-26", "2025-06-18", LATEST_PROTOCOL_VERSION]
2430
"""Protocol revisions this SDK can negotiate."""
2531

tests/client/test_session.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,26 +1401,33 @@ 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_stateless_pinned_session_raises_before_any_frame_is_sent():
1405-
"""A session pinned to the 2026-07-28 stateless protocol rejects ``initialize()`` locally.
1404+
async def test_initialize_on_a_stateless_pinned_session_returns_the_synthesized_result_without_any_frame_sent():
1405+
"""A session pinned to the 2026-07-28 stateless protocol is born initialized.
14061406
14071407
The 2026-07-28 lifecycle replaces the initialize handshake with a per-request ``_meta``
1408-
envelope, so calling ``initialize()`` on a stateless-pinned session is a programmer error
1409-
and raises immediately rather than reaching the wire.
1408+
envelope, so ``initialize()`` is idempotent and returns a locally-synthesized result
1409+
without ever touching the wire.
14101410
"""
14111411
async with raw_client_session(protocol_version="2026-07-28") as (session, _send, from_client):
1412-
with pytest.raises(RuntimeError, match="pinned to a stateless"):
1413-
await session.initialize()
1412+
result = await session.initialize()
1413+
assert result.protocol_version == "2026-07-28"
1414+
assert isinstance(result.capabilities, ServerCapabilities)
14141415
assert from_client.statistics().current_buffer_used == 0
1416+
assert (await session.initialize()) is result
14151417

14161418

14171419
@pytest.mark.anyio
14181420
async def test_initialize_on_a_stateful_pin_requests_the_pinned_version():
14191421
"""A session pinned to a pre-2026 stateful version still runs the handshake, but the
14201422
outgoing ``initialize`` frame requests the pinned version rather than ``LATEST``."""
14211423
async with raw_client_session(protocol_version="2025-06-18") as (session, to_client, from_client):
1424+
first: list[InitializeResult] = []
1425+
1426+
async def do_initialize() -> None:
1427+
first.append(await session.initialize())
1428+
14221429
async with anyio.create_task_group() as tg:
1423-
tg.start_soon(session.initialize)
1430+
tg.start_soon(do_initialize)
14241431
out = await from_client.receive()
14251432
assert isinstance(out.message, JSONRPCRequest)
14261433
assert out.message.params is not None
@@ -1439,6 +1446,14 @@ async def test_initialize_on_a_stateful_pin_requests_the_pinned_version():
14391446
)
14401447
)
14411448
)
1449+
# Drain the notifications/initialized frame so the buffer-used assertion below
1450+
# measures only what the second initialize() emits.
1451+
notif = await from_client.receive()
1452+
assert isinstance(notif.message, JSONRPCNotification)
1453+
# A second call returns the cached result without a second handshake frame.
1454+
again = await session.initialize()
1455+
assert again is first[0]
1456+
assert from_client.statistics().current_buffer_used == 0
14421457

14431458

14441459
@pytest.mark.anyio

tests/interaction/_requirements.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,10 @@ def __post_init__(self) -> None:
329329
),
330330
"lifecycle:stateless:no-initialize": Requirement(
331331
source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation",
332-
behavior="A ClientSession pinned to 2026-07-28 rejects initialize() before any frame is sent.",
332+
behavior=(
333+
"A ClientSession pinned to 2026-07-28 is born initialized: initialize() is idempotent "
334+
"and returns the synthesized result without any frame sent."
335+
),
333336
added_in="2026-07-28",
334337
deferred="covered by a tests/client/ unit test; not observable as an interaction",
335338
),

tests/server/test_experimental_streamable_http_modern.py renamed to tests/server/test_streamable_http_modern.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Unit tests for the experimental 2026-07-28 single-exchange HTTP serving entry.
1+
"""Unit tests for the 2026-07-28 single-exchange HTTP serving entry.
22
33
The interaction suite under ``tests/interaction/transports/test_hosting_http_modern.py`` pins
44
the wire contract end to end; these tests cover the module's internal seams directly --
@@ -16,9 +16,9 @@
1616
from starlette.requests import Request
1717
from starlette.types import Receive, Scope, Send
1818

19-
import mcp.server._experimental.streamable_http_modern as modern
19+
import mcp.server._streamable_http_modern as modern
2020
from mcp.server import Server, ServerRequestContext
21-
from mcp.server._experimental.streamable_http_modern import (
21+
from mcp.server._streamable_http_modern import (
2222
SingleExchangeDispatcher,
2323
_SingleExchangeDispatchContext,
2424
handle_modern_request,

0 commit comments

Comments
 (0)