Server-side 2026-07-28 stateless support: classifier, driver split, server/discover#2928
Server-side 2026-07-28 stateless support: classifier, driver split, server/discover#2928maxisbey wants to merge 16 commits into
Conversation
9c80936 to
732ebc4
Compare
…xture tool, migration note - F1: conformance harness pin -> github:modelcontextprotocol/conformance#fe3a3103; reconcile both expected-failures baselines (auth/scope-step-up moves to 2026-only per #337) - D2: extract to_jsonrpc_response + aclose_shielded helpers in runner.py; add handler_exception_to_error_data in shared/jsonrpc_dispatcher.py (single exception->ErrorData source) - H1: new src/mcp/shared/inbound.py — classify_inbound_request() + ERROR_CODE_HTTP_STATUS table (pure classifier; no I/O, no mcp.server imports; method-at-version via CLIENT_REQUESTS map) - H2: server/discover auto-derived handler at lowlevel.Server.__init__; get_capabilities() params now optional; new server_info property - H4: test_missing_capability fixture tool in everything-server (raises MISSING_REQUIRED_CLIENT_CAPABILITY) - C1: docs/migration.md entry for stateless_http lifespan cardinality change Part of the 2026-07-28 server-stateless conformance work (#2891).
…ifier tests ServerRunner becomes the handler kernel only: - ServerRunner(server, connection, lifespan_state, *, dispatch_middleware) — no run(), no dispatcher field, no stateless flag, no __post_init__ Connection construction - on_request/on_notify as cached_property (middleware composed once) - _resolve_protocol_version deleted; kernel reads connection.protocol_version as a fact - serve_connection(server, dispatcher, *, connection, lifespan_state, init_options) and serve_one(server, request, *, connection, dctx, lifespan_state) free-function drivers; both aclose_shielded(connection) in finally; neither constructs Connection Connection factories replace post-construction mutation: - from_envelope(pv, client_info, caps, *, outbound=_NO_CHANNEL) — born-ready, initialized set - for_loop(outbound, *, session_id, protocol_version_hint) — handshake-driven, version seeded - protocol_version: str non-Optional; has_standalone_channel derived (outbound is not _NO_CHANNEL) - _NoChannelOutbound private singleton: send_raw_request raises NoBackChannelError, notify drops ServerSession two-channel selector (closes the related-request-id routing on stateful HTTP): - ServerSession(request_outbound, connection, *, standalone_outbound) per request in _make_context - send_request/send_notification select channel by related_request_id presence - StatelessModeNotSupported + four if-stateless guards deleted (subsumed by _NoChannelOutbound) - Both type:ignore[call-arg] removed; nothing transport-specific crosses the Outbound Protocol Also: - streamable_http.py: ServerMessageMetadata(protocol_version=...) writers deleted (no readers) - tests/shared/test_inbound.py: 32 table-driven classifier tests, 100% branch coverage - tests/server/test_runner.py: resolver tests deleted, fixture migrated to factories (partial; full migration in T2) Tree is intentionally mid-reshape: lowlevel.Server.run() and the modern HTTP entry still call the deleted ServerRunner.run() / SingleExchangeDispatcher; wave 4 (D5/H3) rewrites those. Part of #2891.
Modern HTTP entry (_streamable_http_modern.py): - handle_modern_request: classify_inbound_request -> on rejection write JSONRPCError at ERROR_CODE_HTTP_STATUS-mapped status; on route -> Connection.from_envelope -> serve_one - All status codes via ERROR_CODE_HTTP_STATUS table (PARSE_ERROR added: 400) - id:null preserved on parse-error responses (exclude_none would drop it) - SingleExchangeDispatcher deleted (-73 lines); duplicate shielded-teardown deleted; both reportPrivateUsage ignores deleted; per-request lifespan entry deleted StreamableHTTPSessionManager: - run() enters lifespan once at startup; lifespan_state stored on self - _handle_stateless_request rewired: builds JSONRPCDispatcher (can_send_request=False so the per-request channel raises NoBackChannelError for server->client requests, allows notifications) + Connection.from_envelope(header-seeded version) -> serve_connection. Bypasses Server.run(); stateless kwarg removed from both manager call sites. lowlevel.Server.run(): - Body rewritten: enter lifespan -> JSONRPCDispatcher -> Connection.for_loop -> serve_connection. stateless kwarg deleted (no callers). Net -4 LoC. Test migrations: - test_runner.py: connected_runner uses factories; resolver tests deleted; per-request session tests; serve_one/serve_connection/to_jsonrpc_response/aclose_shielded coverage - test_connection.py: factory tests; check_capability via from_envelope - test_session.py: StubOutbound; two-channel selector tests (4 new) - test_stateless_mode.py: rewritten; 8x pytest.raises(NoBackChannelError) via _NoChannelOutbound - test_streamable_http_modern.py: classifier-path tests; SingleExchangeDispatcher tests deleted - test_lowlevel_exception_handling.py: stateless=True kwarg removed - test_streamable_http.py:1371: protocol_version assertion updated (non-Optional) Part of #2891.
…odies A spec-correct server may return the JSON-RPC error in an application/json body at a non-2xx status (e.g. 400 for INVALID_PARAMS, 404 for METHOD_NOT_FOUND per the 2026-07-28 transport binding). Surface that error to the caller instead of synthesizing a generic INTERNAL_ERROR / 'Session terminated' from the status alone. Falls back to the status-derived error if the body isn't valid JSON-RPC. Unconditional (not version-gated): parsing a JSON-RPC body from a 4xx application/json response is correct at any protocol version.
…ts, assertion patterns
Fixes from the testing-standards review of the new/modified test files.
Hollow-proof (the two SERIOUS findings):
- test_standalone_outbound_defaults_to_request_outbound: drop outbound= from from_envelope so
conn.outbound is the no-channel sentinel; traffic on the request outbound now proves the default
- test_server_info_version_falls_back_to_package: assert == importlib.metadata.version('mcp')
Assertion patterns:
- test_stateless_mode.py: 8x match= -> exc.value.method (structured attribute, not message text)
- test_streamable_http_modern.py: literal 404/400 status asserts (impl reads same table, so
table[X]==table[X] proved nothing); restore full-object json equality with id:None pin
- test_inbound.py: assert isinstance(route, InboundModernRoute) before frozen check
Constants: replace version/header/meta-key string literals with named imports across
test_session.py / test_streamable_http_modern.py.
Hygiene: drop from __future__ import annotations from tests/shared/test_inbound.py and
src/mcp/shared/inbound.py (no forward refs); duplicate StubOutbound locally in
test_stateless_mode.py (no cross-file test imports); _StubDispatchContext methods raise
NotImplementedError; private _request_handlers -> public get_request_handler.
Docstrings: add Spec-mandated:/SDK-defined: provenance tags to ~50 tests across 7 files.
Naming: test_cacheable_defaults -> test_discover_result_defaults_to_immediately_stale_private_cache;
test_header_rung_skipped_when_headers_none -> test_header_rung_does_not_reject_when_headers_arg_is_none;
drop 'handler' from test_serve_one_maps_error_... (it's kernel METHOD_NOT_FOUND).
After rebasing onto #2926: - Re-add # pyright: ignore[reportDeprecated] on the deprecated method calls in the reworked test_connection.py / test_server_context.py / test_stateless_mode.py (the rewrites had dropped main's suppressions) Fix to the client 4xx body-parsing patch (bb9b134): - A server's 4xx JSON-RPC error body may carry id:null (request rejected before its id was parsed). Surfacing that verbatim broke response correlation (client waits for id=N, gets id=null, hangs). Now use the parsed error data with the client's own request id. - Only surface the body when it's a JSONRPCError; anything else falls through to the status-derived stand-in. - Relax the two 'Session terminated' message-text assertions in test_streamable_http.py (the client now surfaces the server's actual message).
…rror The tool wrapper at Tool.run() was catching MCPError along with all other exceptions and re-wrapping it as ToolError, which _handle_call_tool then flattens into CallToolResult(isError=True). This discards the JSON-RPC error code and structured data, so a tool raising e.g. MISSING_REQUIRED_CLIENT_CAPABILITY couldn't produce a protocol-level error response. UrlElicitationRequiredError was already special-cased; since it subclasses MCPError, generalising the re-raise to MCPError covers both and any future protocol-error subclass.
- T3 verifier: snapshot supportedVersions instead of asserting equal to the constant that produced it (was tautological) - T5 verifier: drop near-duplicate middleware-returns-42 test (zero coverage gain) - 3 tests updated for the MCPError-re-raise change: tools that propagate an uncaught MCPError from a peer call (no roots/sampling callback) or from the transport (NoBackChannelError on stateless HTTP) now surface it as a top-level JSON-RPC error rather than CallToolResult(isError=True)
732ebc4 to
4a79c32
Compare
… header-mismatch ahead of version-supported The conformance harness sends header=v999.0.0 (matching the body) to trigger UnsupportedProtocolVersionError, and header=2026 with body=v999 to trigger HeaderMismatch. With the manager only routing on header in MODERN_PROTOCOL_VERSIONS, the first case fell through to the legacy stateful path and never reached the classifier; with the classifier checking version-supported before header-match, the second case returned -32022 instead of -32020. Manager now routes any non-legacy header value to the modern entry (the classifier owns rejection of unknown versions). Classifier now checks header-body agreement before version-supported, so a client that disagrees with itself is told so rather than told its body version is unsupported. Also: 3 dead test-helper lines (registered-but-never-invoked handler bodies and a never-read property on a stub) replaced per the testing-standards convention.
…anager era-routing) The manager only routes header values in SUPPORTED_PROTOCOL_VERSIONS (or no header) to the legacy transport, so _validate_protocol_version's error branch was unreachable. Deleted the function; _validate_request_headers now just delegates to _validate_session. Updated the one shared/ test that exercised the dead branch to assert the new modern-entry rejection instead.
644745c to
c94095c
Compare
…or audit Conformance @ 65fcd39: server-stateless 25/25 SUCCESS, 0 FAILURE. Removed from both expected-failures files along with two stale entries that now pass (input-required-result-validate-input, input-required-result-missing-input-response). http-header-validation stays baselined (SEP-2243 Mcp-Method/Mcp-Name cross-check is separate work). TODO comments in S2-touched files now cite their CLEANUP-LEDGER row; _requirements.py drops the deleted SingleExchangeDispatcher class name from its prose constant.
c94095c to
2313cde
Compare
…igration.md fixes
Four review findings, reworked for by-construction:
1. Non-POST -> bare 405 + Allow header (HTTP-layer rejection happens before
JSON-RPC parsing, so it doesn't go through the JSON-RPC status table).
_write stays table-pure with no override parameter.
2. Stateful HTTP no longer enters lifespan twice. New serve_loop() free
function in runner.py owns the loop-mode dispatcher recipe (notably
inline_methods={'initialize'}); both Server.run() and the manager's
stateful path call it. Manager owns lifespan for all HTTP modes;
Server.run() owns it for stdio/memory. Stateful sessions also now have
their session_id threaded onto Connection.
3. Well-formed JSON that isn't a single request object (notification,
response, batch) returns INVALID_REQUEST instead of PARSE_ERROR.
json.loads and model_validate are now distinct steps; INVALID_REQUEST
added to ERROR_CODE_HTTP_STATUS. The classifier now receives the decoded
body directly rather than a reconstructed dict.
4. migration.md: fixed the broken ctx.connection example (replaced with
prose; the high-level Context surface for this is being finalised),
documented stateless kwarg + StatelessModeNotSupported removals,
updated the ServerSession constructor example, and extended the
lifespan-once entry to cover stateful as well.
| async def _handle_discover( | ||
| self, ctx: ServerRequestContext[LifespanResultT], params: types.RequestParams | None | ||
| ) -> types.DiscoverResult: | ||
| """Default `server/discover` handler. | ||
|
|
||
| Auto-derived from server state at call time, so capabilities reflect | ||
| whatever has been registered (constructor `on_*` kwargs and later | ||
| `add_request_handler` calls). Operators can replace it wholesale via | ||
| `add_request_handler("server/discover", ...)`. Reachability for legacy | ||
| peers is decided at the boundary (`types.methods`), not here. | ||
| """ | ||
| return types.DiscoverResult( | ||
| supported_versions=list(MODERN_PROTOCOL_VERSIONS), | ||
| capabilities=self.get_capabilities(), | ||
| server_info=self.server_info, | ||
| instructions=self.instructions, | ||
| ) |
There was a problem hiding this comment.
🟡 The auto-derived server/discover handler builds its capabilities via self.get_capabilities() with no arguments, so a lowlevel-Server operator who declares listChanged or experimental capabilities via create_initialization_options(NotificationOptions(...), {...}) will advertise them in the initialize response but not in the server/discover response. Consider threading the stored options through (or storing them on the Server), or explicitly documenting that discover ignores create_initialization_options arguments.
Extended reasoning...
What the bug is. The new default Server._handle_discover (src/mcp/server/lowlevel/server.py:397-413) returns DiscoverResult(capabilities=self.get_capabilities(), ...). With this PR, get_capabilities() defaults notification_options to NotificationOptions() (all list_changed flags False) and experimental_capabilities to None. Meanwhile the legacy initialize path still reports capabilities from InitializationOptions (ServerRunner._handle_initialize uses opts.capabilities), which the operator builds via the documented create_initialization_options(NotificationOptions(tools_changed=True), {\"my-ext\": {...}}). The same server therefore advertises different capabilities depending on which protocol era the client speaks.
Code path / step-by-step proof.
- An operator constructs a lowlevel
Serverand callsserver.run(read, write, server.create_initialization_options(NotificationOptions(tools_changed=True), {\"my-ext\": {}}))— the existing, documented way to declarelistChangedand experimental capabilities. - A 2025-era client sends
initialize;_handle_initializereturnsopts.capabilities→tools.listChanged == trueand theexperimentalblock is present. - A 2026-era client (or any modern-envelope path) sends
server/discover;_handle_discovercallsself.get_capabilities()with no arguments →notification_options = NotificationOptions(),experimental_capabilities = None→ the response reportstools.listChanged == falseand noexperimentalblock. - The two advertisements disagree for the same server, and there is no knob to influence the discover-derived capabilities short of replacing the whole handler via
add_request_handler(\"server/discover\", ...).
Why existing code doesn't prevent it. _handle_discover structurally cannot see the per-run InitializationOptions — they are a parameter of Server.run()/serve_loop, not server state — so the under-advertisement is baked into the handler's design rather than an accidental wiring miss.
Mitigating factors (why this is filed as a nit, addressing the refutation). The practical blast radius is narrow: every in-tree caller (MCPServer, the SSE app, the stdio __main__, the streamable-HTTP manager) calls create_initialization_options() with no arguments, so initialize and discover agree on all SDK-owned paths; only out-of-tree lowlevel operators who explicitly pass NotificationOptions/experimental are affected. The affected fields are advisory (listChanged hints, experimental block), and on the modern single-exchange HTTP path server notifications are currently dropped anyway (TODO D-005a), so listChanged=False there is arguably the more honest answer. The pre-existing TODO(L53) on get_capabilities already flags that list_changed/experimental should be derived from server state instead of caller-assembled options, and the discover docstring documents the wholesale-override escape hatch. None of that makes the divergence go away, though — the inconsistency is newly introduced by this PR (server/discover is new here) and is user-visible to any client that compares the two advertisements.
Impact. A 2026-era client consuming server/discover from such a server will believe list-changed notifications are not emitted and that no experimental capabilities exist, while a 2025-era client connecting to the same server is told the opposite. Capability-gated client behaviour (subscribing to list-changed, enabling experimental features) silently degrades on the modern path.
How to fix. Either (a) let the Server store NotificationOptions / experimental capabilities (e.g. constructor params, in line with TODO(L53)) and have both create_initialization_options() and _handle_discover read from that single source, or (b) at minimum document on create_initialization_options / the migration guide that arguments passed there do not influence the server/discover response and that operators needing parity must override the handler via add_request_handler(\"server/discover\", ...).
…l from Connection; hygiene - ServerSession: drop standalone_outbound kwarg; read connection.outbound at the channel-select sites (one source of truth for the standalone channel; the documented two-arg form is now correct rather than mis-wiring) - serve_loop: add to __all__; list in module docstring; soften the recipe-ownership claim - _write: drop dead extra_headers kwarg - _SingleExchangeDispatchContext.can_send_request: field(init=False) so True is unconstructible - inbound: use UnsupportedProtocolVersionErrorData typed model for -32022 data; document why INTERNAL_ERROR is unmapped; anchor the header-validation TODO - Sharpen the notification-POST rejection comment to cite the spec's cannot-accept branch - Docstring corrections: session.py module docstring (per-request, not per-connection); Server.run() drops the internal tracking ref - TODO anchors aligned to current tracking entries - Tests: use public client_params property; complete ERROR_CODE_HTTP_STATUS parametrize; assert .error.code over message-text regex
| with anyio.fail_after(5): | ||
| # stateless=True so server.run doesn't wait for initialize handshake. | ||
| # Before the fix, this raised ExceptionGroup(ClosedResourceError). | ||
| await server.run(read_recv, write_send, server.create_initialization_options(), stateless=True) | ||
| await server.run(read_recv, write_send, server.create_initialization_options()) |
There was a problem hiding this comment.
🟡 Stale comment: this PR removed the stateless kwarg from Server.run() (and updated this call), but the line above still says "stateless=True so server.run doesn't wait for initialize handshake" — it now describes an argument that no longer exists. Update or delete the comment.
Extended reasoning...
What the issue is. This PR deletes the stateless: bool parameter from the lowlevel Server.run() (see src/mcp/server/lowlevel/server.py and the new migration-guide entry "Server.run() no longer takes a stateless flag"), and the call site in tests/server/test_lowlevel_exception_handling.py was mechanically updated to await server.run(read_recv, write_send, server.create_initialization_options()). The comment immediately above that call, however, was left untouched: # stateless=True so server.run doesn't wait for initialize handshake. The comment now refers to an argument that is neither passed nor accepted anywhere in the API.
Why it's misleading. The comment doesn't just name a removed flag — its rationale no longer describes how the test works. The test pushes a RuntimeError into the read stream and then closes it; server.run() exits because the read side closes, not because an init gate was skipped. No request is ever dispatched, so the initialize handshake is irrelevant to the test's flow either way. A reader trying to understand the new serve_loop-based Server.run() could be misled into believing a stateless mode still exists on the loop driver, when the PR's whole point is that statelessness is now a property of how the Connection is constructed (Connection.from_envelope), not a flag on the loop.
Step-by-step. (1) Before this PR, line 42 read await server.run(..., stateless=True) and the comment on line 40 explained that argument. (2) This PR removes stateless from Server.run()'s signature and edits line 42 to drop the kwarg. (3) Line 40 still says "stateless=True so server.run doesn't wait for initialize handshake", describing code that no longer exists. (4) The test still passes — the exit path is the read-stream close — confirming the comment's rationale is not load-bearing.
Why nothing else catches it. Comments aren't checked by the type checker or tests, and nothing else in the file references the removed flag, so the stale text will persist until someone notices it manually.
How to fix. Delete the line, or replace it with something accurate, e.g. # server.run exits when the read stream yields the exception and then closes. One-line cleanup; behaviour is unaffected, so this is a nit and shouldn't block the PR.
Implements server-side support for the 2026-07-28 stateless streamable-HTTP path, removing
server-statelessfrom the conformance expected-failures baseline (25/25 checks pass).Part of #2891. Touches #2892, #2893, #2895, #2896.
Motivation and Context
The 2026-07-28 protocol revision drops the initialize handshake on streamable HTTP: each POST is self-describing (protocol version, client info, client capabilities ride in
params._meta) and the server responds with a single JSON-RPC reply. Getting there cleanly meant separating "what handles a request" from "what owns the connection lifecycle" — the existing code threaded astateless: boolflag through several layers and re-derived per-connection facts in multiple places.What changed
Kernel/driver split.
ServerRunneris now a pure handler kernel (server,connection,lifespan_state→on_request/on_notify). Three free-function drivers compose it with a transport:serve_one(single exchange),serve_connection(caller-supplied dispatcher),serve_loop(stream-pair convenience; whatServer.run()and the stateful HTTP path now call).Connectionowns per-peer state. Two factories —from_envelopefor the per-request stateless path,for_loopfor handshake-driven connections.protocol_version: stris non-Optional and factory-populated; the previousor "2025-11-25"fallbacks and the 17-line resolver are gone._NoChannelOutboundmakes "no back-channel" a property of the held object, not a mode flag —stateless: boolis removed fromServerRunner,ServerSession, andServer.run().Inbound classifier.
shared/inbound.pyis a pure validation ladder (envelope shape → header/body match → version supported) returning a typed verdict;ERROR_CODE_HTTP_STATUSis the single code→status table the HTTP entry reads.Modern HTTP entry.
_streamable_http_modern.handle_modern_requestis the per-POST path: parse → classify →Connection.from_envelope→serve_one→ status-mapped response. The session manager routes by header: known initialize-handshake versions go to the legacy transport; everything else (including unrecognised versions) goes to the modern entry where the classifier validates.Lifespan owned once.
StreamableHTTPSessionManager.run()entersapp.lifespanonce at startup for both stateful and stateless modes; the stateful path now drives viaserve_loopwith that state instead of re-entering per session.server/discover. Auto-derived from registered handlers; a 2025 client getsMETHOD_NOT_FOUNDfrom the method/version map, not from a version check in the handler body.@mcp.tool()exception handling.MCPError(and subclasses) raised from a tool body now propagates as a top-level JSON-RPC error instead of being wrapped asisError: truecontent — generalising the existing special-case forUrlElicitationRequiredError.Client. The streamable-HTTP client now parses JSON-RPC error bodies on 4xx responses (so the typed error reaches the caller instead of a generic HTTP exception).
How Has This Been Tested?
server-statelessconformance scenario: 25/25 at the pinned harness, removed from both expected-failures baselines./scripts/test: 100% branch coverage,strict-no-covercleantests/shared/test_inbound.pycovers the classifier exhaustively;tests/interaction/transports/test_hosting_http_modern.pypins the wire contract end to endBreaking Changes
All documented in
docs/migration.md:Server.run()no longer takesstateless: boolStatelessModeNotSupportedremoved → callers catchNoBackChannelErrorlifespanis entered once at manager startup (was per-session/per-request)ServerSessionconstructor signature changed (now(request_outbound, connection))MCPErrorraised from@mcp.tool()now surfaces as a protocol error (wasisError: true)Types of changes
Checklist
Additional context
Method-existence is intentionally not a classifier rung — kernel dispatch owns that decision so custom-registered methods route and the answer lives in one place. The modern entry takes the spec's "cannot accept" branch for notification POSTs (400 +
INVALID_REQUEST); the core protocol defines no client→server notifications over HTTP at this revision (cancellation is SSE-stream close).AI Disclaimer