Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c0397c0
Add experimental 2026-07-28 stateless HTTP serving entry
maxisbey Jun 18, 2026
980d57a
Add protocol_version pin to ClientSession for stateless 2026-07-28 mode
maxisbey Jun 18, 2026
188dc83
Pass app and security_settings explicitly to handle_modern_request
maxisbey Jun 18, 2026
9aebd53
Add interaction tests for the 2026-07-28 stateless lifecycle and HTTP…
maxisbey Jun 19, 2026
a4f0939
Register 2026-07-28 stateless requirements in the interaction-suite m…
maxisbey Jun 19, 2026
cf65e8b
Add MockTransport, capstone, and client-unit tests for the 2026-07-28…
maxisbey Jun 19, 2026
081c564
Register remaining 2026-07-28 stateless requirements
maxisbey Jun 19, 2026
ae383c5
Add coverage tests for the experimental modern HTTP entry
maxisbey Jun 19, 2026
26ff922
Reconcile conformance baselines for the stateless serving path
maxisbey Jun 19, 2026
92c078a
Harden the experimental modern HTTP entry's error and cleanup paths
maxisbey Jun 19, 2026
06d1492
Derive transport headers from a constructor protocol_version pin
maxisbey Jun 19, 2026
4378d15
Mark post-shielded-cancel assertions as lax-no-cover for 3.11
maxisbey Jun 19, 2026
194f225
Address review feedback and consolidate 2026-07-28 interaction tests
maxisbey Jun 19, 2026
3afe0f0
Tighten the consolidated 2026-07-28 interaction tests
maxisbey Jun 19, 2026
12f2539
Dispatch ClientSession.protocol_version by era instead of restricting…
maxisbey Jun 19, 2026
4954ed4
Make a pinned ClientSession born-initialized and centralize the moder…
maxisbey Jun 19, 2026
47a422c
Use MODERN_PROTOCOL_VERSIONS membership instead of a threshold constant
maxisbey Jun 20, 2026
7bf87f8
Thread protocol_version through Client and default CacheableResult to…
maxisbey Jun 20, 2026
3fcf832
Turn on the 2026-07-28 column in the interaction-suite matrix
maxisbey Jun 20, 2026
05211a3
Reconcile conformance baselines after CacheableResult default change
maxisbey Jun 20, 2026
1044f42
Report the negotiated protocol_version once initialized; clarify enve…
maxisbey Jun 20, 2026
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
38 changes: 6 additions & 32 deletions .github/actions/conformance/expected-failures.2026-07-28.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,33 +82,11 @@ client:
# neither run nor evaluated on this leg.

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

# --- WARNING-only entries ---
# These scenarios emit no FAILURE checks, only SHOULD-level WARNINGs, but
# the expected-failures evaluator counts WARNINGs as failures. Same entries
# as the draft suite in expected-failures.yml.
# SEP-2164: server returns -32600 (not -32602) and omits error.data.uri.
- sep-2164-resource-not-found
# SEP-2322 SHOULD-level behaviours (re-request missing inputResponses,
# ignore unrecognized inputResponses keys).
# SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
- input-required-result-missing-input-response
- input-required-result-ignore-extra-params
20 changes: 8 additions & 12 deletions .github/actions/conformance/expected-failures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,23 +65,19 @@ server:
- input-required-result-result-type
- input-required-result-tampered-state
- input-required-result-capability-check
# SEP-2549 (caching): no ttlMs/cacheScope support; scenario also hits the
# stateful-mode "Missing session ID" error.
- caching
# SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and
# case-insensitive/whitespace-trimmed header validation not implemented.
- http-header-validation
- http-custom-header-server-validation
# WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level
# WARNINGs, but the expected-failures evaluator counts WARNINGs as failures.
# SEP-2164: server returns -32600 (not -32602) and omits error.data.uri.
- sep-2164-resource-not-found
# SEP-2322 SHOULD-level behaviours (re-request missing inputResponses, ignore
# unrecognized inputResponses keys).
# SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
- input-required-result-missing-input-response
- input-required-result-ignore-extra-params
# Intentionally NOT baselined (2 of 19 draft scenarios): the SEP-2322
# negative-case scenarios input-required-result-unsupported-methods and
# input-required-result-validate-input pass today only because the stateful
# server's -32600 "Missing session ID" satisfies their assertions. They will
# start failing for real once stateless mode lands; add them then.
# SEP-2322 negative-case scenarios: input-required-result-validate-input is
# now baselined (added when the stateless path landed — the stateless server
# reaches the handler, so the previous accidental pass via -32600 "Missing
# session ID" no longer applies). input-required-result-unsupported-methods
# is intentionally NOT baselined: it still passes for now; add it once it
# starts failing for real.
- input-required-result-validate-input
2 changes: 1 addition & 1 deletion docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1241,7 +1241,7 @@ If you relied on extra fields round-tripping through MCP types, move that data i

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

