First end-to-end 2026-07-28 stateless tools/call (experimental entry + ClientSession pin)#2910
Conversation
89b1ed8 to
d9eda60
Compare
Routes MCP-Protocol-Version: 2026-07-28 requests at the session-manager seam to a new direct-invocation handler in mcp.server._experimental, leaving the existing 2025-era paths (stateful and stateless_http) unchanged. The new handler builds a fresh per-request ServerRunner over a single-exchange Dispatcher implementation (no memory streams, no JSONRPCDispatcher), pre-commits the connection to 2026-07-28, runs the composed on_request directly in the request task, and writes a JSON response. Server-to-client requests raise NoBackChannelError; notifications no-op pending SSE streaming. Dispatcher annotations on ServerRunner/ServerSession widened from JSONRPCDispatcher to the Dispatcher Protocol. The module is experimental and not part of the public API. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
When ClientSession is constructed with protocol_version="2026-07-28", each outgoing request carries the io.modelcontextprotocol/* envelope (protocolVersion, clientInfo, clientCapabilities) in params._meta, and initialize() raises if called. Capabilities derivation is extracted to _build_capabilities() so both paths share it. The streamable-HTTP transport derives MCP-Protocol-Version, Mcp-Method and (for tools/call) Mcp-Name headers per POST from the body's envelope; non-header-safe values are Base64-sentinel-encoded per the spec. Envelope-less bodies get no derived headers, so unpinned behaviour is unchanged. Session-id capture, the standalone GET stream and DELETE on close are gated on traffic the pinned mode never produces. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
Drops the StreamableHTTPSessionManager dependency from the experimental module; the handler only needs the lowlevel Server and the TransportSecuritySettings. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
… path - assert_no_modern_vocabulary helper and on_response= hook on mounted_app - lowlevel/test_lifecycle_stateless.py: pinned ClientSession stamps the envelope on every request, initialize() is rejected, caller _meta survives the merge, unpinned sessions carry no 2026 vocabulary - transports/test_legacy_wire.py: a 2025-era exchange carries no 2026 vocabulary at the HTTP seam - transports/test_client_transport_http_modern.py: body-derived header table and the Mcp-Name Base64-sentinel encoding - transports/test_hosting_http_modern.py: stateless tools/call returns resultType complete, no Mcp-Session-Id, initialize is METHOD_NOT_FOUND - transports/test_hosting_http.py: the Unsupported-protocol-version rejection literal stays sniffable Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
…anifest Eleven new entries: nine with added_in="2026-07-28" sourced from SPEC_2026_BASE_URL, plus the two cross-era guard entries (protocol-version-rejection-literal, legacy-no-modern-vocabulary). Each transports-restricted entry carries a note per the manifest invariant. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
… path - transports/test_client_transport_http_modern.py: pinned session POST carries body-derived headers on the wire; a returned session id is ignored and no GET/DELETE is sent - transports/test_hosting_http_modern.py: non-2026 headers fall through to the legacy transport unchanged; handler exceptions map to INTERNAL_ERROR with a generic message; capstone end-to-end stateless tools/call (real ClientSession against the modern entry) - tests/client/test_streamable_http.py: unit tests for _body_derived_headers and the _encode_header_value Base64-sentinel gate (private-helper coverage, kept out of the interaction suite) Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
Adds client-transport:http:stateless-ignores-session-id, hosting:http:modern:legacy-fallthrough and hosting:http:modern:handler-exception-internal-error. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
Unit tests for SingleExchangeDispatcher (NoBackChannelError, no-op notify, run() raises) and _SingleExchangeDispatchContext, plus handle_modern_request edge paths (non-POST 405, malformed-body PARSE_ERROR, transport-security rejection, ValidationError mapping). One additional _body_derived_headers case covers the name-absent branch. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
The modern entry now serves 13 carried-forward scenarios (tools/call, prompts/get, completion, dns-rebinding) at the 2026-07-28 wire; remove them from the 2026 baseline. List-result scenarios remain expected-fail pending cacheScope/ttlMs defaults (SEP-2549). input-required-result-validate-input is now baselined per the comment that predicted it; input-required-result-ignore-extra-params now passes. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
- Parse-error response keeps the required "id": null member - 405 carries the Allow: POST header - exit_stack.aclose() is shielded, bounded, and exception-suppressed, matching ServerRunner.run()'s contract - The success/error response is sent inside the per-request lifespan scope so a teardown error cannot drop an already-computed result - Coverage tests for the cleanup-raises and cleanup-hangs arms - Two no-branch pragmas for the 3.14 nested-async-with coverage quirk Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
streamablehttp_client() and StreamableHTTPTransport now take protocol_version: str | None = None, seeding the existing self.protocol_version field that _prepare_headers() already reads. _body_derived_headers (which sniffed params._meta) is replaced by _per_message_headers, gated on the pin and reading message.method directly so requests and notifications are handled uniformly. The _meta envelope is request-only per spec and stays the session's responsibility; the transport no longer treats the body as the source of truth for connection-level headers. The constructor pin also wins over the InitializeResult snoop. ClientSession: pinned sessions set cancel_on_abandon=False so the dispatcher never emits notifications/cancelled (a stateless server cannot correlate it); the envelope keys now overwrite caller-supplied _meta values rather than setdefault. For now the pin is passed to both streamablehttp_client and ClientSession; the high-level Client will collapse this to one argument. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
d9eda60 to
06d1492
Compare
The exit-stack-hangs test passes on all Python versions, but coverage.py on 3.11 misreports the assertions after the shielded move_on_after cancellation as unhit (the tracer in the test frame is disrupted by the cancel inside the request task). lax no cover is the sanctioned exclusion for lines covered on some versions but not others. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
fe1f248 to
4378d15
Compare
| data["method"], data.get("params"), opts or None, _related_request_id=related | ||
| # TODO: _related_request_id is not on the Dispatcher Protocol; either | ||
| # add it there or refactor ServerSession once the legacy path is compat-only. | ||
| result = cast( |
There was a problem hiding this comment.
seems like we should use the dispatcher context object here then? that's the whole point of it, is it know it's own secret params
There was a problem hiding this comment.
Agreed it's the wrong shape. Reworded the TODO to point at the actual fix (a per-request Outbound — the DispatchContext — alongside the connection-level one, with related_request_id as the channel selector). That belongs with the ServerSession/Context rework; widening the Protocol is the wrong direction since related_request_id is transport-specific.
| """Send a typed server-to-client notification.""" | ||
| data = notification.model_dump(by_alias=True, mode="json", exclude_none=True) | ||
| await self._dispatcher.notify(data["method"], data.get("params"), _related_request_id=related_request_id) | ||
| await self._dispatcher.notify(data["method"], data.get("params"), _related_request_id=related_request_id) # type: ignore[call-arg] |
There was a problem hiding this comment.
Same as above — see the reworded TODO at :94.
| return types.ClientCapabilities(sampling=sampling, elicitation=elicitation, experimental=None, roots=roots) | ||
|
|
||
| async def initialize(self) -> types.InitializeResult: | ||
| if self._pinned_version is not None: |
There was a problem hiding this comment.
Pinning to a stateful version is the contradiction, not calling initialize() afterward. Tightened the param to Literal["2026-07-28"] (StatelessProtocolVersion) so a 2025-era pin is a type error; the raise stays as the fail-fast for misuse (a no-op would have to synthesize an InitializeResult we don't have).
|
|
||
|
|
||
| @requirement("lifecycle:stateless:request-envelope") | ||
| async def test_pinned_session_stamps_the_envelope_meta_on_every_request_and_never_initializes() -> None: |
There was a problem hiding this comment.
this doesn't seem very high level? why is it being ran like this?
There was a problem hiding this comment.
Dropped this file (and the MockTransport one) in 194f225. The capstone in transports/test_hosting_http_modern.py proves everything they did at higher altitude — envelope, headers, no-initialize, and now caller-_meta-preserved via a server-side ctx.meta echo. The initialize()-raises and session-id-ignore checks moved to tests/client/.
Review-response changes: - Merge the duplicate _pinned_version guard in ClientSession.send_request - Use is_version_at_least() instead of a raw string compare for the per-message-headers gate - Base64-wrap Mcp-Name values with leading/trailing spaces (RFC 7230 forbids them; h11 rejects on real transports) - Add a TODO at the Mcp-Name gate naming prompts/get and resources/read - Type the protocol_version pin as Literal["2026-07-28"] via StatelessProtocolVersion so 2025-era values are a type error - Reword the _related_request_id TODO in ServerSession to point at the per-request Outbound shape (not at widening the Protocol) Interaction-suite consolidation: - Drop test_lifecycle_stateless.py and test_client_transport_http_modern.py; their assertions are now proven by the capstone in test_hosting_http_modern.py (envelope, headers, no-initialize) or moved to tests/client/ (initialize-raises, session-id-ignore against a misbehaving peer) - Extend the capstone to capture ctx.meta server-side and assert the caller-supplied _meta key survives the envelope merge - Reconcile _requirements.py: stack request-envelope and caller-meta-preserved on the capstone; defer no-initialize and stateless-ignores-session-id to tests/client/; drop the duplicate unpinned-legacy-wire and body-derived-headers entries
- Prove the envelope is stamped when the caller passes no _meta by
snapshotting the implicit tools/list body in the capstone
- Give _server() an on_meta hook so the capstone reuses it instead of
duplicating its handlers
- Restore the wire-emptiness check on the pinned-initialize-raises unit
test (buffer-used == 0 after the raise)
- Restore lifecycle:stateless:unpinned-legacy-wire (deferred) and
client-transport:http:body-derived-headers (stacked on the capstone)
in the requirements ledger
- Drop the redundant strip(" ") arg in _encode_header_value
… to a Literal A pin to a stateful version now flows through initialize() as the requested version (instead of LATEST_PROTOCOL_VERSION); only a pin to a 2026-07-28+ stateless version raises. The envelope-stamp and cancel_on_abandon=False in send_request key off the same is_version_at_least gate. StatelessProtocolVersion is dropped.
…n-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.
The stateless-era predicate is set membership, not an ordering threshold; drop FIRST_MODERN_VERSION and is_version_at_least at the four call sites. The modern entry now takes the protocol_version that matched (threaded from the manager) instead of hard-coding it.
dfc49d6 to
47a422c
Compare
… immediately-stale private - Client gains protocol_version: str | None; threads to ClientSession and streamable_http_client. The interaction-suite connect factories forward the parameter they already accepted. - CacheableResult defaults to ttl_ms=0, cache_scope="private" so list/read results constructed without explicit hints validate against the 2026-07-28 surface and never accidentally enable shared caching. - TRANSPORT_SPEC_VERSIONS era-locks in-memory and streamable-http-stateless to 2025-11-25 (the former pending a modern in-memory entry; the latter collapses into stateful at the newer revision).
- SPEC_VERSIONS = ("2025-11-25", "2026-07-28"); node ids gain a -version
suffix suite-wide
- 13 lifecycle:initialize:* / lifecycle:version:* / initialized-notification /
requests-before-initialized requirements get removed_in="2026-07-28"
(the handshake does not exist at this revision)
- 10 progress/logging requirements get a KnownFailure at 2026-07-28 (the
modern entry does not yet stream handler-emitted notifications onto the
per-request response; burns down once the SSE response mode lands)
- protocol:progress:client-to-server gets a requires-session arm exclusion
(a bare client->server notification has no route at the stateless entry)
- 6 error-shape requirements get a modern-error-surface arm exclusion (the
tests pin the legacy code-0/leaked-message divergence; the modern arm
returns the spec-correct -32603; needs era-aware assertions to re-admit)
[streamable-http-2026-07-28]: 60 pass, 8 xfail.
The ttl_ms=0 / cache_scope="private" defaults make list and read results validate at the 2026-07-28 surface, so caching, the carried-forward list/read scenarios, and http-custom-header-server-validation now pass. tools-call-with-progress stays expected-fail (progress notifications are not yet delivered on the single-exchange path).
…lope-overwrite semantics - ClientSession.protocol_version returns the value from the InitializeResult once one exists (negotiated for stateful, the pin for stateless via the synthesized result), falling back to the pin only before the handshake. A stateful pin is the requested version, not a guarantee of the negotiated one; inbound validation now keys off what the server actually agreed to. - Reword lifecycle:stateless:caller-meta-preserved: the three envelope keys overwrite caller-supplied values for those keys; non-colliding caller keys are preserved. The capstone now passes a colliding key and the wire snapshot proves the overwrite.
| def _maybe_extract_protocol_version_from_message(self, message: JSONRPCMessage) -> None: | ||
| """Extract protocol version from initialization response message.""" | ||
| if self.protocol_version is not None: | ||
| # Constructor pin wins over snooping the InitializeResult. | ||
| return |
There was a problem hiding this comment.
🔴 When a stateful pin (e.g. Client(url, protocol_version="2025-06-18")) is set, the new early return in _maybe_extract_protocol_version_from_message keeps the transport stamping MCP-Protocol-Version with the pinned value even after the server negotiates a different (older) version — a scenario this PR explicitly supports and tests at the session layer. The spec requires subsequent HTTP requests to carry the negotiated version, and the SDK becomes internally inconsistent (session.protocol_version reports the negotiated value while the wire header reports the pin). Consider gating the pin-wins early return on the pin being in MODERN_PROTOCOL_VERSIONS, where no InitializeResult ever exists.
Extended reasoning...
What the bug is. StreamableHTTPTransport now accepts protocol_version at construction, and _maybe_extract_protocol_version_from_message returns early whenever self.protocol_version is not None ("Constructor pin wins over snooping the InitializeResult"). That early return is correct for the stateless 2026-07-28 pin, where no initialize handshake (and hence no InitializeResult) ever exists. But the same constructor pin is also used for stateful pins: Client.__post_init__ forwards protocol_version unconditionally to streamable_http_client(...), and the interaction-suite connect_over_streamable_http factory does the same. So a client pinned to e.g. 2025-06-18 builds a transport whose snoop is permanently disabled.
The code path that triggers it. ClientSession.initialize() with a stateful pin still runs the handshake, requesting the pinned version. The PR explicitly supports the server answering with a different (older) supported version: test_initialize_on_a_stateful_pin_requests_the_pinned_version has the server respond 2025-03-26 to a 2025-06-18 pin, the session accepts it, and session.protocol_version reports the negotiated 2025-03-26. Meanwhile, in the transport, _handle_json_response / _handle_sse_event call _maybe_extract_protocol_version_from_message for the initialization response — but the pin makes it a no-op, so self.protocol_version stays 2025-06-18. _prepare_headers() then sets MCP-Protocol-Version: 2025-06-18 on every subsequent POST/GET/DELETE.
Step-by-step proof.
Client("https://server/mcp", protocol_version="2025-06-18")→__post_init__callsstreamable_http_client(url, protocol_version="2025-06-18")→StreamableHTTPTransport(url, protocol_version="2025-06-18"), sotransport.protocol_version == "2025-06-18"from construction.Client.__aenter__buildsClientSession(..., protocol_version="2025-06-18")and callsinitialize(). Since"2025-06-18" not in MODERN_PROTOCOL_VERSIONS, the handshake runs and requestsprotocolVersion: "2025-06-18".- The server only supports up to
2025-03-26and answersInitializeResult(protocolVersion="2025-03-26", ...).2025-03-26is inSUPPORTED_PROTOCOL_VERSIONS, so the session accepts it;session.protocol_versionnow returns2025-03-26(exactly what the new test asserts). - The transport receives that
InitializeResultand calls_maybe_extract_protocol_version_from_message, which returns immediately becauseself.protocol_versionis already"2025-06-18". - Every subsequent request —
tools/list,tools/call, the standalone GET stream, the closing DELETE — goes out via_prepare_headers()withMCP-Protocol-Version: 2025-06-18, a version the server never negotiated.
Why nothing else prevents it. The only pin-wins test (test_constructor_pin_is_not_overwritten_by_an_initialize_result) and the constructor docstring ("Required for stateless 2026-07-28 sessions") both target the 2026 stateless pin; nothing covers or pins the stateful behavior. The session layer and the transport layer each track their own version with no reconciliation.
Impact. The streamable-HTTP spec requires the client to send the protocol version negotiated during initialization on subsequent HTTP requests, so the header carries a non-negotiated value — a spec violation against stricter servers, which may reject the requests outright (this SDK's own server only checks the header is a supported version, so the failure mode is interop with third-party servers). Even when nothing rejects, the SDK is internally inconsistent: session.protocol_version says 2025-03-26 while the wire says 2025-06-18.
How to fix. Gate the early return on the pin being a modern (stateless) version, e.g. if self.protocol_version is not None and self.protocol_version in MODERN_PROTOCOL_VERSIONS: return — for stateful pins the snoop should still capture the negotiated version (or, equivalently, let the InitializeResult overwrite the pin). This keeps the stateless 2026-07-28 path untouched while restoring spec-correct headers after a downgrade negotiation. The trigger is narrow (a stateful pin plus a server that downgrades), but the pin plumbing is introduced by this PR, so it is worth fixing here.
| media_type="application/json", | ||
| )(scope, receive, send) | ||
| return | ||
|
|
||
| dispatcher = SingleExchangeDispatcher(request) | ||
| # TODO: per-request lifespan re-entry matches stateless_http=True today; revisit in #2893. | ||
| async with app.lifespan(app) as lifespan_state: | ||
| runner = ServerRunner( | ||
| server=app, | ||
| dispatcher=dispatcher, | ||
| lifespan_state=lifespan_state, |
There was a problem hiding this comment.
🔴 handle_modern_request validates every POST body strictly as JSONRPCRequest, so a well-formed JSON-RPC notification (no id) is answered with HTTP 400 + a JSONRPCError(PARSE_ERROR) body — the server responds to a notification and mislabels valid JSON as a parse error, while the legacy transport answers the same POST with 202 and no body. Since the pinned client silently swallows the 400, notifications sent via send_progress_notification/send_roots_list_changed on a 2026-07-28 session are dropped invisibly; consider widening the parse to JSONRPCRequest | JSONRPCNotification and answering notifications with 202/no-op (or rejecting them deliberately without a Parse-error envelope).
Extended reasoning...
The bug. In handle_modern_request the request body is parsed with JSONRPCRequest.model_validate_json(body). JSONRPCRequest declares id: RequestId as required (src/mcp/types/jsonrpc.py), so any well-formed JSON-RPC notification — a valid object that simply has no id member — raises ValidationError and falls into the except branch, which answers with HTTP 400 and a JSONRPCError(id=null, code=PARSE_ERROR, message="Parse error") body. Two things are wrong with that: the body is syntactically valid JSON-RPC (it just isn't a request), so -32700 Parse error is a misclassification; and JSON-RPC 2.0 forbids sending a response to a notification, while this path emits a JSON-RPC error envelope for one.\n\nReachability from the SDK's own client. This is not a purely hypothetical peer. A ClientSession pinned to 2026-07-28 still exposes send_notification, send_progress_notification, and send_roots_list_changed, and the pinned StreamableHTTPTransport explicitly supports notifications on this path: _per_message_headers derives Mcp-Method for JSONRPCNotification (and the new transport unit tests pin that behaviour for notifications/cancelled). Because the pinned transport stamps MCP-Protocol-Version: 2026-07-28 on every POST, StreamableHTTPSessionManager.handle_request routes the notification to handle_modern_request, where it hits the 400/Parse-error branch.\n\nStep-by-step proof. (1) Build a pinned client: streamable_http_client(url, protocol_version="2026-07-28") + ClientSession(read, write, protocol_version="2026-07-28"). (2) Call await session.send_progress_notification("tok", 0.5). (3) The transport POSTs {"jsonrpc": "2.0", "method": "notifications/progress", "params": {...}} with MCP-Protocol-Version: 2026-07-28 and Mcp-Method: notifications/progress. (4) The manager sees the modern version header and calls handle_modern_request. (5) JSONRPCRequest.model_validate_json fails on the missing id, so the server returns 400 with {"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": "Parse error"}}. (6) On the client, _handle_post_request only emits an error frame for JSONRPCRequest messages when status >= 400, so for a notification the 400 is swallowed and the call returns as if the notification was delivered. By contrast, the legacy streamable-HTTP transport answers the identical notification-only POST with 202 and no body — so this is a behavioural divergence introduced by the new entry, and the loss is invisible to the caller.\n\nWhy nothing else prevents it. The module's TODO ladder covers Accept-header validation and GET/DELETE rejection, not notification bodies, and the interaction-suite exclusion (protocol:progress:client-to-server excluded at 2026-07-28 with reason requires-session) only removes the test, it does not make the wire response correct. The docstring's "one JSON-RPC request in, one JSON-RPC response out" describes the happy path, not a decision to answer notifications with a Parse-error envelope.\n\nOn the counter-argument. One reviewer noted that the streamable-HTTP transport spec permits an HTTP error status (optionally with an id-less JSON-RPC error body) when the server cannot accept a notification POST, and that client-originated notifications are arguably out of scope for this slice. Even granting both points, the response is still wrong on its own terms: -32700 Parse error asserts the body could not be parsed, which is false (the appropriate shape would be an Invalid Request / deliberate not-supported rejection, or simply a 202 no-op as the legacy path does), and the silent client-side swallow means real SDK calls (send_progress_notification, send_roots_list_changed) disappear with no signal anywhere. The fix is small and local — widen the parse to JSONRPCRequest | JSONRPCNotification and answer notifications with 202/no body (matching legacy and the dispatcher's existing notify() no-op), or return a deliberate, correctly-coded rejection.\n\nImpact and fix. Without a change, any 2026-07-28 deployment that uses progress or roots-changed notifications loses them silently and emits a spec-questionable wire response that other-SDK clients may log or mishandle. Suggested fix in handle_modern_request: try JSONRPCRequest, fall back to JSONRPCNotification; for a notification, return Response(status_code=202) (the SingleExchangeDispatcher.notify path already no-ops, pending the SSE response mode), reserving the 400/Parse-error answer for genuinely unparseable bodies.
| 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 | ||
|
|
There was a problem hiding this comment.
🟡 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
server = MCPServer("test")with anaddtool (the exact pattern in theClientclass docstring example).async with Client(server, protocol_version="2026-07-28") as client:—__post_init__wrapsserverinInMemoryTransport(no version pin);__aenter__builds a stateless-pinnedClientSessionand callsinitialize(), which returns the synthesized result without sending any frame. The context manager enters successfully.await client.call_tool("add", {"a": 1, "b": 2})— the request reaches the lowlevel server'sServerRunner._on_request;connection.initialize_acceptedis false (no handshake ever happened), so the gate raisesINVALID_PARAMSand the user seesMCPError: 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.
First end-to-end slice of the 2026-07-28 stateless protocol, plus the interaction-suite era axis turned on so the existing transport-agnostic tests run at the new revision.
Part of #2891, #2892, #2893, #2894 (closes none).
Server
mcp.server._streamable_http_modern—handle_modern_requestdirect-invocation path (private)StreamableHTTPSessionManager.handle_requestbranches onMCP-Protocol-Version∈MODERN_PROTOCOL_VERSIONSServerRunnerover aSingleExchangeDispatcher(no back-channel; notifications no-op pending the SSE response mode)Client
Client,ClientSession, andstreamable_http_client()gainprotocol_version: str | None"2026-07-28": born initialized (synthesizedInitializeResult, placeholderserver_infopendingserver/discover);initialize()is idempotent; stamps theio.modelcontextprotocol/{protocolVersion,clientInfo,clientCapabilities}envelope on every request;cancel_on_abandon=Falseinitialize()requests that version instead ofLATEST_PROTOCOL_VERSIONMCP-Protocol-Versionfrom the constructor;Mcp-Method/Mcp-Namederived per message;_encode_header_valueBase64-sentinel-wraps RFC-7230-unsafe namesMODERN_PROTOCOL_VERSIONSconstant inmcp.shared.versionTypes
CacheableResultdefaults tottl_ms=0, cache_scope="private"so list/read results constructed without explicit hints validate against the 2026-07-28 surface and never accidentally enable shared cachingInteraction suite
SPEC_VERSIONS = ("2025-11-25", "2026-07-28")— node ids gain a-versionsuffixconnectfixture forwardsprotocol_versionthroughClient;in-memoryandstreamable-http-statelessera-locked to 2025-11-25[streamable-http-2026-07-28]: 60 pass, 8 xfail (xfails are progress/logging — handler-emitted notifications not yet streamed on the single-exchange path)lifecycle:*requirements getremoved_in="2026-07-28"; 6 error-shape requirements excluded (modern-error-surface— tests pin the legacycode=0divergence; the modern arm returns the spec-correct-32603)transports/test_hosting_http_modern.pyConformance
tools-call-with-progressstays expected-fail (progress delivery on the single-exchange path)Behaviour changes
ClientSession.initialize()is now idempotent: a second call returns the cached result (or the synthesized one for a stateless pin) instead of re-sending the handshake. All other 2026 behaviour is opt-in viaprotocol_version=.SUPPORTED_PROTOCOL_VERSIONSandLATEST_PROTOCOL_VERSIONare unchanged.Flagged for review
# type: ignore[call-arg]inserver/session.pyfor_related_request_id(TODO at :94 — fix is theServerSession/Contextrework)# type: ignore[reportPrivateUsage]in the modern entry (_compose_on_request,_EXIT_STACK_CLOSE_TIMEOUT) — both fall out of theServerRunnerdriver-splitOut of scope
server/discover,_metavalidation ladder, version negotiation,subscriptions/listen, MRTR, SSE response mode for the modern entry, public design of the new HTTP path, in-memory modern entry.AI Disclaimer