Skip to content

Commit 52546fa

Browse files
committed
ClientSession: install per-request stamp at connect time; transport becomes pv-agnostic
The era difference is now which stamp closure was installed, not a flag send_request reads: - Three stamp builders: _preconnect_stamp (cancel-suppressed only), _make_handshake_stamp (pv header), _make_modern_stamp (_meta envelope + cancel-suppressed + pv/method/name headers) - ClientSession.adopt(InitializeResult | DiscoverResult) installs negotiated state without wire traffic; .initialize() now calls .adopt(result) so the handshake stamp is installed before notifications/initialized goes out - send_request and send_notification call self._stamp(data, opts) unconditionally — _stateless_pinned, _pinned_version, and the inline envelope branch are deleted - ClientSession(protocol_version=) and Client.protocol_version removed - StreamableHTTPTransport drops protocol_version, _per_message_headers, _maybe_extract_protocol_version_from_message; _prepare_headers no longer derives the pv header. The transport caches the pv header from the first stamped POST's metadata and reuses it on transport-internal GET/DELETE - streamable_http_client(protocol_version=) removed - Interaction-suite [streamable-http-2026-07-28] arm now drives via ClientSession + .adopt(DiscoverResult); pagination/cancellation tests adapted to the Client|ClientSession common subset - migration.md documents the removals
1 parent e0990bf commit 52546fa

12 files changed

Lines changed: 215 additions & 361 deletions

File tree

‎docs/migration.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been re
159159

