Skip to content

Commit 84bf9bd

Browse files
authored
First end-to-end 2026-07-28 stateless tools/call (experimental entry + ClientSession pin) (#2917)
1 parent 1cec2d6 commit 84bf9bd

26 files changed

Lines changed: 1515 additions & 93 deletions

.github/actions/conformance/expected-failures.2026-07-28.yml

Lines changed: 6 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -82,33 +82,11 @@ client:
8282
# neither run nor evaluated on this leg.
8383

8484
server:
85-
# --- No stateless server path on main yet (carried-forward 2025-era scenarios) ---
86-
# mcp-everything-server only runs in 2025 stateful mode. With
87-
# --spec-version 2026-07-28 the harness sends stateless requests
88-
# (MCP-Protocol-Version: 2026-07-28, _meta envelope, no initialize), which
89-
# the server rejects before the handler runs. These scenarios all pass on
90-
# the 2025 legs; they unblock once mcp-everything-server routes 2026
91-
# requests through a stateless path.
92-
- completion-complete
93-
- tools-list
94-
- tools-call-simple-text
95-
- tools-call-image
96-
- tools-call-audio
97-
- tools-call-embedded-resource
98-
- tools-call-mixed-content
99-
- tools-call-error
85+
# --- Carried-forward 2025-era scenarios still failing on the 2026 wire ---
86+
# The stateless 2026 path now reaches handlers for plain request/response
87+
# scenarios; tools-call-with-progress still fails because the stateless
88+
# server has no channel for server→client progress notifications.
10089
- tools-call-with-progress
101-
- server-sse-multiple-streams
102-
- resources-list
103-
- resources-read-text
104-
- resources-read-binary
105-
- resources-templates-read
106-
- prompts-list
107-
- prompts-get-simple
108-
- prompts-get-with-args
109-
- prompts-get-embedded-resource
110-
- prompts-get-with-image
111-
- dns-rebinding-protection
11290
# SEP-2106 (JSON Schema 2020-12 in tool inputSchema): the fixture tool's
11391
# schema has none of the 2020-12 keywords the scenario checks. The scenario
11492
# is in `--suite all` but not `--suite active`, so this is the only leg that
@@ -130,20 +108,16 @@ server:
130108
- input-required-result-result-type
131109
- input-required-result-tampered-state
132110
- input-required-result-capability-check
133-
# SEP-2549 (caching): no ttlMs/cacheScope support.
134-
- caching
111+
- input-required-result-validate-input
135112
# SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and
136113
# case-insensitive/whitespace-trimmed header validation not implemented.
137114
- http-header-validation
138-
- http-custom-header-server-validation
139115

140116
# --- WARNING-only entries ---
141117
# These scenarios emit no FAILURE checks, only SHOULD-level WARNINGs, but
142118
# the expected-failures evaluator counts WARNINGs as failures. Same entries
143119
# as the draft suite in expected-failures.yml.
144120
# SEP-2164: server returns -32600 (not -32602) and omits error.data.uri.
145121
- sep-2164-resource-not-found
146-
# SEP-2322 SHOULD-level behaviours (re-request missing inputResponses,
147-
# ignore unrecognized inputResponses keys).
122+
# SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
148123
- input-required-result-missing-input-response
149-
- input-required-result-ignore-extra-params

.github/actions/conformance/expected-failures.yml

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,23 +65,19 @@ server:
6565
- input-required-result-result-type
6666
- input-required-result-tampered-state
6767
- input-required-result-capability-check
68-
# SEP-2549 (caching): no ttlMs/cacheScope support; scenario also hits the
69-
# stateful-mode "Missing session ID" error.
70-
- caching
7168
# SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and
7269
# case-insensitive/whitespace-trimmed header validation not implemented.
7370
- http-header-validation
74-
- http-custom-header-server-validation
7571
# WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level
7672
# WARNINGs, but the expected-failures evaluator counts WARNINGs as failures.
7773
# SEP-2164: server returns -32600 (not -32602) and omits error.data.uri.
7874
- sep-2164-resource-not-found
79-
# SEP-2322 SHOULD-level behaviours (re-request missing inputResponses, ignore
80-
# unrecognized inputResponses keys).
75+
# SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
8176
- input-required-result-missing-input-response
82-
- input-required-result-ignore-extra-params
83-
# Intentionally NOT baselined (2 of 19 draft scenarios): the SEP-2322
84-
# negative-case scenarios input-required-result-unsupported-methods and
85-
# input-required-result-validate-input pass today only because the stateful
86-
# server's -32600 "Missing session ID" satisfies their assertions. They will
87-
# start failing for real once stateless mode lands; add them then.
77+
# SEP-2322 negative-case scenarios: input-required-result-validate-input is
78+
# now baselined (added when the stateless path landed — the stateless server
79+
# reaches the handler, so the previous accidental pass via -32600 "Missing
80+
# session ID" no longer applies). input-required-result-unsupported-methods
81+
# is intentionally NOT baselined: it still passes for now; add it once it
82+
# starts failing for real.
83+
- input-required-result-validate-input

docs/migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1241,7 +1241,7 @@ If you relied on extra fields round-tripping through MCP types, move that data i
12411241

12421242
### 2025-11-25 and 2026-07-28 protocol fields modeled
12431243

1244-
`mcp.types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `None`; `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions.
1244+
`mcp.types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `0`/`"private"` (immediately stale, not shared-cacheable); `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions.
12451245

12461246
### `streamable_http_app()` available on lowlevel Server
12471247

src/mcp/client/client.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,15 @@ async def main():
9292
client_info: Implementation | None = None
9393
"""Client implementation info to send to server."""
9494

95+
protocol_version: str | None = None
96+
"""Pin the protocol version instead of negotiating it.
97+
98+
Pinning to ``2026-07-28`` or later selects the stateless transport era: no initialize
99+
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.
101+
Leave as ``None`` to negotiate the version via the initialize handshake.
102+
"""
103+
95104
elicitation_callback: ElicitationFnT | None = None
96105
"""Callback for handling elicitation requests."""
97106

@@ -103,7 +112,7 @@ def __post_init__(self) -> None:
103112
if isinstance(self.server, Server | MCPServer):
104113
self._transport = InMemoryTransport(self.server, raise_exceptions=self.raise_exceptions)
105114
elif isinstance(self.server, str):
106-
self._transport = streamable_http_client(self.server)
115+
self._transport = streamable_http_client(self.server, protocol_version=self.protocol_version)
107116
else:
108117
self._transport = self.server
109118

@@ -126,6 +135,7 @@ async def __aenter__(self) -> Client:
126135
message_handler=self.message_handler,
127136
client_info=self.client_info,
128137
elicitation_callback=self.elicitation_callback,
138+
protocol_version=self.protocol_version,
129139
)
130140
)
131141

