Skip to content

feat(core,server): route stateless-revision HTTP requests to a stateless dispatch seam#2236

Draft
felixweinberger wants to merge 6 commits into
fweinberger/m3-draft-version-guardsfrom
fweinberger/m4-stateless-routing
Draft

feat(core,server): route stateless-revision HTTP requests to a stateless dispatch seam#2236
felixweinberger wants to merge 6 commits into
fweinberger/m3-draft-version-guardsfrom
fweinberger/m4-stateless-routing

Conversation

@felixweinberger
Copy link
Copy Markdown
Contributor

🗺️ Milestone tracker · M4: stateless request routing · stacked on #2235

Streamable HTTP servers route sessionless requests claiming a stateless protocol revision (newer than 2025-11-25) to a stateless dispatch seam. The dispatch itself is not implemented yet and answers with a clear error; all existing traffic is byte-identical.

Motivation and Context

Stateless revisions negotiate per-request and never carry sessions, so the transport needs a second dispatch path before any of their behaviors can exist. Server.connect() installs the seam automatically — no user configuration.

How Has This Been Tested?

Unit tests for the four routing rules (session header always wins; version resolution; both server-side keys required; stateful path untouched) and the error mapping. Three e2e wire-contract requirements incl. the self-describing gap error vs the session-required 400. Full matrix 0 unexpected failures, baseline byte-identical.

Breaking Changes

None.

Types of changes

  • New feature (non-breaking change which adds functionality)

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

Additional context

git grep NotImplementedYet is the gap inventory: exactly one open seam (Server._dispatchStateless), filled by the envelope-acceptance PR. Part of #2184/#2185.

Add STATEFUL_PROTOCOL_VERSIONS — the closed list of protocol versions
negotiated via the initialize handshake; every revision after 2025-11-25
is stateless and negotiates per-request — with an
isStatefulProtocolVersion() predicate on the internal barrel, plus
DRAFT_PROTOCOL_VERSION_2026 ('DRAFT-2026-v1', mirroring the draft
specification schema) and DRAFT_PROTOCOL_VERSIONS.
SUPPORTED_PROTOCOL_VERSIONS is unchanged. Unit tests pin the draft wire
literal and its stateless classification against the generated draft
spec schema.
…itialize

Protocol revisions after 2025-11-25 are stateless and never negotiated
via the initialize handshake. Both sides now run the unchanged handshake
logic against the stateful subset of supportedProtocolVersions:

- Client: requests the first stateful supported version, rejects in
  connect() when the subset is empty, and rejects an initialize result
  whose version is outside the subset.
- Server: accepts the requested version only when it is in its own
  subset, otherwise falls back to the subset's first entry.

Covered by unit tests on both sides and an e2e requirement
(lifecycle:version:initialize-stateful-versions-only) that wire-taps a
handshake where both sides list the draft revision and asserts no
post-2025-11-25 version string appears.
- NotImplementedYetError (internal barrel only): marks a deliberately-open
  seam whose behavior lands with a later milestone; every throw site names
  what fills the gap in a code comment, so grepping for the class name is
  the inventory of remaining gaps.
- Transport.setStatelessHandlers?() (@internal) plus the StatelessHandlers /
  StatelessDispatchContext shapes: the seam a server uses to install a
  stateless dispatch path on transports that support per-request routing.
  The context is minimal (authInfo only) and grows with its consumers.

No transport implements the seam yet and nothing installs it; behavior is
unchanged.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Jun 1, 2026

🦋 Changeset detected

Latest commit: dcc53d5

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@modelcontextprotocol/core Patch
@modelcontextprotocol/server Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 1, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@2236

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/codemod@2236

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@2236

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server-legacy@2236

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@2236

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@2236

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@2236

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@2236

commit: dcc53d5

@felixweinberger felixweinberger force-pushed the fweinberger/m4-stateless-routing branch from 7610527 to ea0305f Compare June 1, 2026 23:51
… seam