160160
Note: `sse_client` retains its `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters — only the streamable HTTP transport changed.
161161

162+
### `protocol_version` removed from `StreamableHTTPTransport` and `streamable_http_client`
163+
164+
The `protocol_version` attribute on `StreamableHTTPTransport` and the `protocol_version` parameter on `streamable_http_client` have been removed. The transport no longer holds per-connection protocol state; era-dependent headers (e.g. `MCP-Protocol-Version`) are supplied per-message by the session, so the transport never needs to know the negotiated version.
165+
162166
### `terminate_windows_process` removed
163167

164168
The deprecated `mcp.os.win32.utilities.terminate_windows_process` function has been
@@ -350,6 +354,10 @@ if result is not None:
350354

351355
The high-level `Client.initialize_result` returns the same `InitializeResult` but is non-nullable — initialization is guaranteed inside the context manager, so no `None` check is needed. This replaces v1's `Client.server_capabilities`; use `client.initialize_result.capabilities` instead.
352356

357+
### `ClientSession(protocol_version=)` removed
358+
359+
The `protocol_version` constructor parameter on `ClientSession` has been removed. To install a known protocol version without performing the `initialize` handshake (e.g. when reconnecting to an existing session), call `session.adopt(result)` after construction with a stored `InitializeResult`.
360+
353361
### `McpError` renamed to `MCPError`
354362

355363
The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK.

‎src/mcp/client/client.py‎

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -95,17 +95,6 @@ async def main():
9595
client_info: Implementation | None = None
9696
"""Client implementation info to send to server."""
9797

98-
protocol_version: str | None = None
99-
"""Pin the protocol version instead of negotiating it.
100-
101-
Pinning to ``2026-07-28`` or later selects the stateless transport era: no initialize
102-
handshake is sent on the wire (the session synthesizes its `InitializeResult` locally),
103-
and for HTTP the ``MCP-Protocol-Version`` header is set from the first request. A modern
104-
pin currently requires a URL or `Transport`; the in-memory `Server`/`MCPServer` path
105-
does not yet have a modern entry point.
106-
Leave as ``None`` to negotiate the version via the initialize handshake.
107-
"""
108-
10998
elicitation_callback: ElicitationFnT | None = None
11099
"""Callback for handling elicitation requests."""
111100

@@ -117,7 +106,7 @@ def __post_init__(self) -> None:
117106
if isinstance(self.server, Server | MCPServer):
118107
self._transport = InMemoryTransport(self.server, raise_exceptions=self.raise_exceptions)
119108
elif isinstance(self.server, str):
120-
self._transport = streamable_http_client(self.server, protocol_version=self.protocol_version)
109+
self._transport = streamable_http_client(self.server)
121110
else:
122111
self._transport = self.server
123112

@@ -140,7 +129,6 @@ async def __aenter__(self) -> Client:
140129
message_handler=self.message_handler,
141130
client_info=self.client_info,
142131
elicitation_callback=self.elicitation_callback,
143-
protocol_version=self.protocol_version,
144132
)
145133
)
146134

‎src/mcp/client/session.py‎

Lines changed: 77 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import logging
4-
from collections.abc import Mapping
4+
from collections.abc import Callable, Mapping
55
from dataclasses import dataclass
66
from types import TracebackType
77
from typing import Any, Protocol, cast
@@ -17,6 +17,12 @@
1717
from mcp.shared._compat import resync_tracer
1818
from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT
1919
from mcp.shared.exceptions import MCPDeprecationWarning, MCPError
20+
from mcp.shared.inbound import (
21+
MCP_METHOD_HEADER,
22+
MCP_NAME_HEADER,
23+
MCP_PROTOCOL_VERSION_HEADER,
24+
encode_header_value,
25+
)
2026
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
2127
from mcp.shared.message import ClientMessageMetadata, SessionMessage
2228
from mcp.shared.session import RequestResponder
@@ -37,6 +43,38 @@
3743

3844
logger = logging.getLogger("client")
3945

46+
47+
def _preconnect_stamp(data: dict[str, Any], opts: CallOptions) -> None:
48+
# Only initialize/discover go out before connect; both forbid cancellation.
49+
opts["cancel_on_abandon"] = False
50+
51+
52+
def _make_handshake_stamp(protocol_version: str) -> Callable[[dict[str, Any], CallOptions], None]:
53+
def stamp(data: dict[str, Any], opts: CallOptions) -> None:
54+
opts.setdefault("headers", {})[MCP_PROTOCOL_VERSION_HEADER] = protocol_version
55+
56+
return stamp
57+
58+
59+
def _make_modern_stamp(
60+
protocol_version: str, client_info: dict[str, Any], capabilities: dict[str, Any]
61+
) -> Callable[[dict[str, Any], CallOptions], None]:
62+
def stamp(data: dict[str, Any], opts: CallOptions) -> None:
63+
params = data.setdefault("params", {})
64+
meta = params.setdefault("_meta", {})
65+
meta[PROTOCOL_VERSION_META_KEY] = protocol_version
66+
meta[CLIENT_INFO_META_KEY] = client_info
67+
meta[CLIENT_CAPABILITIES_META_KEY] = capabilities
68+
opts["cancel_on_abandon"] = False
69+
headers = opts.setdefault("headers", {})
70+
headers[MCP_PROTOCOL_VERSION_HEADER] = protocol_version
71+
headers[MCP_METHOD_HEADER] = data["method"]
72+
if data["method"] == "tools/call" and isinstance(name := params.get("name"), str):
73+
headers[MCP_NAME_HEADER] = encode_header_value(name)
74+
75+
return stamp
76+
77+
4078
ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel)
4179

4280

@@ -149,34 +187,20 @@ def __init__(
149187
message_handler: MessageHandlerFnT | None = None,
150188
client_info: types.Implementation | None = None,
151189
*,
152-
protocol_version: str | None = None,
153190
sampling_capabilities: types.SamplingCapability | None = None,
154191
dispatcher: Dispatcher[Any] | None = None,
155192
) -> None:
156193
self._session_read_timeout_seconds = read_timeout_seconds
157194
self._client_info = client_info or DEFAULT_CLIENT_INFO
158-
self._pinned_version = protocol_version
159-
self._stateless_pinned = protocol_version in MODERN_PROTOCOL_VERSIONS
160195
self._sampling_callback = sampling_callback or _default_sampling_callback
161196
self._sampling_capabilities = sampling_capabilities
162197
self._elicitation_callback = elicitation_callback or _default_elicitation_callback
163198
self._list_roots_callback = list_roots_callback or _default_list_roots_callback
164199
self._logging_callback = logging_callback or _default_logging_callback
165200
self._message_handler = message_handler or _default_message_handler
166201
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
167-
self._initialize_result: types.InitializeResult | None
168-
if self._stateless_pinned:
169-
assert protocol_version is not None
170-
# A stateless-pinned session is born initialized: there is no handshake
171-
# at 2026-07-28+, so we synthesize the result locally. `server_info` is a
172-
# placeholder until `server/discover` is implemented to populate it.
173-
self._initialize_result = types.InitializeResult(
174-
protocol_version=protocol_version,
175-
capabilities=types.ServerCapabilities(),
176-
server_info=types.Implementation(name="", version=""),
177-
)
178-
else:
179-
self._initialize_result = None
202+
self._initialize_result: types.InitializeResult | None = None
203+
self._stamp: Callable[[dict[str, Any], CallOptions], None] = _preconnect_stamp
180204
self._task_group: anyio.abc.TaskGroup | None = None
181205
if dispatcher is not None:
182206
if read_stream is not None or write_stream is not None:
@@ -242,19 +266,7 @@ async def send_request(
242266
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
243267
method: str = data["method"]
244268
opts: CallOptions = {}
245-
if self._stateless_pinned:
246-
params = data.setdefault("params", {})
247-
envelope_meta = params.setdefault("_meta", {})
248-
envelope_meta[PROTOCOL_VERSION_META_KEY] = self._pinned_version
249-
envelope_meta[CLIENT_INFO_META_KEY] = self._client_info.model_dump(
250-
by_alias=True, mode="json", exclude_none=True
251-
)
252-
envelope_meta[CLIENT_CAPABILITIES_META_KEY] = self._build_capabilities().model_dump(
253-
by_alias=True, mode="json", exclude_none=True
254-
)
255-
# Stateless pinned mode: disconnect-as-cancel is the spec mechanism, so the
256-
# dispatcher must not emit notifications/cancelled when the caller abandons.
257-
opts["cancel_on_abandon"] = False
269+
self._stamp(data, opts)
258270
timeout = (
259271
request_read_timeout_seconds
260272
if request_read_timeout_seconds is not None
@@ -269,9 +281,6 @@ async def send_request(
269281
opts["resumption_token"] = metadata.resumption_token
270282
if metadata.on_resumption_token_update is not None:
271283
opts["on_resumption_token"] = metadata.on_resumption_token_update
272-
if method == "initialize":
273-
# The spec forbids cancelling initialize.
274-
opts["cancel_on_abandon"] = False
275284
raw = await self._dispatcher.send_raw_request(method, data.get("params"), opts)
276285
# Literal fallback covers pre-handshake and stateless; matches runner.py.
277286
version = self.protocol_version or "2025-11-25"
@@ -288,7 +297,9 @@ async def send_notification(self, notification: types.ClientNotification) -> Non
288297
dropped with a debug log instead of raising.
289298
"""
290299
data = notification.model_dump(by_alias=True, mode="json", exclude_none=True)
291-
await self._dispatcher.notify(data["method"], data.get("params"))
300+
opts: CallOptions = {}
301+
self._stamp(data, opts)
302+
await self._dispatcher.notify(data["method"], data.get("params"), opts)
292303

293304
def _build_capabilities(self) -> types.ClientCapabilities:
294305
sampling = (
@@ -314,14 +325,11 @@ def _build_capabilities(self) -> types.ClientCapabilities:
314325
async def initialize(self) -> types.InitializeResult:
315326
if self._initialize_result is not None:
316327
return self._initialize_result
317-
capabilities = self._build_capabilities()
318328
result = await self.send_request(
319329
types.InitializeRequest(
320330
params=types.InitializeRequestParams(
321-
protocol_version=self._pinned_version
322-
if self._pinned_version is not None
323-
else HANDSHAKE_PROTOCOL_VERSIONS[-1],
324-
capabilities=capabilities,
331+
protocol_version=HANDSHAKE_PROTOCOL_VERSIONS[-1],
332+
capabilities=self._build_capabilities(),
325333
client_info=self._client_info,
326334
),
327335
),
@@ -331,34 +339,49 @@ async def initialize(self) -> types.InitializeResult:
331339
if result.protocol_version not in HANDSHAKE_PROTOCOL_VERSIONS:
332340
raise RuntimeError(f"Unsupported protocol version from the server: {result.protocol_version}")
333341

334-
self._initialize_result = result
342+
self.adopt(result)
335343

336344
await self.send_notification(types.InitializedNotification())
337345

338346
return result
339347

348+
def adopt(self, result: types.InitializeResult | types.DiscoverResult) -> None:
349+
"""Install negotiated state from a result the caller already holds (no wire traffic)."""
350+
if isinstance(result, types.DiscoverResult):
351+
mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in result.supported_versions]
352+
if not mutual:
353+
raise RuntimeError(
354+
f"No mutually supported modern protocol version "
355+
f"(server: {result.supported_versions}, client: {list(MODERN_PROTOCOL_VERSIONS)})"
356+
)
357+
protocol_version = mutual[-1]
358+
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True)
359+
capabilities = self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True)
360+
self._stamp = _make_modern_stamp(protocol_version, client_info, capabilities)
361+
self._initialize_result = types.InitializeResult(
362+
protocol_version=protocol_version,
363+
capabilities=result.capabilities,
364+
server_info=result.server_info,
365+
instructions=result.instructions,
366+
)
367+
else:
368+
self._stamp = _make_handshake_stamp(result.protocol_version)
369+
self._initialize_result = result
370+
340371
@property
341372
def initialize_result(self) -> types.InitializeResult | None:
342-
"""The server's InitializeResult. None until initialize() has been called.
373+
"""The server's InitializeResult. None until `initialize()` or `adopt()`.
343374
344-
A stateless-pinned session (protocol_version >= 2026-07-28) is born
345-
initialized: this property is populated at construction with a
346-
synthesized result and `initialize()` returns it without touching the
347-
wire. Contains server_info, capabilities, instructions, and the
348-
negotiated protocol_version.
375+
Contains server_info, capabilities, instructions, and the negotiated
376+
protocol_version. For a modern session adopted from a DiscoverResult,
377+
this is synthesized locally with the chosen protocol version.
349378
"""
350379
return self._initialize_result
351380

352381
@property
353382
def protocol_version(self) -> str | None:
354-
"""Negotiated or pinned protocol version. None until initialize() unless pinned at construction.
355-
356-
Once `initialize()` has completed, this is the version the server actually
357-
negotiated (which can differ from a stateful pin); before that, the pin.
358-
"""
359-
if self._initialize_result is not None:
360-
return self._initialize_result.protocol_version
361-
return self._pinned_version
383+
"""Negotiated protocol version. None until `initialize()` or `adopt()`."""
384+
return self._initialize_result.protocol_version if self._initialize_result is not None else None
362385

363386
async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult:
364387
"""Send a ping request."""

0 commit comments

Comments
 (0)