`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.
`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.

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

Expand Down
12 changes: 11 additions & 1 deletion src/mcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@
client_info: Implementation | None = None
"""Client implementation info to send to server."""

protocol_version: str | None = None
"""Pin the protocol version instead of negotiating it.

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.
Leave as ``None`` to negotiate the version via the initialize handshake.
"""

elicitation_callback: ElicitationFnT | None = None
"""Callback for handling elicitation requests."""

Expand All @@ -100,13 +109,13 @@
_transport: Transport = field(init=False)

def __post_init__(self) -> None:
if isinstance(self.server, Server | MCPServer):
self._transport = InMemoryTransport(self.server, raise_exceptions=self.raise_exceptions)
elif isinstance(self.server, str):
self._transport = streamable_http_client(self.server)
self._transport = streamable_http_client(self.server, protocol_version=self.protocol_version)
else:
self._transport = self.server

Check warning on line 118 in src/mcp/client/client.py

View check run for this annotation

Claude / Claude Code Review

In-memory transport plus 2026-07-28 pin yields a silently broken client

Pinning `protocol_version="2026-07-28"` on a `Client` constructed with a `Server`/`MCPServer` instance silently produces a broken session: the pin only flows into `streamable_http_client` for URL servers, while the in-memory transport still drives the legacy stateful server, so the born-initialized session never sends `initialize`/`notifications/initialized` and every call fails with an opaque "Invalid request parameters" error. Since the in-memory modern entry is out of scope for this PR, consi
Comment on lines 112 to 118

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Pinning protocol_version="2026-07-28" on a Client constructed with a Server/MCPServer instance silently produces a broken session: the pin only flows into streamable_http_client for URL servers, while the in-memory transport still drives the legacy stateful server, so the born-initialized session never sends initialize/notifications/initialized and every call fails with an opaque "Invalid request parameters" error. Since the in-memory modern entry is out of scope for this PR, consider a constructor-time fail-fast (raise when a MODERN_PROTOCOL_VERSIONS pin is combined with a non-URL server) or a docstring restriction on the new protocol_version field.

Extended reasoning...

What happens

Client.__post_init__ (src/mcp/client/client.py:112-118) only forwards protocol_version into streamable_http_client(...) when server is a URL string. When server is a Server/MCPServer instance it builds InMemoryTransport(self.server, ...) with no version awareness — but __aenter__ still passes protocol_version into ClientSession. With a 2026-07-28 pin that session is "born initialized": initialize() returns the locally-synthesized result without ever touching the wire, and no notifications/initialized is sent.

Why every call then fails

InMemoryTransport._connect runs the lowlevel server via actual_server.run(...) with the default stateless=False, so the legacy init gate in ServerRunner._on_request applies: any non-ping request before initialize/notifications/initialized is rejected with MCPError(INVALID_PARAMS, "Invalid request parameters"). The per-request io.modelcontextprotocol/protocolVersion envelope the pinned session stamps does not help — 2026-07-28 is not in SUPPORTED_PROTOCOL_VERSIONS, and the init gate is independent of version resolution anyway.

