Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion conformance/src/bin/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
4 changes: 4 additions & 0 deletions crates/rmcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
//!
Expand Down
62 changes: 52 additions & 10 deletions crates/rmcp/src/transport/streamable_http_server/tower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,13 @@ pub struct StreamableHttpServerConfig {
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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is a breaking change, the commit message should contains ! to pass CI like refactor!: rename stateful_mode to legacy_session_mode.

/// 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.
Expand Down Expand Up @@ -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()],
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -250,6 +255,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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this helper return Result and perform the existing header/meta validation before any caller touches the session manager?

fn validate_protocol_version_header(

fn validate_header_matches_init_body(

fn validate_request_protocol_version_meta(

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<RequestId>,
message: impl Into<Cow<'static, str>>,
Expand Down Expand Up @@ -662,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.
Expand Down Expand Up @@ -1158,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,
_ => {
Expand Down Expand Up @@ -1204,6 +1237,9 @@ where
)
.expect("valid response"));
}
if !is_legacy_request(None, request.headers()) {
return Ok(method_not_allowed_response());
}
Comment on lines +1240 to +1242

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should move this check to the start of handle_get() to make draft GET behavior independent of legacy SSE validation.

// check session id
let session_id = request
.headers()
Expand Down Expand Up @@ -1340,7 +1376,10 @@ where
Err(response) => return Ok(response),
};

if self.config.stateful_mode {
let use_session =
self.config.legacy_session_mode && is_legacy_request(Some(&message), &part.headers);

if use_session {
// do we have a session id?
let session_id = part
.headers
Expand Down Expand Up @@ -1673,6 +1712,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()
Expand Down
2 changes: 1 addition & 1 deletion crates/rmcp/tests/test_discover_http_client_startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
);
Expand Down
12 changes: 6 additions & 6 deletions crates/rmcp/tests/test_server_discover_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion crates/rmcp/tests/test_stateless_protocol_version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ async fn spawn_stateless_server(json_response: bool) -> anyhow::Result<TestServe

let server_ct = CancellationToken::new();
let config = StreamableHttpServerConfig::default()
.with_stateful_mode(false)
.with_legacy_session_mode(false)
.with_json_response(json_response)
// A short keep-alive lets the SSE server notice a dropped connection
// quickly (hyper only observes the disconnect on its next write).
Expand Down
10 changes: 5 additions & 5 deletions crates/rmcp/tests/test_streamable_http_json_response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ async fn stateless_json_response_returns_application_json() -> 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()),
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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()),
)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions crates/rmcp/tests/test_streamable_http_priming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Calculator, LocalSessionManager> =
StreamableHttpService::new(
|| Ok(Calculator::new()),
Expand Down Expand Up @@ -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(),
Expand Down
4 changes: 2 additions & 2 deletions crates/rmcp/tests/test_streamable_http_protocol_version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,15 @@ 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())
}

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())
}
Expand Down
6 changes: 3 additions & 3 deletions crates/rmcp/tests/test_streamable_http_session_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ fn make_service(
) -> StreamableHttpService<Calculator, LocalSessionManager> {
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);
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Loading