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
Original file line number Diff line number Diff line change
Expand Up @@ -296,17 +296,17 @@ impl StreamableHttpClient for reqwest::Client {
.get(HEADER_SESSION_ID)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// A POST carrying only notifications/responses/errors leaves no request
// outstanding, so nothing in the response is load-bearing for us.
let awaits_no_response = matches!(
Comment thread
BlueX888 marked this conversation as resolved.
Resolved
message,
ClientJsonRpcMessage::Notification(_)
| ClientJsonRpcMessage::Response(_)
| ClientJsonRpcMessage::Error(_)
);
// 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(_)
)
{
if status.is_success() && content_length == Some(0) && awaits_no_response {
return Ok(StreamableHttpPostResponse::Accepted);
}
// Non-success responses may carry valid JSON-RPC error payloads that
Expand Down Expand Up @@ -360,14 +360,7 @@ impl StreamableHttpClient for reqwest::Client {
let body = response.bytes().await?;
match serde_json::from_slice::<ServerJsonRpcMessage>(&body) {
Ok(parsed) => Ok(StreamableHttpPostResponse::Json(parsed, session_id)),
Err(e)
if matches!(
message,
ClientJsonRpcMessage::Notification(_)
| ClientJsonRpcMessage::Response(_)
| ClientJsonRpcMessage::Error(_)
) =>
{
Err(e) if awaits_no_response => {
tracing::warn!(
"could not parse JSON response as ServerJsonRpcMessage, treating as accepted: {e}"
);
Expand All @@ -378,6 +371,18 @@ impl StreamableHttpClient for reqwest::Client {
))),
}
}
// Neither JSON nor SSE. A POST that awaits no response needs nothing
// from the body, so accept any 2xx rather than failing the send on an
// unexpected or absent Content-Type. This covers servers that answer
// `notifications/initialized` with 200 instead of 202, and a chunked
// empty body, where an absent Content-Length stops the check above
// from firing.
_ if awaits_no_response => {
tracing::warn!(
"ignoring unexpected content type {content_type:?} on a POST that awaits no response"
);
Ok(StreamableHttpPostResponse::Accepted)
}
_ => {
// unexpected content type
tracing::error!("unexpected content type: {:?}", content_type);
Expand Down
39 changes: 22 additions & 17 deletions crates/rmcp/src/transport/common/unix_socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,15 +296,17 @@ impl StreamableHttpClient for UnixSocketHttpClient {
.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(_)
)
{
// A POST carrying only notifications/responses/errors leaves no request
// outstanding, so nothing in the response is load-bearing for us.
let awaits_no_response = matches!(
Comment thread
BlueX888 marked this conversation as resolved.
Resolved
message,
ClientJsonRpcMessage::Notification(_)
| ClientJsonRpcMessage::Response(_)
| ClientJsonRpcMessage::Error(_)
);
// 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) && awaits_no_response {
return Ok(StreamableHttpPostResponse::Accepted);
}

Expand All @@ -323,14 +325,7 @@ impl StreamableHttpClient for UnixSocketHttpClient {
.to_bytes();
match serde_json::from_slice::<ServerJsonRpcMessage>(&body) {
Ok(parsed) => Ok(StreamableHttpPostResponse::Json(parsed, session_id)),
Err(e)
if matches!(
message,
ClientJsonRpcMessage::Notification(_)
| ClientJsonRpcMessage::Response(_)
| ClientJsonRpcMessage::Error(_)
) =>
{
Err(e) if awaits_no_response => {
tracing::warn!(
"could not parse JSON response as ServerJsonRpcMessage, treating as accepted: {e}"
);
Expand All @@ -341,6 +336,16 @@ impl StreamableHttpClient for UnixSocketHttpClient {
))),
}
}
// Neither JSON nor SSE. A POST that awaits no response needs nothing
// from the body, so accept any 2xx rather than failing the send on an
// unexpected or absent Content-Type.
_ if awaits_no_response => {
tracing::warn!(
"ignoring unexpected content type {:?} on a POST that awaits no response",
content_type
);
Ok(StreamableHttpPostResponse::Accepted)
}
_ => Err(StreamableHttpError::UnexpectedContentType(
content_type.map(|ct| String::from_utf8_lossy(ct.as_bytes()).into_owned()),
)),
Expand Down
151 changes: 140 additions & 11 deletions crates/rmcp/tests/test_streamable_http_empty_2xx_notification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,7 @@ use rmcp::{
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 }));