Server.connect() installs the stateless dispatch handlers on transports
that support the seam, before super.connect() starts the transport so the
first message cannot arrive before the router is wired. The dispatch
implementation itself is a deliberately-open seam: it throws
NotImplementedYetError with a wire-safe message until the envelope
acceptance work lands.

WebStandardStreamableHTTPServerTransport.handleRequest() now routes, in
order: (1) any request carrying Mcp-Session-Id takes the session path,
unconditionally — sessions are version-locked, a version claim never
bypasses session validation; (2) otherwise the claimed version is the
MCP-Protocol-Version header, falling back to the pre-parsed body's first
request _meta; (3) a claimed draft version that the transport lists as
supported routes to the stateless path when handlers are installed;
(4) everything else takes the stateful path — the previous POST/GET/DELETE
switch moved unchanged into handleStatefulRequest(), so a draft version
the server does not list still gets the existing unsupported-version 400.

The stateless path is minimal: it parses a single JSON-RPC request (no
batches, no SSE, no session interaction) and maps NotImplementedYetError
to HTTP 501 with a JSON-RPC -32603 error, echoing the request id when
parseable.

Unit tests cover all four routing rules (including session-header-beats-
version and the seam-absent fallthrough), the 501 mapping, the body-_meta
fallback, and that Server.connect() tolerates transports without the
seam.
Three hosting:routing requirements pin the transport's routing facts on
the wire:

- session-id-never-stateless: a session-bearing request always goes
  through session validation regardless of the protocol version it
  claims (valid session served as today, unknown session 404 — never
  the stateless path's 501).
- stateless-only-configured: a draft-version claim routes stateless only
  when the server lists that version and is connected; otherwise the
  existing unsupported-version 400 is returned byte-identically.
- gap-is-self-describing: a routed request is answered with HTTP 501 and
  a JSON-RPC -32603 error naming the unimplemented dispatch, with the
  request id echoed — observably distinct from the session-required 400.

Plus a changeset (core/server patch) and a docs/server.md note in the
protocol-versions section.
…s seam

StdioServerTransport now implements the stateless dispatch seam:
setSupportedProtocolVersions() (Protocol.connect() already passes the
server's list down optionally) and setStatelessHandlers() (installed by
Server.connect() before start(), so the router is wired before the first
byte of stdin is read).

The read loop routes a message to the stateless path only when all hold:
handlers are installed, the message is a JSON-RPC request, and its
params._meta['io.modelcontextprotocol/protocolVersion'] claim is a string
that is not a stateful protocol version AND is in the supported list --
the same dual-key rule the HTTP transport applies to sessionless
requests. stdio has no session header, so the _meta claim is the routing
signal, but it only activates when the operator explicitly lists a draft
version (drafts never enter the default supported list). Notifications
and responses claiming stateless versions stay on onmessage, matching
the HTTP path at this milestone.

A routed request is dispatched and its response written to stdout;
NotImplementedYetError maps to a JSON-RPC -32603 error with the
wire-safe message and the request id echoed (the same self-describing
gap as the HTTP path's 501); any other dispatch failure answers with a
generic -32603 'Internal error' so internals never leak.

The shared _meta key moves to core as PROTOCOL_VERSION_META_KEY (was a
local constant in streamableHttp.ts) so both transports key on the same
string.

Unit tests cover the routed gap reply (never reaching onmessage),
dispatch-result forwarding, stateful/meta-less/notification fallthrough,
the unlisted-version dual-key fallthrough, the no-handlers fallthrough,
and the generic-error mapping. The hosting:routing e2e requirements
stateless-only-configured and gap-is-self-describing gain stdio cells
driving the spawned fixture server with hand-built messages (the fixture
gains an E2E_LIST_DRAFT_VERSION opt-in knob); the changeset and
docs/server.md note the stdio routing.
@felixweinberger felixweinberger force-pushed the fweinberger/m4-stateless-routing branch from ea0305f to dcc53d5 Compare June 2, 2026 00:09
@felixweinberger felixweinberger force-pushed the fweinberger/m3-draft-version-guards branch from 9306136 to 57aa16c Compare June 2, 2026 08:22
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