feat(core,server): route stateless-revision HTTP requests to a stateless dispatch seam#2236
Draft
felixweinberger wants to merge 6 commits into
Draft
Conversation
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 detectedLatest commit: dcc53d5 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
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 |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
7610527 to
ea0305f
Compare
… 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.
ea0305f to
dcc53d5
Compare
9306136 to
57aa16c
Compare
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.
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
Checklist
Additional context
git grep NotImplementedYetis the gap inventory: exactly one open seam (Server._dispatchStateless), filled by the envelope-acceptance PR. Part of #2184/#2185.