diff --git a/crates/rmcp/src/transport/common/server_side_http.rs b/crates/rmcp/src/transport/common/server_side_http.rs index 09df6d285..ba248ba82 100644 --- a/crates/rmcp/src/transport/common/server_side_http.rs +++ b/crates/rmcp/src/transport/common/server_side_http.rs @@ -195,10 +195,12 @@ pub(crate) const fn internal_error_response( } } -pub(crate) fn unexpected_message_response(expect: &str) -> Response> { +/// MCP spec: servers that require a session ID SHOULD respond to requests +/// without one (other than initialization) with 400 Bad Request. +pub(crate) fn session_id_required_response() -> Response> { Response::builder() - .status(http::StatusCode::UNPROCESSABLE_ENTITY) - .body(Full::new(Bytes::from(format!("Unexpected message, expect {expect}"))).boxed()) + .status(http::StatusCode::BAD_REQUEST) + .body(Full::new(Bytes::from("Bad Request: Session ID is required")).boxed()) .expect("valid response") } diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index ab541ff31..597547bc6 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -45,7 +45,7 @@ use crate::{ mcp_headers, server_side_http::{ BoxResponse, ServerSseMessage, accepted_response, expect_json, - internal_error_response, sse_stream_response, unexpected_message_response, + internal_error_response, session_id_required_response, sse_stream_response, }, }, }, @@ -1914,11 +1914,7 @@ where .and_then(|v| v.to_str().ok()) .map(|s| s.to_owned().into()); let Some(session_id) = session_id else { - // MCP spec: servers that require a session ID SHOULD respond with 400 Bad Request - return Ok(Response::builder() - .status(http::StatusCode::BAD_REQUEST) - .body(Full::new(Bytes::from("Bad Request: Session ID is required")).boxed()) - .expect("valid response")); + return Ok(session_id_required_response()); }; // check if session exists let has_session = self @@ -2159,7 +2155,7 @@ where let stored_init_params = match &mut message { ClientJsonRpcMessage::Request(req) => { let ClientRequest::InitializeRequest(init_req) = &req.request else { - return Err(unexpected_message_response("initialize request").into()); + return Err(session_id_required_response().into()); }; // Reject mismatched MCP-Protocol-Version header before binding the session to anything. validate_header_matches_init_body( @@ -2177,7 +2173,7 @@ where stored_init_params } _ => { - return Err(unexpected_message_response("initialize request").into()); + return Err(session_id_required_response().into()); } }; let service = self @@ -2382,11 +2378,7 @@ where .and_then(|v| v.to_str().ok()) .map(|s| s.to_owned().into()); let Some(session_id) = session_id else { - // MCP spec: servers that require a session ID SHOULD respond with 400 Bad Request - return Ok(Response::builder() - .status(http::StatusCode::BAD_REQUEST) - .body(Full::new(Bytes::from("Bad Request: Session ID is required")).boxed()) - .expect("valid response")); + return Ok(session_id_required_response()); }; // Validate MCP-Protocol-Version header (per 2025-06-18 spec) validate_protocol_version_header(request.headers(), false)?; diff --git a/crates/rmcp/tests/test_streamable_http_protocol_version.rs b/crates/rmcp/tests/test_streamable_http_protocol_version.rs index d4388d143..2adf54532 100644 --- a/crates/rmcp/tests/test_streamable_http_protocol_version.rs +++ b/crates/rmcp/tests/test_streamable_http_protocol_version.rs @@ -240,6 +240,41 @@ async fn stateful_init_rejects_when_header_mismatches_body() -> anyhow::Result<( Ok(()) } +/// MCP spec (Streamable HTTP, Session Management): servers that require a +/// session ID SHOULD respond to requests without an `Mcp-Session-Id` header +/// (other than initialization) with HTTP 400 Bad Request. +#[tokio::test] +async fn stateful_post_without_session_id_is_bad_request() -> anyhow::Result<()> { + let session_manager = Arc::new(LocalSessionManager::default()); + let (client, url, ct) = + spawn_server_with_manager(stateful_config(), session_manager.clone()).await; + + let response = post_non_initialize(&client, &url).await; + assert_eq!(response.status(), 400); + assert_eq!( + response.text().await?, + "Bad Request: Session ID is required" + ); + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .send() + .await?; + assert_eq!(response.status(), 400); + assert_eq!( + response.text().await?, + "Bad Request: Session ID is required" + ); + + assert_eq!(session_manager.sessions.read().await.len(), 0); + + ct.cancel(); + Ok(()) +} + #[tokio::test] async fn stateful_rejected_initial_posts_do_not_create_sessions() -> anyhow::Result<()> { let session_manager = Arc::new(LocalSessionManager::default()); @@ -247,7 +282,7 @@ async fn stateful_rejected_initial_posts_do_not_create_sessions() -> anyhow::Res spawn_server_with_manager(stateful_config(), session_manager.clone()).await; let response = post_non_initialize(&client, &url).await; - assert_eq!(response.status(), 422); + assert_eq!(response.status(), 400); assert_eq!(session_manager.sessions.read().await.len(), 0); let response = post_init(&client, &url, Some("2024-11-05"), "2025-11-25").await;