Skip to content

Serve the Sessionless Modern Path over Streamable HTTP per SEP-2575 - #479

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:http_modern_path
Open

Serve the Sessionless Modern Path over Streamable HTTP per SEP-2575#479
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:http_modern_path

Conversation

@koic

@koic koic commented Aug 2, 2026

Copy link
Copy Markdown
Member

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 #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.

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

## 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.
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.

2 participants