From 158e18179dc1245357f20b155d7d628b0fc1b0d7 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Thu, 16 Jul 2026 13:52:19 -0400 Subject: [PATCH 1/3] fix(server): serve draft-version requests statelessly per SEP-2567 --- .../transport/streamable_http_server/tower.rs | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 117912e4d..9d1f51262 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -1340,7 +1340,31 @@ where Err(response) => return Ok(response), }; - if self.config.stateful_mode { + // SEP-2567: sessions are removed at the protocol level from 2026-07-28. + // Serve such requests statelessly even in stateful mode, never assigning + // or requiring an Mcp-Session-Id. `initialize` declares the version in its + // body params; every other request in the `MCP-Protocol-Version` header + // (defaulting to `2025-03-26` when absent). + let init_version = match &message { + ClientJsonRpcMessage::Request(req) => match &req.request { + ClientRequest::InitializeRequest(init) => { + Some(init.params.protocol_version.clone()) + } + _ => None, + }, + _ => None, + }; + let declared_version = init_version.unwrap_or_else(|| { + part.headers + .get(HEADER_MCP_PROTOCOL_VERSION) + .and_then(|value| value.to_str().ok()) + .and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok()) + .unwrap_or(ProtocolVersion::V_2025_03_26) + }); + let use_session = + self.config.stateful_mode && declared_version < ProtocolVersion::V_2026_07_28; + + if use_session { // do we have a session id? let session_id = part .headers From d27b509da21f022364341efafcee237b98560ff9 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Fri, 17 Jul 2026 13:52:53 -0400 Subject: [PATCH 2/3] refactor: address PR feedback --- .../transport/streamable_http_server/tower.rs | 57 ++++++++++++------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 9d1f51262..96b19f158 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -250,6 +250,34 @@ fn message_has_per_request_protocol_version(message: &ClientJsonRpcMessage) -> b } } +// SEP-2567: sessions are removed from 2026-07-28; older versions are legacy. +fn is_legacy_request(message: Option<&ClientJsonRpcMessage>, headers: &HeaderMap) -> bool { + let from_body = match message { + Some(ClientJsonRpcMessage::Request(req)) => match &req.request { + ClientRequest::InitializeRequest(init) => Some(init.params.protocol_version.clone()), + _ => req.request.get_meta().protocol_version(), + }, + _ => None, + }; + let version = from_body + .or_else(|| { + headers + .get(HEADER_MCP_PROTOCOL_VERSION) + .and_then(|value| value.to_str().ok()) + .and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok()) + }) + .unwrap_or(ProtocolVersion::V_2025_03_26); + version < ProtocolVersion::V_2026_07_28 +} + +fn method_not_allowed_response() -> BoxResponse { + Response::builder() + .status(http::StatusCode::METHOD_NOT_ALLOWED) + .header(ALLOW, "POST") + .body(Full::new(Bytes::from("Method Not Allowed")).boxed()) + .expect("valid response") +} + fn invalid_request_jsonrpc_response( id: Option, message: impl Into>, @@ -1204,6 +1232,9 @@ where ) .expect("valid response")); } + if !is_legacy_request(None, request.headers()) { + return Ok(method_not_allowed_response()); + } // check session id let session_id = request .headers() @@ -1340,29 +1371,8 @@ where Err(response) => return Ok(response), }; - // SEP-2567: sessions are removed at the protocol level from 2026-07-28. - // Serve such requests statelessly even in stateful mode, never assigning - // or requiring an Mcp-Session-Id. `initialize` declares the version in its - // body params; every other request in the `MCP-Protocol-Version` header - // (defaulting to `2025-03-26` when absent). - let init_version = match &message { - ClientJsonRpcMessage::Request(req) => match &req.request { - ClientRequest::InitializeRequest(init) => { - Some(init.params.protocol_version.clone()) - } - _ => None, - }, - _ => None, - }; - let declared_version = init_version.unwrap_or_else(|| { - part.headers - .get(HEADER_MCP_PROTOCOL_VERSION) - .and_then(|value| value.to_str().ok()) - .and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok()) - .unwrap_or(ProtocolVersion::V_2025_03_26) - }); let use_session = - self.config.stateful_mode && declared_version < ProtocolVersion::V_2026_07_28; + self.config.stateful_mode && is_legacy_request(Some(&message), &part.headers); if use_session { // do we have a session id? @@ -1697,6 +1707,9 @@ where B: Body + Send + 'static, B::Error: Display, { + if !is_legacy_request(None, request.headers()) { + return Ok(method_not_allowed_response()); + } // check session id let session_id = request .headers() From 80e9e93213786a441eb22ecbb36312c9d4f538e9 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Fri, 17 Jul 2026 16:12:36 -0400 Subject: [PATCH 3/3] refactor: rename stateful_mode to legacy_session_mode --- conformance/src/bin/server.rs | 2 +- crates/rmcp/CHANGELOG.md | 4 +++ .../streamable_http_server/session.rs | 2 +- .../transport/streamable_http_server/tower.rs | 25 +++++++++++-------- .../test_discover_http_client_startup.rs | 2 +- .../rmcp/tests/test_server_discover_http.rs | 12 ++++----- .../tests/test_stateless_protocol_version.rs | 2 +- .../test_streamable_http_disconnect_cancel.rs | 2 +- .../test_streamable_http_json_response.rs | 10 ++++---- .../tests/test_streamable_http_priming.rs | 4 +-- .../test_streamable_http_protocol_version.rs | 4 +-- .../test_streamable_http_session_store.rs | 6 ++--- .../test_streamable_http_standard_headers.rs | 2 +- 13 files changed, 43 insertions(+), 34 deletions(-) diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 4df4812d6..b83b53843 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -1229,7 +1229,7 @@ async fn main() -> anyhow::Result<()> { let server = ConformanceServer::new(); let stateless = std::env::var_os("STATELESS").is_some(); let config = StreamableHttpServerConfig::default() - .with_stateful_mode(!stateless) + .with_legacy_session_mode(!stateless) .with_json_response(stateless); let service = StreamableHttpService::new( move || Ok(server.clone()), diff --git a/crates/rmcp/CHANGELOG.md b/crates/rmcp/CHANGELOG.md index 1b6c212ec..dbfb14623 100644 --- a/crates/rmcp/CHANGELOG.md +++ b/crates/rmcp/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING**: rename `StreamableHttpServerConfig::stateful_mode` to `legacy_session_mode` (and the builder `with_stateful_mode` to `with_legacy_session_mode`) to clarify that the option only affects legacy protocol versions (`< 2026-07-28`); per SEP-2567 the `2026-07-28` draft version is always served statelessly ([#999](https://github.com/modelcontextprotocol/rust-sdk/pull/999)) + ## [2.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v2.1.0...rmcp-v2.2.0) - 2026-07-08 ### Added diff --git a/crates/rmcp/src/transport/streamable_http_server/session.rs b/crates/rmcp/src/transport/streamable_http_server/session.rs index 4be265130..ab0ff3244 100644 --- a/crates/rmcp/src/transport/streamable_http_server/session.rs +++ b/crates/rmcp/src/transport/streamable_http_server/session.rs @@ -13,7 +13,7 @@ //! //! * [`local::LocalSessionManager`] — in-memory session store (default). //! * [`never::NeverSessionManager`] — rejects all session operations, used -//! when stateful mode is disabled. +//! when legacy session mode is disabled. //! //! # Custom session managers //! diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 96b19f158..490c016e6 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -59,8 +59,13 @@ pub struct StreamableHttpServerConfig { pub sse_retry: Option, /// 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, - /// When true and `stateful_mode` is false, the server prefers + /// + /// Only applies to legacy protocol versions (`< 2026-07-28`). Per SEP-2567, + /// sessions are removed from the `2026-07-28` draft version, so requests + /// negotiating that version are always served statelessly regardless of + /// this setting. + pub legacy_session_mode: bool, + /// When true and `legacy_session_mode` is false, the server prefers /// `Content-Type: application/json` for simple request-response tools. /// If the handler emits a notification or request before the final response, /// the server falls back to `text/event-stream` so no message is lost. @@ -130,7 +135,7 @@ impl Default for StreamableHttpServerConfig { Self { sse_keep_alive: Some(Duration::from_secs(15)), sse_retry: Some(Duration::from_secs(3)), - stateful_mode: true, + legacy_session_mode: true, json_response: false, cancellation_token: CancellationToken::new(), allowed_hosts: vec!["localhost".into(), "127.0.0.1".into(), "::1".into()], @@ -176,8 +181,8 @@ impl StreamableHttpServerConfig { self } - pub fn with_stateful_mode(mut self, stateful: bool) -> Self { - self.stateful_mode = stateful; + pub fn with_legacy_session_mode(mut self, legacy_session_mode: bool) -> Self { + self.legacy_session_mode = legacy_session_mode; self } @@ -690,7 +695,7 @@ fn validate_origin_header( /// /// ## Session management /// -/// When [`StreamableHttpServerConfig::stateful_mode`] is `true` (the default), +/// When [`StreamableHttpServerConfig::legacy_session_mode`] is `true` (the default), /// the server creates a session for each client that sends an `initialize` /// request. The session ID is returned in the `Mcp-Session-Id` response header /// and the client must include it on all subsequent requests. @@ -1186,13 +1191,13 @@ where return response; } let method = request.method().clone(); - let allowed_methods = match self.config.stateful_mode { + let allowed_methods = match self.config.legacy_session_mode { true => "GET, POST, DELETE", false => "POST", }; - let result = match (method, self.config.stateful_mode) { + let result = match (method, self.config.legacy_session_mode) { (Method::POST, _) => self.handle_post(request).await, - // if we're not in stateful mode, we don't support GET or DELETE because there is no session + // if legacy session mode is disabled, we don't support GET or DELETE because there is no session (Method::GET, true) => self.handle_get(request).await, (Method::DELETE, true) => self.handle_delete(request).await, _ => { @@ -1372,7 +1377,7 @@ where }; let use_session = - self.config.stateful_mode && is_legacy_request(Some(&message), &part.headers); + self.config.legacy_session_mode && is_legacy_request(Some(&message), &part.headers); if use_session { // do we have a session id? diff --git a/crates/rmcp/tests/test_discover_http_client_startup.rs b/crates/rmcp/tests/test_discover_http_client_startup.rs index 6ce1dbe40..c6051a047 100644 --- a/crates/rmcp/tests/test_discover_http_client_startup.rs +++ b/crates/rmcp/tests/test_discover_http_client_startup.rs @@ -54,7 +54,7 @@ async fn discover_http_client_bootstraps_headers_without_initialize() { || Ok(DiscoverHttpServer), Default::default(), StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) .with_json_response(true) .with_cancellation_token(ct.child_token()), ); diff --git a/crates/rmcp/tests/test_server_discover_http.rs b/crates/rmcp/tests/test_server_discover_http.rs index ad9a16d0a..3dbafb1db 100644 --- a/crates/rmcp/tests/test_server_discover_http.rs +++ b/crates/rmcp/tests/test_server_discover_http.rs @@ -32,16 +32,16 @@ impl ServerHandler for DiscoveryServer { } async fn spawn_server(json_response: bool) -> (reqwest::Client, String, CancellationToken) { - spawn_server_with_stateful_mode(json_response, false).await + spawn_server_with_legacy_session_mode(json_response, false).await } -async fn spawn_server_with_stateful_mode( +async fn spawn_server_with_legacy_session_mode( json_response: bool, - stateful_mode: bool, + legacy_session_mode: bool, ) -> (reqwest::Client, String, CancellationToken) { let cancellation_token = CancellationToken::new(); let config = StreamableHttpServerConfig::default() - .with_stateful_mode(stateful_mode) + .with_legacy_session_mode(legacy_session_mode) .with_json_response(json_response) .with_sse_keep_alive(None) .with_cancellation_token(cancellation_token.clone()); @@ -136,8 +136,8 @@ async fn discover_returns_server_metadata_without_session() { } #[tokio::test] -async fn discover_does_not_require_initialization_in_stateful_mode() { - let (client, url, cancellation_token) = spawn_server_with_stateful_mode(true, true).await; +async fn discover_does_not_require_initialization_in_legacy_session_mode() { + let (client, url, cancellation_token) = spawn_server_with_legacy_session_mode(true, true).await; let response = post_discover(&client, &url, "2025-11-25", Some("2025-11-25")).await; diff --git a/crates/rmcp/tests/test_stateless_protocol_version.rs b/crates/rmcp/tests/test_stateless_protocol_version.rs index 5103ddd8d..1804b791b 100644 --- a/crates/rmcp/tests/test_stateless_protocol_version.rs +++ b/crates/rmcp/tests/test_stateless_protocol_version.rs @@ -16,7 +16,7 @@ use common::calculator::Calculator; fn stateless_json_config() -> StreamableHttpServerConfig { StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) .with_json_response(true) .with_sse_keep_alive(None) .with_cancellation_token(CancellationToken::new()) diff --git a/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs b/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs index 73d056fa1..31e74137d 100644 --- a/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs +++ b/crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs @@ -81,7 +81,7 @@ async fn spawn_stateless_server(json_response: bool) -> anyhow::Result anyhow::Result<() let ct = CancellationToken::new(); let (client, url, ct) = spawn_server( StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) .with_json_response(true) .with_sse_keep_alive(None) .with_cancellation_token(ct.child_token()), @@ -145,7 +145,7 @@ async fn stateless_json_response_falls_back_to_sse_for_progress() -> anyhow::Res let ct = CancellationToken::new(); let (client, url, ct) = spawn_progress_server( StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) .with_json_response(true) .with_sse_keep_alive(None) .with_cancellation_token(ct.child_token()), @@ -197,7 +197,7 @@ async fn stateless_sse_mode_default_unchanged() -> anyhow::Result<()> { let ct = CancellationToken::new(); let (client, url, ct) = spawn_server( StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) .with_sse_keep_alive(None) .with_cancellation_token(ct.child_token()), ) @@ -234,9 +234,9 @@ async fn stateless_sse_mode_default_unchanged() -> anyhow::Result<()> { } #[tokio::test] -async fn json_response_ignored_in_stateful_mode() -> anyhow::Result<()> { +async fn json_response_ignored_in_legacy_session_mode() -> anyhow::Result<()> { let ct = CancellationToken::new(); - // json_response: true has no effect when stateful_mode: true — server still uses SSE + // json_response: true has no effect when legacy_session_mode: true — server still uses SSE let (client, url, ct) = spawn_server( StreamableHttpServerConfig::default() .with_json_response(true) diff --git a/crates/rmcp/tests/test_streamable_http_priming.rs b/crates/rmcp/tests/test_streamable_http_priming.rs index 436d48227..1e0e5e5f9 100644 --- a/crates/rmcp/tests/test_streamable_http_priming.rs +++ b/crates/rmcp/tests/test_streamable_http_priming.rs @@ -14,7 +14,7 @@ use common::calculator::Calculator; async fn test_priming_on_stream_start() -> anyhow::Result<()> { let ct = CancellationToken::new(); - // stateful_mode: true automatically enables priming with DEFAULT_RETRY_INTERVAL (3 seconds) + // legacy_session_mode: true automatically enables priming with DEFAULT_RETRY_INTERVAL (3 seconds) let service: StreamableHttpService = StreamableHttpService::new( || Ok(Calculator::new()), @@ -416,7 +416,7 @@ async fn test_priming_on_stream_close() -> anyhow::Result<()> { let ct = CancellationToken::new(); let session_manager = Arc::new(LocalSessionManager::default()); - // stateful_mode: true automatically enables priming with DEFAULT_RETRY_INTERVAL (3 seconds) + // legacy_session_mode: true automatically enables priming with DEFAULT_RETRY_INTERVAL (3 seconds) let service = StreamableHttpService::new( || Ok(Calculator::new()), session_manager.clone(), diff --git a/crates/rmcp/tests/test_streamable_http_protocol_version.rs b/crates/rmcp/tests/test_streamable_http_protocol_version.rs index 0ed61c0e6..cf63624ce 100644 --- a/crates/rmcp/tests/test_streamable_http_protocol_version.rs +++ b/crates/rmcp/tests/test_streamable_http_protocol_version.rs @@ -50,7 +50,7 @@ async fn spawn_server_with_manager( fn stateless_json_config() -> StreamableHttpServerConfig { StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) .with_json_response(true) .with_sse_keep_alive(None) .with_cancellation_token(CancellationToken::new()) @@ -58,7 +58,7 @@ fn stateless_json_config() -> StreamableHttpServerConfig { fn stateful_config() -> StreamableHttpServerConfig { StreamableHttpServerConfig::default() - .with_stateful_mode(true) + .with_legacy_session_mode(true) .with_sse_keep_alive(None) .with_cancellation_token(CancellationToken::new()) } diff --git a/crates/rmcp/tests/test_streamable_http_session_store.rs b/crates/rmcp/tests/test_streamable_http_session_store.rs index 91e77029e..0c8f6a835 100644 --- a/crates/rmcp/tests/test_streamable_http_session_store.rs +++ b/crates/rmcp/tests/test_streamable_http_session_store.rs @@ -73,7 +73,7 @@ fn make_service( ) -> StreamableHttpService { StreamableHttpService::new(|| Ok(Calculator::new()), Default::default(), { let mut cfg = StreamableHttpServerConfig::default(); - cfg.stateful_mode = true; + cfg.legacy_session_mode = true; cfg.sse_keep_alive = None; cfg.cancellation_token = ct.child_token(); cfg.session_store = Some(session_store); @@ -147,7 +147,7 @@ async fn test_session_state_deleted_from_store_on_delete() -> anyhow::Result<()> let service = StreamableHttpService::new(|| Ok(Calculator::new()), session_manager.clone(), { let mut cfg = StreamableHttpServerConfig::default(); - cfg.stateful_mode = true; + cfg.legacy_session_mode = true; cfg.sse_keep_alive = None; cfg.cancellation_token = ct.child_token(); cfg.session_store = Some(store.clone()); @@ -219,7 +219,7 @@ fn spawn_server( ) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { let svc = StreamableHttpService::new(|| Ok(Calculator::new()), session_manager, { let mut cfg = StreamableHttpServerConfig::default(); - cfg.stateful_mode = true; + cfg.legacy_session_mode = true; cfg.sse_keep_alive = None; cfg.cancellation_token = ct.child_token(); cfg.session_store = session_store; diff --git a/crates/rmcp/tests/test_streamable_http_standard_headers.rs b/crates/rmcp/tests/test_streamable_http_standard_headers.rs index c2c51a7c4..510433e70 100644 --- a/crates/rmcp/tests/test_streamable_http_standard_headers.rs +++ b/crates/rmcp/tests/test_streamable_http_standard_headers.rs @@ -37,7 +37,7 @@ impl ServerHandler for HeaderValidationServer { async fn spawn_server() -> (reqwest::Client, String, CancellationToken) { let config = StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) .with_json_response(true) .with_sse_keep_alive(None) .with_cancellation_token(CancellationToken::new());