diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index 32de491d..e6d4943d 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -178,11 +178,25 @@ impl StreamableHttpClient for reqwest::Client { .headers() .get(reqwest::header::CONTENT_TYPE) .map(|ct| String::from_utf8_lossy(ct.as_bytes()).to_string()); + let content_length = response.content_length(); let session_id = response .headers() .get(HEADER_SESSION_ID) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); + // Spec requires 202 Accepted for these, but some servers return an empty 200. + // Treat empty success responses as equivalent to Accepted. + if status.is_success() + && content_length == Some(0) + && matches!( + message, + ClientJsonRpcMessage::Notification(_) + | ClientJsonRpcMessage::Response(_) + | ClientJsonRpcMessage::Error(_) + ) + { + return Ok(StreamableHttpPostResponse::Accepted); + } // Non-success responses may carry valid JSON-RPC error payloads that // should be surfaced as McpError rather than lost in TransportSend. if !status.is_success() { diff --git a/crates/rmcp/src/transport/common/unix_socket.rs b/crates/rmcp/src/transport/common/unix_socket.rs index 9170c129..8ea30f57 100644 --- a/crates/rmcp/src/transport/common/unix_socket.rs +++ b/crates/rmcp/src/transport/common/unix_socket.rs @@ -257,12 +257,29 @@ impl StreamableHttpClient for UnixSocketHttpClient { } let content_type = response.headers().get(http::header::CONTENT_TYPE).cloned(); + let content_length = response + .headers() + .get(http::header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()); let session_id = response .headers() .get(HEADER_SESSION_ID) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); + if status.is_success() + && content_length == Some(0) + && matches!( + message, + ClientJsonRpcMessage::Notification(_) + | ClientJsonRpcMessage::Response(_) + | ClientJsonRpcMessage::Error(_) + ) + { + return Ok(StreamableHttpPostResponse::Accepted); + } + match content_type { Some(ref ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => { let sse_stream = SseStream::new(response.into_body()).boxed(); diff --git a/crates/rmcp/tests/test_streamable_http_empty_2xx_notification.rs b/crates/rmcp/tests/test_streamable_http_empty_2xx_notification.rs new file mode 100644 index 00000000..c8a15334 --- /dev/null +++ b/crates/rmcp/tests/test_streamable_http_empty_2xx_notification.rs @@ -0,0 +1,48 @@ +#![cfg(all( + feature = "transport-streamable-http-client", + feature = "transport-streamable-http-client-reqwest", + not(feature = "local") +))] + +use std::{collections::HashMap, sync::Arc}; + +use rmcp::{ + model::{ClientJsonRpcMessage, ClientNotification, InitializedNotification}, + transport::streamable_http_client::{StreamableHttpClient, StreamableHttpPostResponse}, +}; + +async fn spawn_empty_ok_server() -> String { + use axum::{Router, http::StatusCode, routing::post}; + + let router = Router::new().route("/mcp", post(|| async { StatusCode::OK })); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + + format!("http://{addr}/mcp") +} + +#[tokio::test] +async fn empty_success_response_to_notification_is_accepted() { + let url = spawn_empty_ok_server().await; + let client = reqwest::Client::new(); + let result = client + .post_message( + Arc::from(url.as_str()), + ClientJsonRpcMessage::notification(ClientNotification::InitializedNotification( + InitializedNotification::default(), + )), + None, + None, + HashMap::new(), + ) + .await; + + match result { + Ok(StreamableHttpPostResponse::Accepted) => {} + other => panic!("expected Accepted, got: {other:?}"), + } +}