Skip to content
Merged
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
8 changes: 5 additions & 3 deletions crates/rmcp/src/transport/common/server_side_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,10 +195,12 @@ pub(crate) const fn internal_error_response<E: Display>(
}
}

pub(crate) fn unexpected_message_response(expect: &str) -> Response<BoxBody<Bytes, Infallible>> {
/// 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<BoxBody<Bytes, Infallible>> {
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")
}

Expand Down
18 changes: 5 additions & 13 deletions crates/rmcp/src/transport/streamable_http_server/tower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
},
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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)?;
Expand Down
37 changes: 36 additions & 1 deletion crates/rmcp/tests/test_streamable_http_protocol_version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,14 +240,49 @@ 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());
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(), 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;
Expand Down
Loading