Skip to content

Commit a5bb51c

Browse files
authored
fix(server): serve draft-version requests statelessly per SEP-2567 (#999)
* fix(server): serve draft-version requests statelessly per SEP-2567 * refactor: address PR feedback * refactor!: rename stateful_mode to legacy_session_mode * fix: address PR feedback
1 parent 7abbf29 commit a5bb51c

13 files changed

Lines changed: 103 additions & 34 deletions

conformance/src/bin/server.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1229,7 +1229,7 @@ async fn main() -> anyhow::Result<()> {
12291229
let server = ConformanceServer::new();
12301230
let stateless = std::env::var_os("STATELESS").is_some();
12311231
let config = StreamableHttpServerConfig::default()
1232-
.with_stateful_mode(!stateless)
1232+
.with_legacy_session_mode(!stateless)
12331233
.with_json_response(stateless);
12341234
let service = StreamableHttpService::new(
12351235
move || Ok(server.clone()),

crates/rmcp/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- **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))
13+
1014
## [2.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v2.1.0...rmcp-v2.2.0) - 2026-07-08
1115

1216
### Added

crates/rmcp/src/transport/streamable_http_server/session.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
//!
1414
//! * [`local::LocalSessionManager`] — in-memory session store (default).
1515
//! * [`never::NeverSessionManager`] — rejects all session operations, used
16-
//! when stateful mode is disabled.
16+
//! when legacy session mode is disabled.
1717
//!
1818
//! # Custom session managers
1919
//!

crates/rmcp/src/transport/streamable_http_server/tower.rs

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,13 @@ pub struct StreamableHttpServerConfig {
5959
pub sse_retry: Option<Duration>,
6060
/// If true, the server will create a session for each request and keep it alive.
6161
/// When enabled, SSE priming events are sent to enable client reconnection.
62-
pub stateful_mode: bool,
63-
/// When true and `stateful_mode` is false, the server prefers
62+
///
63+
/// Only applies to legacy protocol versions (`< 2026-07-28`). Per SEP-2567,
64+
/// sessions are removed from the `2026-07-28` draft version, so requests
65+
/// negotiating that version are always served statelessly regardless of
66+
/// this setting.
67+
pub legacy_session_mode: bool,
68+
/// When true and `legacy_session_mode` is false, the server prefers
6469
/// `Content-Type: application/json` for simple request-response tools.
6570
/// If the handler emits a notification or request before the final response,
6671
/// the server falls back to `text/event-stream` so no message is lost.
@@ -130,7 +135,7 @@ impl Default for StreamableHttpServerConfig {
130135
Self {
131136
sse_keep_alive: Some(Duration::from_secs(15)),
132137
sse_retry: Some(Duration::from_secs(3)),
133-
stateful_mode: true,
138+
legacy_session_mode: true,
134139
json_response: false,
135140
cancellation_token: CancellationToken::new(),
136141
allowed_hosts: vec!["localhost".into(), "127.0.0.1".into(), "::1".into()],
@@ -176,8 +181,8 @@ impl StreamableHttpServerConfig {
176181
self
177182
}
178183

179-
pub fn with_stateful_mode(mut self, stateful: bool) -> Self {
180-
self.stateful_mode = stateful;
184+
pub fn with_legacy_session_mode(mut self, legacy_session_mode: bool) -> Self {
185+
self.legacy_session_mode = legacy_session_mode;
181186
self
182187
}
183188

@@ -250,6 +255,57 @@ fn message_has_per_request_protocol_version(message: &ClientJsonRpcMessage) -> b
250255
}
251256
}
252257

258+
#[expect(
259+
clippy::result_large_err,
260+
reason = "BoxResponse is intentionally large; matches other handlers in this file"
261+
)]
262+
// SEP-2567: sessions are removed from 2026-07-28; older versions are legacy.
263+
// Validates protocol-version consistency and returns `Ok(true)` only for a valid legacy request.
264+
fn is_legacy_request(
265+
message: Option<&ClientJsonRpcMessage>,
266+
headers: &HeaderMap,
267+
) -> Result<bool, BoxResponse> {
268+
let has_per_request_version = message.is_some_and(message_has_per_request_protocol_version);
269+
validate_protocol_version_header(headers, has_per_request_version)?;
270+
if let Some(message) = message {
271+
if let ClientJsonRpcMessage::Request(req) = message {
272+
if let ClientRequest::InitializeRequest(init) = &req.request {
273+
validate_header_matches_init_body(
274+
headers,
275+
init.params.protocol_version.as_str(),
276+
Some(req.id.clone()),
277+
)?;
278+
}
279+
}
280+
validate_request_protocol_version_meta(headers, message)?;
281+
}
282+
283+
let from_body = match message {
284+
Some(ClientJsonRpcMessage::Request(req)) => match &req.request {
285+
ClientRequest::InitializeRequest(init) => Some(init.params.protocol_version.clone()),
286+
_ => req.request.get_meta().protocol_version(),
287+
},
288+
_ => None,
289+
};
290+
let version = from_body
291+
.or_else(|| {
292+
headers
293+
.get(HEADER_MCP_PROTOCOL_VERSION)
294+
.and_then(|value| value.to_str().ok())
295+
.and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok())
296+
})
297+
.unwrap_or(ProtocolVersion::V_2025_03_26);
298+
Ok(version < ProtocolVersion::V_2026_07_28)
299+
}
300+
301+
fn method_not_allowed_response() -> BoxResponse {
302+
Response::builder()
303+
.status(http::StatusCode::METHOD_NOT_ALLOWED)
304+
.header(ALLOW, "POST")
305+
.body(Full::new(Bytes::from("Method Not Allowed")).boxed())
306+
.expect("valid response")
307+
}
308+
253309
fn invalid_request_jsonrpc_response(
254310
id: Option<RequestId>,
255311
message: impl Into<Cow<'static, str>>,
@@ -662,7 +718,7 @@ fn validate_origin_header(
662718
///
663719
/// ## Session management
664720
///
665-
/// When [`StreamableHttpServerConfig::stateful_mode`] is `true` (the default),
721+
/// When [`StreamableHttpServerConfig::legacy_session_mode`] is `true` (the default),
666722
/// the server creates a session for each client that sends an `initialize`
667723
/// request. The session ID is returned in the `Mcp-Session-Id` response header
668724
/// and the client must include it on all subsequent requests.
@@ -1158,13 +1214,13 @@ where
11581214
return response;
11591215
}
11601216
let method = request.method().clone();
1161-
let allowed_methods = match self.config.stateful_mode {
1217+
let allowed_methods = match self.config.legacy_session_mode {
11621218
true => "GET, POST, DELETE",
11631219
false => "POST",
11641220
};
1165-
let result = match (method, self.config.stateful_mode) {
1221+
let result = match (method, self.config.legacy_session_mode) {
11661222
(Method::POST, _) => self.handle_post(request).await,
1167-
// if we're not in stateful mode, we don't support GET or DELETE because there is no session
1223+
// if legacy session mode is disabled, we don't support GET or DELETE because there is no session
11681224
(Method::GET, true) => self.handle_get(request).await,
11691225
(Method::DELETE, true) => self.handle_delete(request).await,
11701226
_ => {
@@ -1187,6 +1243,9 @@ where
11871243
B: Body + Send + 'static,
11881244
B::Error: Display,
11891245
{
1246+
if !is_legacy_request(None, request.headers())? {
1247+
return Ok(method_not_allowed_response());
1248+
}
11901249
// check accept header
11911250
if !request
11921251
.headers()
@@ -1340,7 +1399,10 @@ where
13401399
Err(response) => return Ok(response),
13411400
};
13421401

1343-
if self.config.stateful_mode {
1402+
let use_session =
1403+
self.config.legacy_session_mode && is_legacy_request(Some(&message), &part.headers)?;
1404+
1405+
if use_session {
13441406
// do we have a session id?
13451407
let session_id = part
13461408
.headers
@@ -1673,6 +1735,9 @@ where
16731735
B: Body + Send + 'static,
16741736
B::Error: Display,
16751737
{
1738+
if !is_legacy_request(None, request.headers())? {
1739+
return Ok(method_not_allowed_response());
1740+
}
16761741
// check session id
16771742
let session_id = request
16781743
.headers()

crates/rmcp/tests/test_discover_http_client_startup.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ async fn discover_http_client_bootstraps_headers_without_initialize() {
5454
|| Ok(DiscoverHttpServer),
5555
Default::default(),
5656
StreamableHttpServerConfig::default()
57-
.with_stateful_mode(false)
57+
.with_legacy_session_mode(false)
5858
.with_json_response(true)
5959
.with_cancellation_token(ct.child_token()),
6060
);

crates/rmcp/tests/test_server_discover_http.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,16 @@ impl ServerHandler for DiscoveryServer {
3232
}
3333

3434
async fn spawn_server(json_response: bool) -> (reqwest::Client, String, CancellationToken) {
35-
spawn_server_with_stateful_mode(json_response, false).await
35+
spawn_server_with_legacy_session_mode(json_response, false).await
3636
}
3737

38-
async fn spawn_server_with_stateful_mode(
38+
async fn spawn_server_with_legacy_session_mode(
3939
json_response: bool,
40-
stateful_mode: bool,
40+
legacy_session_mode: bool,
4141
) -> (reqwest::Client, String, CancellationToken) {
4242
let cancellation_token = CancellationToken::new();
4343
let config = StreamableHttpServerConfig::default()
44-
.with_stateful_mode(stateful_mode)
44+
.with_legacy_session_mode(legacy_session_mode)
4545
.with_json_response(json_response)
4646
.with_sse_keep_alive(None)
4747
.with_cancellation_token(cancellation_token.clone());
@@ -136,8 +136,8 @@ async fn discover_returns_server_metadata_without_session() {
136136
}
137137

138138
#[tokio::test]
139-
async fn discover_does_not_require_initialization_in_stateful_mode() {
140-
let (client, url, cancellation_token) = spawn_server_with_stateful_mode(true, true).await;
139+
async fn discover_does_not_require_initialization_in_legacy_session_mode() {
140+
let (client, url, cancellation_token) = spawn_server_with_legacy_session_mode(true, true).await;
141141

142142
let response = post_discover(&client, &url, "2025-11-25", Some("2025-11-25")).await;
143143

crates/rmcp/tests/test_stateless_protocol_version.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use common::calculator::Calculator;
1616

1717
fn stateless_json_config() -> StreamableHttpServerConfig {
1818
StreamableHttpServerConfig::default()
19-
.with_stateful_mode(false)
19+
.with_legacy_session_mode(false)
2020
.with_json_response(true)
2121
.with_sse_keep_alive(None)
2222
.with_cancellation_token(CancellationToken::new())

crates/rmcp/tests/test_streamable_http_disconnect_cancel.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ async fn spawn_stateless_server(json_response: bool) -> anyhow::Result<TestServe
8181

8282
let server_ct = CancellationToken::new();
8383
let config = StreamableHttpServerConfig::default()
84-
.with_stateful_mode(false)
84+
.with_legacy_session_mode(false)
8585
.with_json_response(json_response)
8686
// A short keep-alive lets the SSE server notice a dropped connection
8787
// quickly (hyper only observes the disconnect on its next write).

crates/rmcp/tests/test_streamable_http_json_response.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ async fn stateless_json_response_returns_application_json() -> anyhow::Result<()
103103
let ct = CancellationToken::new();
104104
let (client, url, ct) = spawn_server(
105105
StreamableHttpServerConfig::default()
106-
.with_stateful_mode(false)
106+
.with_legacy_session_mode(false)
107107
.with_json_response(true)
108108
.with_sse_keep_alive(None)
109109
.with_cancellation_token(ct.child_token()),
@@ -145,7 +145,7 @@ async fn stateless_json_response_falls_back_to_sse_for_progress() -> anyhow::Res
145145
let ct = CancellationToken::new();
146146
let (client, url, ct) = spawn_progress_server(
147147
StreamableHttpServerConfig::default()
148-
.with_stateful_mode(false)
148+
.with_legacy_session_mode(false)
149149
.with_json_response(true)
150150
.with_sse_keep_alive(None)
151151
.with_cancellation_token(ct.child_token()),
@@ -197,7 +197,7 @@ async fn stateless_sse_mode_default_unchanged() -> anyhow::Result<()> {
197197
let ct = CancellationToken::new();
198198
let (client, url, ct) = spawn_server(
199199
StreamableHttpServerConfig::default()
200-
.with_stateful_mode(false)
200+
.with_legacy_session_mode(false)
201201
.with_sse_keep_alive(None)
202202
.with_cancellation_token(ct.child_token()),
203203
)
@@ -234,9 +234,9 @@ async fn stateless_sse_mode_default_unchanged() -> anyhow::Result<()> {
234234
}
235235

236236
#[tokio::test]
237-
async fn json_response_ignored_in_stateful_mode() -> anyhow::Result<()> {
237+
async fn json_response_ignored_in_legacy_session_mode() -> anyhow::Result<()> {
238238
let ct = CancellationToken::new();
239-
// json_response: true has no effect when stateful_mode: true — server still uses SSE
239+
// json_response: true has no effect when legacy_session_mode: true — server still uses SSE
240240
let (client, url, ct) = spawn_server(
241241
StreamableHttpServerConfig::default()
242242
.with_json_response(true)

crates/rmcp/tests/test_streamable_http_priming.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use common::calculator::Calculator;
1414
async fn test_priming_on_stream_start() -> anyhow::Result<()> {
1515
let ct = CancellationToken::new();
1616

17-
// stateful_mode: true automatically enables priming with DEFAULT_RETRY_INTERVAL (3 seconds)
17+
// legacy_session_mode: true automatically enables priming with DEFAULT_RETRY_INTERVAL (3 seconds)
1818
let service: StreamableHttpService<Calculator, LocalSessionManager> =
1919
StreamableHttpService::new(
2020
|| Ok(Calculator::new()),
@@ -416,7 +416,7 @@ async fn test_priming_on_stream_close() -> anyhow::Result<()> {
416416
let ct = CancellationToken::new();
417417
let session_manager = Arc::new(LocalSessionManager::default());
418418

419-
// stateful_mode: true automatically enables priming with DEFAULT_RETRY_INTERVAL (3 seconds)
419+
// legacy_session_mode: true automatically enables priming with DEFAULT_RETRY_INTERVAL (3 seconds)
420420
let service = StreamableHttpService::new(
421421
|| Ok(Calculator::new()),
422422
session_manager.clone(),

0 commit comments

Comments
 (0)