async fn spawn_server(router: axum::Router) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
Expand All @@ -25,24 +21,157 @@ async fn spawn_empty_ok_server() -> String {
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
async fn post_initialized(
url: &str,
) -> Result<
StreamableHttpPostResponse,
rmcp::transport::streamable_http_client::StreamableHttpError<reqwest::Error>,
> {
reqwest::Client::new()
.post_message(
Arc::from(url.as_str()),
Arc::from(url),
ClientJsonRpcMessage::notification(ClientNotification::InitializedNotification(
InitializedNotification::default(),
)),
None,
None,
HashMap::new(),
)
.await
}

#[tokio::test]
async fn empty_success_response_to_notification_is_accepted() {
use axum::{Router, http::StatusCode, routing::post};

let url = spawn_server(Router::new().route("/mcp", post(|| async { StatusCode::OK }))).await;

match post_initialized(&url).await {
Ok(StreamableHttpPostResponse::Accepted) => {}
other => panic!("expected Accepted, got: {other:?}"),
}
}

#[tokio::test]
async fn success_response_with_unexpected_content_type_to_notification_is_accepted() {
use axum::{Router, http::StatusCode, routing::post};

let url = spawn_server(Router::new().route(
"/mcp",
post(|| async { (StatusCode::OK, "this is not a JSON-RPC message") }),
))
.await;

match post_initialized(&url).await {
Ok(StreamableHttpPostResponse::Accepted) => {}
other => panic!("expected Accepted, got: {other:?}"),
}
}

#[tokio::test]
async fn success_response_without_content_type_or_length_to_notification_is_accepted() {
use axum::{Router, body::Body, routing::post};

// A streaming body of unknown length: no Content-Length, and no
// Content-Type either, so the reply looks neither JSON nor SSE.
let url = spawn_server(Router::new().route(
"/mcp",
post(|| async {
Body::from_stream(futures::stream::empty::<
Result<axum::body::Bytes, std::io::Error>,
>())
}),
))
.await;

match post_initialized(&url).await {
Ok(StreamableHttpPostResponse::Accepted) => {}
other => panic!("expected Accepted, got: {other:?}"),
}
}

#[tokio::test]
async fn server_error_response_to_notification_is_still_rejected() {
use axum::{Router, http::StatusCode, routing::post};
use rmcp::transport::streamable_http_client::StreamableHttpError;

// A non-2xx reply is an error even for a notification: the 2xx fallback
// must stay below the status check, or server failures become accepted
// sends.
let url = spawn_server(Router::new().route(
"/mcp",
post(|| async { (StatusCode::INTERNAL_SERVER_ERROR, "server error") }),
))
.await;

match post_initialized(&url).await {
Err(StreamableHttpError::HttpStatus(error)) => assert_eq!(error.status, 500),
other => panic!("expected HttpStatus(500), got: {other:?}"),
}
}

#[tokio::test]
async fn success_response_to_a_client_response_is_accepted() {
use axum::{Router, http::StatusCode, routing::post};
use rmcp::model::{ClientResult, EmptyResult, RequestId};

// A client Response also awaits no reply, so it shares the notification
// fallback; this pins that arm alongside the Notification cases.
let url = spawn_server(Router::new().route(
"/mcp",
post(|| async { (StatusCode::OK, "this is not a JSON-RPC message") }),
))
.await;

let result = reqwest::Client::new()
.post_message(
Arc::from(url.as_str()),
ClientJsonRpcMessage::response(
ClientResult::EmptyResult(EmptyResult {}),
RequestId::Number(1),
),
None,
None,
HashMap::new(),
)
.await;

match result {
Ok(StreamableHttpPostResponse::Accepted) => {}
other => panic!("expected Accepted, got: {other:?}"),
}
}

#[tokio::test]
async fn request_response_with_unexpected_content_type_is_still_rejected() {
use axum::{Router, routing::post};
use rmcp::{
model::{ClientRequest, PingRequest, RequestId},
transport::streamable_http_client::StreamableHttpError,
};

let url = spawn_server(
Router::new().route("/mcp", post(|| async { "this is not a JSON-RPC message" })),
)
.await;

let result = reqwest::Client::new()
.post_message(
Arc::from(url.as_str()),
ClientJsonRpcMessage::request(
ClientRequest::PingRequest(PingRequest::default()),
RequestId::Number(1),
),
None,
None,
HashMap::new(),
)
.await;

match result {
Err(StreamableHttpError::UnexpectedContentType(Some(content_type))) => {
assert!(content_type.starts_with("text/plain"));
}
other => panic!("expected UnexpectedContentType, got: {other:?}"),
}
}
Loading
Loading