Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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

Client with in-memory server + 2026-07-28 pin silently produces a broken connection

Pinning the new `Client.protocol_version` to `2026-07-28` with an in-memory `Server`/`MCPServer` instance silently produces a broken connection: only the URL branch threads the pin into the transport, so the server runs the legacy stateful path (initialize gate) while the pinned session never sends `initialize`, and every subsequent request fails with a cryptic `INVALID_PARAMS` 'Invalid request parameters'. Consider raising a clear `ValueError` (or adding a docstring caveat) when `protocol_versi
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 the new Client.protocol_version to 2026-07-28 with an in-memory Server/MCPServer instance silently produces a broken connection: only the URL branch threads the pin into the transport, so the server runs the legacy stateful path (initialize gate) while the pinned session never sends initialize, and every subsequent request fails with a cryptic INVALID_PARAMS 'Invalid request parameters'. Consider raising a clear ValueError (or adding a docstring caveat) when protocol_version is in MODERN_PROTOCOL_VERSIONS and the server is an in-memory instance, until the in-memory modern entry point lands.

Extended reasoning...

What happens. Client.__post_init__ only threads protocol_version into the transport on the URL branch (streamable_http_client(self.server, protocol_version=...)). When server is a Server/MCPServer instance, it builds a plain InMemoryTransport with no awareness of the pin (src/mcp/client/client.py:112-118), while the pin is still passed to ClientSession (line 138). A 2026-07-28-pinned ClientSession is "born initialized": initialize() returns the locally synthesized result without sending anything on the wire, so Client.__aenter__ succeeds and the connection looks healthy.

Why every request then fails. InMemoryTransport._connect runs actual_server.run(...) on the legacy stateful path (no stateless=True), so ServerRunner's init gate is active: if not self.connection.initialize_accepted and method not in _INIT_EXEMPT: raise MCPError(INVALID_PARAMS, 'Invalid request parameters'). The pinned session never sends initialize or notifications/initialized, so initialize_accepted never becomes true. The per-request _meta envelope doesn't help either: _resolve_protocol_version only honours the _meta version if it is in SUPPORTED_PROTOCOL_VERSIONS, which does not include 2026-07-28, so it falls back to 2025-11-25 and the gate still fires.

Step-by-step proof.

  1. async with Client(my_mcpserver, protocol_version=\"2026-07-28\") as client:__post_init__ builds InMemoryTransport (no pin), __aenter__ calls session.initialize(), which returns the synthesized result without touching the wire. The context manager enters successfully.
  2. await client.call_tool(\"add\", {\"a\": 1, \"b\": 2}) — the request reaches ServerRunner._on_request with connection.initialize_accepted still false (no handshake ever ran, and stateless is False so connection.initialized was never pre-set).
  3. The init gate raises MCPError(INVALID_PARAMS, \"Invalid request parameters\") — the user sees an error that suggests their arguments are wrong, with no hint that the version pin is the cause.

Why nothing prevents it. The new Client.protocol_version docstring explicitly invites pinning to 2026-07-28 ('selects the stateless transport era: no initialize handshake is sent') and frames HTTP behavior as an additional detail, not a precondition. The only acknowledgement of the gap is buried in test infrastructure: tests/interaction/_requirements.py locks the in-memory transport to 2025-11-25 with the comment 'the in-memory transport has no modern entry point yet'. The public API neither documents nor rejects the combination, so a user following the docstring with the in-memory server (the first usage shown in the Client class example) hits the cryptic failure.

Impact and fix. This is a missing-guard/diagnostics gap rather than silent corruption — the failure is loud, just misleading. Since the in-memory modern factory is explicitly slated to land later in this milestone, the lightweight fix is either (a) raise a clear ValueError in __post_init__ when protocol_version in MODERN_PROTOCOL_VERSIONS and the server is a Server/MCPServer instance ('the in-memory transport does not support 2026-07-28 yet'), or (b) add a caveat to the protocol_version docstring stating that modern pins currently only work with URL/HTTP servers. Either removes the trap until the in-memory modern path 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