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
This commit is contained in:
parent
7abbf29e93
commit
a5bb51c9d2
13 changed files with 103 additions and 34 deletions
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
//!
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
/// 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
|
||||
}
|
||||
|
||||
|
|
@ -250,6 +255,57 @@ fn message_has_per_request_protocol_version(message: &ClientJsonRpcMessage) -> b
|
|||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::result_large_err,
|
||||
reason = "BoxResponse is intentionally large; matches other handlers in this file"
|
||||
)]
|
||||
// SEP-2567: sessions are removed from 2026-07-28; older versions are legacy.
|
||||
// Validates protocol-version consistency and returns `Ok(true)` only for a valid legacy request.
|
||||
fn is_legacy_request(
|
||||
message: Option<&ClientJsonRpcMessage>,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<bool, BoxResponse> {
|
||||
let has_per_request_version = message.is_some_and(message_has_per_request_protocol_version);
|
||||
validate_protocol_version_header(headers, has_per_request_version)?;
|
||||
if let Some(message) = message {
|
||||
if let ClientJsonRpcMessage::Request(req) = message {
|
||||
if let ClientRequest::InitializeRequest(init) = &req.request {
|
||||
validate_header_matches_init_body(
|
||||
headers,
|
||||
init.params.protocol_version.as_str(),
|
||||
Some(req.id.clone()),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
validate_request_protocol_version_meta(headers, message)?;
|
||||
}
|
||||
|
||||
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);
|
||||
Ok(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>>,
|
||||
|
|
@ -662,7 +718,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.
|
||||
|
|
@ -1158,13 +1214,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,
|
||||
_ => {
|
||||
|
|
@ -1187,6 +1243,9 @@ where
|
|||
B: Body + Send + 'static,
|
||||
B::Error: Display,
|
||||
{
|
||||
if !is_legacy_request(None, request.headers())? {
|
||||
return Ok(method_not_allowed_response());
|
||||
}
|
||||
// check accept header
|
||||
if !request
|
||||
.headers()
|
||||
|
|
@ -1340,7 +1399,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
|
||||
|
|
@ -1673,6 +1735,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()
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in a new issue