Skip to content

First end-to-end 2026-07-28 stateless tools/call (experimental entry + ClientSession pin)#2910

Draft
maxisbey wants to merge 14 commits into
mainfrom
stateless-tools-call
Draft

First end-to-end 2026-07-28 stateless tools/call (experimental entry + ClientSession pin)#2910
maxisbey wants to merge 14 commits into
mainfrom
stateless-tools-call

Conversation

@maxisbey

@maxisbey maxisbey commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

First end-to-end slice of the 2026-07-28 stateless protocol era: the SDK's own client makes a stateless tools/call against the SDK's own server over streamable HTTP — no initialize, no session id, the per-request _meta envelope 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 initialize handshake, 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_request branches on MCP-Protocol-Version: 2026-07-28 to a direct-invocation handler: a fresh per-request ServerRunner over a small single-exchange Dispatcher implementation (server-to-client requests raise NoBackChannelError; notifications no-op pending SSE streaming), with the connection pre-committed to 2026-07-28. 2025 paths (stateful and stateless_http=True) are byte-for-byte unchanged. The module is private and not part of the public API.

ClientClientSession and streamablehttp_client() each gain a keyword-only protocol_version: str | None = None. When set to "2026-07-28": the session stamps the io.modelcontextprotocol/{protocolVersion,clientInfo,clientCapabilities} envelope into every request's params._meta (the envelope keys overwrite caller-supplied values), initialize() raises, and cancel_on_abandon is disabled (a stateless server cannot correlate notifications/cancelled). The transport seeds its existing protocol_version field at construction; _prepare_headers() already emits MCP-Protocol-Version from it on every POST. Mcp-Method and Mcp-Name are derived per message from message.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-level Client will collapse this to one argument.

Tests — built on the era-axis manifest machinery from #2909:

  • lowlevel/test_lifecycle_stateless.py — pinned ClientSession over a scripted peer (envelope stamping, initialize() rejected, caller _meta survives 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 seam
  • transports/test_client_transport_http_modern.py — body-derived headers reach the wire; a returned session-id is ignored, no GET/DELETE
  • transports/test_hosting_http_modern.py — stateless tools/call returns resultType: complete, no Mcp-Session-Id, initialize is METHOD_NOT_FOUND, non-2026 headers fall through to legacy unchanged, handler exceptions map to INTERNAL_ERROR with 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_headers and the _encode_header_value Base64-sentinel gate
  • 13 new requirement-manifest entries (10 with added_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 ClientSession against 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 from expected-failures.2026-07-28.yml. List-result scenarios (tools-list, prompts-list, resources-*) remain expected-fail pending cacheScope/ttlMs defaults (SEP-2549). input-required-result-validate-input is now baselined per the comment in expected-failures.yml that predicted it.

Breaking Changes

None. All new behaviour is opt-in (the experimental serving entry, the protocol_version= pin). SUPPORTED_PROTOCOL_VERSIONS and LATEST_PROTOCOL_VERSION are unchanged; the 2025-era handshake and the "Unsupported protocol version" rejection literal are preserved.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed — the experimental module is deliberately undocumented for now

Additional context

Design calls flagged for review:

  • protocol_version= parameter spelling on both ClientSession and streamablehttp_client (chose the bare string; a richer version_negotiation= mode object can wrap it later on the high-level Client, which will also collapse the pass-twice duplication)
  • Two # type: ignore[call-arg] in server/session.py for the _related_request_id kwarg (not on the Dispatcher Protocol; ServerSession is being reworked); one # type: ignore[reportPrivateUsage] on runner._compose_on_request() and one on the _EXIT_STACK_CLOSE_TIMEOUT import

Out of scope for this PR (next milestone): server/discover, the _meta validation 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_by links between the new added_in entries and #2909's removed_in entries are deferred to a follow-up.

AI Disclaimer

@maxisbey maxisbey force-pushed the stateless-tools-call branch from 89b1ed8 to d9eda60 Compare June 19, 2026 14:13
maxisbey added 11 commits June 19, 2026 15:07
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
@maxisbey maxisbey force-pushed the stateless-tools-call branch from d9eda60 to 06d1492 Compare June 19, 2026 15:39
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
@maxisbey maxisbey force-pushed the stateless-tools-call branch from fe1f248 to 4378d15 Compare June 19, 2026 15:55
Comment thread src/mcp/server/session.py
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(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Comment thread src/mcp/server/session.py
"""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]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this is bad

Comment thread src/mcp/client/session.py
return types.ClientCapabilities(sampling=sampling, elicitation=elicitation, experimental=None, roots=roots)

async def initialize(self) -> types.InitializeResult:
if self._pinned_version is not None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

why?



@requirement("lifecycle:stateless:request-envelope")
async def test_pinned_session_stamps_the_envelope_meta_on_every_request_and_never_initializes() -> None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this doesn't seem very high level? why is it being ran like this?

maxisbey added 2 commits June 19, 2026 17:48
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant