Serve the Sessionless Modern Path over Streamable HTTP per SEP-2575 - #479
Open
koic wants to merge 1 commit into
Open
Serve the Sessionless Modern Path over Streamable HTTP per SEP-2575#479koic wants to merge 1 commit into
koic wants to merge 1 commit into
Conversation
## Motivation and Context Fourth step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for the 2026-07-28 MCP spec release. `StreamableHTTPTransport` now routes header-first, like the Python SDK's streamable HTTP manager: an `MCP-Protocol-Version` header naming a version outside every supported list enters a new sessionless modern path, while requests without the header (or with a stable-only version) take the existing paths untouched. Routing unknown versions to the modern path means an unknown future version receives the spec-mandated `-32022` with `data: { supported:, requested: }` instead of a generic invalid-request error. Since 2026-07-28 became the latest stable protocol version, it serves both lifecycles of the dual-era model, and the header value alone can no longer decide the era. For a dual-era header the routing disambiguates by request shape: an `Mcp-Session-Id` binds the request to an established legacy session (POST requests, the GET SSE stream, and DELETE termination keep working), and a sessionless POST whose body is `initialize` is the legacy-distinctive handshake, so a client negotiating 2026-07-28 over the classic handshake connects unchanged. Everything else under a dual-era header is sessionless modern traffic (`server/discover`, envelope-carrying requests, and envelope-missing requests that get the modern path's error shape). The era sniff reads the body once, bounded by `max_request_bytes`, and hands the string to whichever path serves the request, since Rack 3 inputs need not be rewindable. The modern path (`handle_modern`) is a single POST/JSON exchange: - GET (the removed listening stream, replaced by `subscriptions/listen`) and DELETE (no sessions to terminate) return 405. - It never consults `@stateless`, `@sessions`, or `@enable_json_response`, never issues an `Mcp-Session-Id`, and rejects requests carrying one with HTTP 400. - Its body read is bounded by `max_request_bytes` (HTTP 413), like the legacy POST path. - Header/body match rules surface as `-32020 HEADER_MISMATCH` (HTTP 400): the header version against the `_meta`-carried version, and the `Mcp-Method` / `Mcp-Name` mirror headers against the body when sent. `Mcp-Name` decoding mirrors the client transport's `=?base64?...?=` sentinel for values that are not header-safe ASCII. - HTTP statuses follow the Python SDK's ladder: `-32020`/`-32021`/ `-32022` and the generic parse/invalid codes map to 400, `-32601` maps to 404 (disambiguating an unknown method from a legacy HTTP+SSE 404), and everything else including internal errors stays 200. - Dispatch runs against an ephemeral per-request `ServerSession` locked to `era: :modern`, whose fresh unregistered `session_id` makes notification delivery degrade gracefully instead of broadcasting to unrelated legacy sessions through the broadcast branch of `send_notification`. The legacy POST path gains one load-bearing guard for bodies carrying the modern `_meta` triple, which would previously fall through the legacy path via the header default. Session-bound under a dual-era header, such a request is a lifecycle violation, rejected as `-32600` because the session already negotiated the legacy lifecycle (mirroring the stdio era lock). With the header missing or naming a stable-only version, it violates the header/body match requirement and is rejected as a header mismatch (`-32020`). Intentional behavior changes for previously-erroneous requests, all following from header-primary routing: an unknown header version now yields `-32022` with data instead of the legacy `-32600` message (`initialize` included, which legacy-wise ignored the header), and GET/DELETE with an unknown header version, or with a dual-era header and no session, yield 405 instead of 400. An empty header value stays malformed on the legacy path. Existing tests were updated to codify these. Refs modelcontextprotocol#389. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover: the sessionless 200 exchange without `Mcp-Session-Id`, `-32022` with the supported list for unknown header versions, `-32020` for header/body version, `Mcp-Method`, and base64-encoded `Mcp-Name` mismatches, the `Mcp-Session-Id` rejection, 405 for modern GET/DELETE, 404/`-32601` for unknown methods, the envelope requirement (`-32600`), `-32021` with `requiredCapabilities` from a handler capability guard, `server/discover` without an envelope, and the legacy-path sniff for modern-envelope bodies without the modern header. Dual-era routing tests: a sessionless `initialize` with the 2026-07-28 header stays legacy and negotiates 2026-07-28, session-bound POST and DELETE with that header stay on the legacy path, a session-bound POST carrying the modern envelope is rejected as `-32600`, and an oversized modern POST returns 413. ## Breaking Changes None for conforming clients: requests without the header or with a stable-only version are byte-identical to before, and legacy clients negotiating 2026-07-28 over the classic handshake connect unchanged. Requests that were already rejected change error shape (see intentional behavior changes above) to the forms the 2026-07-28 spec mandates.
atesgoral
approved these changes
Aug 2, 2026
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.
Motivation and Context
Fourth step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for the 2026-07-28 MCP spec release.
StreamableHTTPTransportnow routes header-first, like the Python SDK's streamable HTTP manager: anMCP-Protocol-Versionheader naming a version outside every supported list enters a new sessionless modern path, while requests without the header (or with a stable-only version) take the existing paths untouched. Routing unknown versions to the modern path means an unknown future version receives the spec-mandated-32022withdata: { supported:, requested: }instead of a generic invalid-request error.Since 2026-07-28 became the latest stable protocol version, it serves both lifecycles of the dual-era model, and the header value alone can no longer decide the era. For a dual-era header the routing disambiguates by request shape: an
Mcp-Session-Idbinds the request to an established legacy session (POST requests, the GET SSE stream, and DELETE termination keep working), and a sessionless POST whose body isinitializeis the legacy-distinctive handshake, so a client negotiating 2026-07-28 over the classic handshake connects unchanged. Everything else under a dual-era header is sessionless modern traffic (server/discover, envelope-carrying requests, and envelope-missing requests that get the modern path's error shape). The era sniff reads the body once, bounded bymax_request_bytes, and hands the string to whichever path serves the request, since Rack 3 inputs need not be rewindable.The modern path (
handle_modern) is a single POST/JSON exchange:subscriptions/listen) and DELETE (no sessions to terminate) return 405.@stateless,@sessions, or@enable_json_response, never issues anMcp-Session-Id, and rejects requests carrying one with HTTP 400.max_request_bytes(HTTP 413), like the legacy POST path.-32020 HEADER_MISMATCH(HTTP 400): the header version against the_meta-carried version, and theMcp-Method/Mcp-Namemirror headers against the body when sent.Mcp-Namedecoding mirrors the client transport's=?base64?...?=sentinel for values that are not header-safe ASCII.-32020/-32021/-32022and the generic parse/invalid codes map to 400,-32601maps to 404 (disambiguating an unknown method from a legacy HTTP+SSE 404), and everything else including internal errors stays 200.ServerSessionlocked toera: :modern, whose fresh unregisteredsession_idmakes notification delivery degrade gracefully instead of broadcasting to unrelated legacy sessions through the broadcast branch ofsend_notification.The legacy POST path gains one load-bearing guard for bodies carrying the modern
_metatriple, which would previously fall through the legacy path via the header default. Session-bound under a dual-era header, such a request is a lifecycle violation, rejected as-32600because the session already negotiated the legacy lifecycle (mirroring the stdio era lock). With the header missing or naming a stable-only version, it violates the header/body match requirement and is rejected as a header mismatch (-32020).Intentional behavior changes for previously-erroneous requests, all following from header-primary routing: an unknown header version now yields
-32022with data instead of the legacy-32600message (initializeincluded, which legacy-wise ignored the header), and GET/DELETE with an unknown header version, or with a dual-era header and no session, yield 405 instead of 400. An empty header value stays malformed on the legacy path. Existing tests were updated to codify these.Refs #389.
How Has This Been Tested?
New tests in
test/mcp/server/transports/streamable_http_transport_test.rbcover: the sessionless 200 exchange withoutMcp-Session-Id,-32022with the supported list for unknown header versions,-32020for header/body version,Mcp-Method, and base64-encodedMcp-Namemismatches, theMcp-Session-Idrejection, 405 for modern GET/DELETE, 404/-32601for unknown methods, the envelope requirement (-32600),-32021withrequiredCapabilitiesfrom a handler capability guard,server/discoverwithout an envelope, and the legacy-path sniff for modern-envelope bodies without the modern header.Dual-era routing tests: a sessionless
initializewith the 2026-07-28 header stays legacy and negotiates 2026-07-28, session-bound POST and DELETE with that header stay on the legacy path, a session-bound POST carrying the modern envelope is rejected as-32600, and an oversized modern POST returns 413.Breaking Changes
None for conforming clients: requests without the header or with a stable-only version are byte-identical to before, and legacy clients negotiating 2026-07-28 over the classic handshake connect unchanged. Requests that were already rejected change error shape (see intentional behavior changes above) to the forms the 2026-07-28 spec mandates.
Types of changes
Checklist