Step-by-step proof

  1. server = MCPServer("test") with an add tool (the exact pattern in the Client class docstring example).
  2. async with Client(server, protocol_version="2026-07-28") as client:__post_init__ wraps server in InMemoryTransport (no version pin); __aenter__ builds a stateless-pinned ClientSession and calls initialize(), which returns the synthesized result without sending any frame. The context manager enters successfully.
  3. await client.call_tool("add", {"a": 1, "b": 2}) — the request reaches the lowlevel server's ServerRunner._on_request; connection.initialize_accepted is false (no handshake ever happened), so the gate raises INVALID_PARAMS and the user sees MCPError: Invalid request parameters — an error that points nowhere near the actual cause (an unsupported transport/version combination chosen at construction).

Why nothing prevents it today

Nothing in Client.__post_init__ rejects or warns about the combination, and the new Client.protocol_version docstring describes the stateless behaviour generically ("Pinning to 2026-07-28 or later selects the stateless transport era...") with HTTP only mentioned as an additional detail — while the class's own primary docstring example is exactly the in-memory MCPServer case. The PR description lists the in-memory modern entry as out of scope, and the interaction suite era-locks in-memory to 2025-11-25 in TRANSPORT_SPEC_VERSIONS, so the gap is known internally — but the public API surface gives the user no signal.

Impact and suggested fix

This is not silent corruption — the first call fails loudly — but the failure is confusing and far removed from the cause, on a brand-new public parameter whose docstring invites exactly this usage. A cheap, non-breaking guard fixes it: in __post_init__, raise (e.g. ValueError) when protocol_version in MODERN_PROTOCOL_VERSIONS and server is a Server/MCPServer instance (or, more conservatively, any non-URL transport), with a message pointing at the missing in-memory modern entry. Alternatively, restrict the pin to URL/HTTP servers in the protocol_version docstring until the in-memory modern entry lands.

async def __aenter__(self) -> Client:
"""Enter the async context manager."""
if self._session is not None:
Expand All @@ -126,6 +135,7 @@
message_handler=self.message_handler,
client_info=self.client_info,
elicitation_callback=self.elicitation_callback,
protocol_version=self.protocol_version,
)
)

Expand Down
76 changes: 62 additions & 14 deletions src/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,16 @@
from mcp.shared.message import ClientMessageMetadata, SessionMessage
from mcp.shared.session import RequestResponder
from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
from mcp.types import INTERNAL_ERROR, METHOD_NOT_FOUND, RequestId, RequestParamsMeta
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS
from mcp.types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
INTERNAL_ERROR,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
RequestId,
RequestParamsMeta,
)
from mcp.types import methods as _methods

DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0")
Expand Down Expand Up @@ -141,19 +149,34 @@ def __init__(
message_handler: MessageHandlerFnT | None = None,
client_info: types.Implementation | None = None,
*,
protocol_version: str | None = None,
sampling_capabilities: types.SamplingCapability | None = None,
dispatcher: Dispatcher[Any] | None = None,
) -> None:
self._session_read_timeout_seconds = read_timeout_seconds
self._client_info = client_info or DEFAULT_CLIENT_INFO
self._pinned_version = protocol_version
self._stateless_pinned = protocol_version in MODERN_PROTOCOL_VERSIONS
self._sampling_callback = sampling_callback or _default_sampling_callback
self._sampling_capabilities = sampling_capabilities
self._elicitation_callback = elicitation_callback or _default_elicitation_callback
self._list_roots_callback = list_roots_callback or _default_list_roots_callback
self._logging_callback = logging_callback or _default_logging_callback
self._message_handler = message_handler or _default_message_handler
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
self._initialize_result: types.InitializeResult | None = None
self._initialize_result: types.InitializeResult | None
if self._stateless_pinned:
assert protocol_version is not None
# A stateless-pinned session is born initialized: there is no handshake
# at 2026-07-28+, so we synthesize the result locally. `server_info` is a
# placeholder until `server/discover` is implemented to populate it.
self._initialize_result = types.InitializeResult(
protocol_version=protocol_version,
capabilities=types.ServerCapabilities(),
server_info=types.Implementation(name="", version=""),
)
else:
self._initialize_result = None
self._task_group: anyio.abc.TaskGroup | None = None
if dispatcher is not None:
if read_stream is not None or write_stream is not None:
Expand Down Expand Up @@ -219,6 +242,19 @@ async def send_request(
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
method: str = data["method"]
opts: CallOptions = {}
if self._stateless_pinned:
params = data.setdefault("params", {})
envelope_meta = params.setdefault("_meta", {})
envelope_meta[PROTOCOL_VERSION_META_KEY] = self._pinned_version
envelope_meta[CLIENT_INFO_META_KEY] = self._client_info.model_dump(
by_alias=True, mode="json", exclude_none=True
)
envelope_meta[CLIENT_CAPABILITIES_META_KEY] = self._build_capabilities().model_dump(
by_alias=True, mode="json", exclude_none=True
)
# Stateless pinned mode: disconnect-as-cancel is the spec mechanism, so the
# dispatcher must not emit notifications/cancelled when the caller abandons.
opts["cancel_on_abandon"] = False
timeout = (
request_read_timeout_seconds
if request_read_timeout_seconds is not None
Expand Down Expand Up @@ -254,7 +290,7 @@ async def send_notification(self, notification: types.ClientNotification) -> Non
data = notification.model_dump(by_alias=True, mode="json", exclude_none=True)
await self._dispatcher.notify(data["method"], data.get("params"))

async def initialize(self) -> types.InitializeResult:
def _build_capabilities(self) -> types.ClientCapabilities:
sampling = (
(self._sampling_capabilities or types.SamplingCapability())
if self._sampling_callback is not _default_sampling_callback
Expand All @@ -273,17 +309,19 @@ async def initialize(self) -> types.InitializeResult:
if self._list_roots_callback is not _default_list_roots_callback
else None
)
return types.ClientCapabilities(sampling=sampling, elicitation=elicitation, experimental=None, roots=roots)

async def initialize(self) -> types.InitializeResult:
if self._initialize_result is not None:
return self._initialize_result
capabilities = self._build_capabilities()
result = await self.send_request(
types.InitializeRequest(
params=types.InitializeRequestParams(
protocol_version=types.LATEST_PROTOCOL_VERSION,
capabilities=types.ClientCapabilities(
sampling=sampling,
elicitation=elicitation,
experimental=None,
roots=roots,
),
protocol_version=self._pinned_version
if self._pinned_version is not None
else types.LATEST_PROTOCOL_VERSION,
capabilities=capabilities,
client_info=self._client_info,
),
),
Expand All @@ -303,14 +341,24 @@ async def initialize(self) -> types.InitializeResult:
def initialize_result(self) -> types.InitializeResult | None:
"""The server's InitializeResult. None until initialize() has been called.

Contains server_info, capabilities, instructions, and the negotiated protocol_version.
A stateless-pinned session (protocol_version >= 2026-07-28) is born
initialized: this property is populated at construction with a
synthesized result and `initialize()` returns it without touching the
wire. Contains server_info, capabilities, instructions, and the
negotiated protocol_version.
"""
return self._initialize_result

@property
def protocol_version(self) -> str | None:
"""The negotiated protocol version. None until `initialize()` has completed."""
return self._initialize_result.protocol_version if self._initialize_result else None
"""Negotiated or pinned protocol version. None until initialize() unless pinned at construction.

Once `initialize()` has completed, this is the version the server actually
negotiated (which can differ from a stateful pin); before that, the pin.
"""
if self._initialize_result is not None:
return self._initialize_result.protocol_version
return self._pinned_version

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