First end-to-end 2026-07-28 stateless tools/call (experimental entry + ClientSession pin)#2910
Draft
maxisbey wants to merge 14 commits into
Draft
First end-to-end 2026-07-28 stateless tools/call (experimental entry + ClientSession pin)#2910maxisbey wants to merge 14 commits into
maxisbey wants to merge 14 commits into
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
maxisbey
commented
Jun 19, 2026
| 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( |
Contributor
Author
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
| """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] |
| return types.ClientCapabilities(sampling=sampling, elicitation=elicitation, experimental=None, roots=roots) | ||
|
|
||
| async def initialize(self) -> types.InitializeResult: | ||
| if self._pinned_version is not None: |
|
|
||
|
|
||
| @requirement("lifecycle:stateless:request-envelope") | ||
| async def test_pinned_session_stamps_the_envelope_meta_on_every_request_and_never_initializes() -> None: |
Contributor
Author
There was a problem hiding this comment.
this doesn't seem very high level? why is it being ran like this?
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First end-to-end slice of the 2026-07-28 stateless protocol era: the SDK's own client makes a stateless
tools/callagainst the SDK's own server over streamable HTTP — noinitialize, no session id, the per-request_metaenvelope and request-metadata headers on the request,resultType: "complete"on the result — proven by interaction-suite tests, with existing 2025-era behaviour untouched.Part of #2891, #2892, #2893, #2894 (closes none of them).
Motivation and Context
The 2026-07-28 spec makes the protocol stateless: no
initializehandshake, no session, every request carries its own protocol version, client identity, and capabilities in_meta. The python-sdk cannot speak this era today. This PR is the smallest honest version of that — one end-to-end round trip — to surface where the SDK's seams fight the stateless model before the larger reworks land on top.What's in here
Server — a new experimental modern HTTP entry (
mcp.server._experimental.streamable_http_modern).StreamableHTTPSessionManager.handle_requestbranches onMCP-Protocol-Version: 2026-07-28to a direct-invocation handler: a fresh per-requestServerRunnerover a small single-exchangeDispatcherimplementation (server-to-client requests raiseNoBackChannelError; notifications no-op pending SSE streaming), with the connection pre-committed to2026-07-28. 2025 paths (stateful andstateless_http=True) are byte-for-byte unchanged. The module is private and not part of the public API.Client —
ClientSessionandstreamablehttp_client()each gain a keyword-onlyprotocol_version: str | None = None. When set to"2026-07-28": the session stamps theio.modelcontextprotocol/{protocolVersion,clientInfo,clientCapabilities}envelope into every request'sparams._meta(the envelope keys overwrite caller-supplied values),initialize()raises, andcancel_on_abandonis disabled (a stateless server cannot correlatenotifications/cancelled). The transport seeds its existingprotocol_versionfield at construction;_prepare_headers()already emitsMCP-Protocol-Versionfrom it on every POST.Mcp-MethodandMcp-Nameare derived per message frommessage.method/params.name(Base64-sentinel-encoded when not header-safe). Unpinned behaviour is unchanged. For now the pin is passed to both layers; the high-levelClientwill collapse this to one argument.Tests — built on the era-axis manifest machinery from #2909:
lowlevel/test_lifecycle_stateless.py— pinnedClientSessionover a scripted peer (envelope stamping,initialize()rejected, caller_metasurvives the merge, unpinned sessions carry no 2026 vocabulary)transports/test_legacy_wire.py+_modern_vocab.py— a 2025-era exchange carries no 2026 vocabulary at the HTTP seamtransports/test_client_transport_http_modern.py— body-derived headers reach the wire; a returned session-id is ignored, no GET/DELETEtransports/test_hosting_http_modern.py— statelesstools/callreturnsresultType: complete, noMcp-Session-Id,initializeisMETHOD_NOT_FOUND, non-2026 headers fall through to legacy unchanged, handler exceptions map toINTERNAL_ERRORwith a generic message; plus the end-to-end capstone (real pinned client → real modern entry)tests/client/test_streamable_http.py— unit tests for_body_derived_headersand the_encode_header_valueBase64-sentinel gateadded_in="2026-07-28", 3 cross-era guards)How Has This Been Tested?
Full suite passes locally (2425 tests). The capstone test drives the pinned
ClientSessionagainst the modern entry over the in-process ASGI bridge and asserts the wire log.Conformance
Built on #2911. The modern entry now serves 13 carried-forward scenarios at the 2026-07-28 wire (
tools-call-*,prompts-get-*,completion-complete,dns-rebinding-protection,server-sse-multiple-streams) — removed fromexpected-failures.2026-07-28.yml. List-result scenarios (tools-list,prompts-list,resources-*) remain expected-fail pendingcacheScope/ttlMsdefaults (SEP-2549).input-required-result-validate-inputis now baselined per the comment inexpected-failures.ymlthat predicted it.Breaking Changes
None. All new behaviour is opt-in (the experimental serving entry, the
protocol_version=pin).SUPPORTED_PROTOCOL_VERSIONSandLATEST_PROTOCOL_VERSIONare unchanged; the 2025-era handshake and the"Unsupported protocol version"rejection literal are preserved.Types of changes
Checklist
Additional context
Design calls flagged for review:
protocol_version=parameter spelling on bothClientSessionandstreamablehttp_client(chose the bare string; a richerversion_negotiation=mode object can wrap it later on the high-levelClient, which will also collapse the pass-twice duplication)# type: ignore[call-arg]inserver/session.pyfor the_related_request_idkwarg (not on theDispatcherProtocol;ServerSessionis being reworked); one# type: ignore[reportPrivateUsage]onrunner._compose_on_request()and one on the_EXIT_STACK_CLOSE_TIMEOUTimportOut of scope for this PR (next milestone):
server/discover, the_metavalidation ladder and error codes, 404/405 mapping, conformance-suite scenarios, version negotiation,subscriptions/listen, MRTR, SSE notification streaming, default cache-hint stamping, the public design of the new HTTP serving path and high-level Client.supersedes/superseded_bylinks between the newadded_inentries and #2909'sremoved_inentries are deferred to a follow-up.AI Disclaimer