src/mcp/client/session.py

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,16 @@
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
25-
from mcp.types import INTERNAL_ERROR, METHOD_NOT_FOUND, RequestId, RequestParamsMeta
24+
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS
25+
from mcp.types import (
26+
CLIENT_CAPABILITIES_META_KEY,
27+
CLIENT_INFO_META_KEY,
28+
INTERNAL_ERROR,
29+
METHOD_NOT_FOUND,
30+
PROTOCOL_VERSION_META_KEY,
31+
RequestId,
32+
RequestParamsMeta,
33+
)
2634
from mcp.types import methods as _methods
2735

2836
DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0")
@@ -141,19 +149,34 @@ def __init__(
141149
message_handler: MessageHandlerFnT | None = None,
142150
client_info: types.Implementation | None = None,
143151
*,
152+
protocol_version: str | None = None,
144153
sampling_capabilities: types.SamplingCapability | None = None,
145154
dispatcher: Dispatcher[Any] | None = None,
146155
) -> None:
147156
self._session_read_timeout_seconds = read_timeout_seconds
148157
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
149160
self._sampling_callback = sampling_callback or _default_sampling_callback
150161
self._sampling_capabilities = sampling_capabilities
151162
self._elicitation_callback = elicitation_callback or _default_elicitation_callback
152163
self._list_roots_callback = list_roots_callback or _default_list_roots_callback
153164
self._logging_callback = logging_callback or _default_logging_callback
154165
self._message_handler = message_handler or _default_message_handler
155166
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
156-
self._initialize_result: types.InitializeResult | None = 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
157180
self._task_group: anyio.abc.TaskGroup | None = None
158181
if dispatcher is not None:
159182
if read_stream is not None or write_stream is not None:
@@ -219,6 +242,19 @@ async def send_request(
219242
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
220243
method: str = data["method"]
221244
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
222258
timeout = (
223259
request_read_timeout_seconds
224260
if request_read_timeout_seconds is not None
@@ -254,7 +290,7 @@ async def send_notification(self, notification: types.ClientNotification) -> Non
254290
data = notification.model_dump(by_alias=True, mode="json", exclude_none=True)
255291
await self._dispatcher.notify(data["method"], data.get("params"))
256292

257-
async def initialize(self) -> types.InitializeResult:
293+
def _build_capabilities(self) -> types.ClientCapabilities:
258294
sampling = (
259295
(self._sampling_capabilities or types.SamplingCapability())
260296
if self._sampling_callback is not _default_sampling_callback
@@ -273,17 +309,19 @@ async def initialize(self) -> types.InitializeResult:
273309
if self._list_roots_callback is not _default_list_roots_callback
274310
else None
275311
)
312+
return types.ClientCapabilities(sampling=sampling, elicitation=elicitation, experimental=None, roots=roots)
276313

314+
async def initialize(self) -> types.InitializeResult:
315+
if self._initialize_result is not None:
316+
return self._initialize_result
317+
capabilities = self._build_capabilities()
277318
result = await self.send_request(
278319
types.InitializeRequest(
279320
params=types.InitializeRequestParams(
280-
protocol_version=types.LATEST_PROTOCOL_VERSION,
281-
capabilities=types.ClientCapabilities(
282-
sampling=sampling,
283-
elicitation=elicitation,
284-
experimental=None,
285-
roots=roots,
286-
),
321+
protocol_version=self._pinned_version
322+
if self._pinned_version is not None
323+
else types.LATEST_PROTOCOL_VERSION,
324+
capabilities=capabilities,
287325
client_info=self._client_info,
288326
),
289327
),
@@ -303,14 +341,24 @@ async def initialize(self) -> types.InitializeResult:
303341
def initialize_result(self) -> types.InitializeResult | None:
304342
"""The server's InitializeResult. None until initialize() has been called.
305343
306-
Contains server_info, capabilities, instructions, and the negotiated protocol_version.
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.
307349
"""
308350
return self._initialize_result
309351

310352
@property
311353
def protocol_version(self) -> str | None:
312-
"""The negotiated protocol version. None until `initialize()` has completed."""
313-
return self._initialize_result.protocol_version if self._initialize_result else 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
314362

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

0 commit comments

Comments
 (0)