fix(server): serve draft-version requests statelessly per SEP-2567#999
fix(server): serve draft-version requests statelessly per SEP-2567#999alexhancock wants to merge 3 commits into
Conversation
| .unwrap_or(ProtocolVersion::V_2025_03_26) | ||
| }); | ||
| let use_session = | ||
| self.config.stateful_mode && declared_version < ProtocolVersion::V_2026_07_28; |
There was a problem hiding this comment.
Since #973 has landed, could we classify the request as legacy or modern before touching the session manager, then derive use_session from that classification instead of ordering ProtocolVersion? I'm thinking of something like stateful_mode && lifecycle.is_legacy(). This would also let malformed or unsupported versions fail before any session lookup or restore.
| )) | ||
| } | ||
|
|
||
| async fn handle_post<B>(&self, request: Request<B>) -> Result<BoxResponse, BoxResponse> |
There was a problem hiding this comment.
GET and DELETE still enter legacy session handlers?
There was a problem hiding this comment.
Since 2026-07-28 requests are always stateless, should we deprecate stateful_mode in favor of a legacy-scoped option such as legacy_session_mode? After this change, the current option only affects pre-2026-07-28 traffic, but its name suggests that it controls all requests. Since v3 already allows breaking changes, this may be a good opportunity to clarify the API.
78e4e11 to
d27b509
Compare
| if !is_legacy_request(None, request.headers()) { | ||
| return Ok(method_not_allowed_response()); | ||
| } |
There was a problem hiding this comment.
We should move this check to the start of handle_get() to make draft GET behavior independent of legacy SSE validation.
| } | ||
|
|
||
| // SEP-2567: sessions are removed from 2026-07-28; older versions are legacy. | ||
| fn is_legacy_request(message: Option<&ClientJsonRpcMessage>, headers: &HeaderMap) -> bool { |
There was a problem hiding this comment.
Could this helper return Result and perform the existing header/meta validation before any caller touches the session manager?
| pub sse_retry: Option<Duration>, | ||
| /// If true, the server will create a session for each request and keep it alive. | ||
| /// When enabled, SSE priming events are sent to enable client reconnection. | ||
| pub stateful_mode: bool, |
There was a problem hiding this comment.
Since this is a breaking change, the commit message should contains ! to pass CI like refactor!: rename stateful_mode to legacy_session_mode.
Motivation and Context
Per modelcontextprotocol/modelcontextprotocol#2567 protocol-level sessions (and the
Mcp-Session-Idheader) are removed from the Streamable HTTP transport as of the draft protocol version (2026-07-28); servers speaking that version MUST serve requests statelessly.The rust-sdk server assigned an
Mcp-Session-Idin its defaultstateful_modeeven when the negotiated protocol version was2026-07-28. This gates the session code path on the negotiated version: sessions are used only for pre-draft versions (< 2026-07-28), and draft-version requests are served statelessly even instateful_mode. Older versions retain full session behavior.How Has This Been Tested?
No dedicated SEP-2567 conformance scenario exists (the conformance PR that would add one was closed unmerged); per the maintainer, every existing draft (
2026-07-28) scenario already sends sessionless requests. Verified against the existing draft server suite:The default (stateful) server now produces results byte-for-byte identical to an explicit stateless server (48 passed / 31 failed; the 31 remaining failures are pre-existing SEP-2575
server/discovergaps, out of scope).rmcplib unit tests (232) and streamable-HTTP integration tests (session store, stale session, protocol version, JSON response — 19) pass locally.Breaking Changes
None. The session code path is unchanged for all pre-
2026-07-28versions; only the draft version changes behavior, bringing it into spec compliance.Types of changes
Checklist
Additional context
SEP-2575 (also in progress) removes the
initializehandshake for the draft version. Once that lands, the version detection here can be simplified to a lifecycle check (aninitializerequest implies the pre-draft/stateful lifecycle; draft requests never carryinitialize).