Skip to content

Commit 55b478b

Browse files
authored
fix(rmcp): surface JSON-RPC error bodies on HTTP 4xx responses (#748)
* fix(rmcp): surface JSON-RPC error bodies on HTTP 4xx responses When a server returns a 4xx status with Content-Type: application/json, attempt to deserialize the body as a ServerJsonRpcMessage before falling back to UnexpectedServerResponse. This allows JSON-RPC error payloads carried on HTTP error responses to be surfaced as McpError instead of being lost in a transport-level error string. Fixes #724 * fix(rmcp): surface JSON-RPC error bodies on HTTP 4xx responses When a server returns a 4xx status with Content-Type: application/json, attempt to deserialize the body as a ServerJsonRpcMessage before falling back to UnexpectedServerResponse. This allows JSON-RPC error payloads carried on HTTP error responses to be surfaced as McpError instead of being lost in a transport-level error string. Fixes #724 * fix(rmcp): only accept JsonRpcMessage::Error on non-success responses
1 parent 44dfcf5 commit 55b478b

3 files changed

Lines changed: 196 additions & 12 deletions

File tree

crates/rmcp/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,11 @@ name = "test_streamable_http_json_response"
219219
required-features = ["server", "client", "transport-streamable-http-server", "reqwest"]
220220
path = "tests/test_streamable_http_json_response.rs"
221221

222+
[[test]]
223+
name = "test_streamable_http_4xx_error_body"
224+
required-features = ["transport-streamable-http-client", "transport-streamable-http-client-reqwest"]
225+
path = "tests/test_streamable_http_4xx_error_body.rs"
226+
222227

223228
[[test]]
224229
name = "test_custom_request"

crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use reqwest::header::ACCEPT;
66
use sse_stream::{Sse, SseStream};
77

88
use crate::{
9-
model::{ClientJsonRpcMessage, ServerJsonRpcMessage},
9+
model::{ClientJsonRpcMessage, JsonRpcMessage, ServerJsonRpcMessage},
1010
transport::{
1111
common::http_header::{
1212
EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_MCP_PROTOCOL_VERSION,
@@ -59,6 +59,15 @@ fn apply_custom_headers(
5959
Ok(builder)
6060
}
6161

62+
/// Attempts to parse `body` as a JSON-RPC error message.
63+
/// Returns `None` if the body is not parseable or is not a `JsonRpcMessage::Error`.
64+
fn parse_json_rpc_error(body: &str) -> Option<ServerJsonRpcMessage> {
65+
match serde_json::from_str::<ServerJsonRpcMessage>(body) {
66+
Ok(message @ JsonRpcMessage::Error(_)) => Some(message),
67+
_ => None,
68+
}
69+
}
70+
6271
impl StreamableHttpClient for reqwest::Client {
6372
type Error = reqwest::Error;
6473

@@ -190,21 +199,40 @@ impl StreamableHttpClient for reqwest::Client {
190199
if status == reqwest::StatusCode::NOT_FOUND && session_was_attached {
191200
return Err(StreamableHttpError::SessionExpired);
192201
}
202+
let content_type = response
203+
.headers()
204+
.get(reqwest::header::CONTENT_TYPE)
205+
.map(|ct| String::from_utf8_lossy(ct.as_bytes()).to_string());
206+
let session_id = response
207+
.headers()
208+
.get(HEADER_SESSION_ID)
209+
.and_then(|v| v.to_str().ok())
210+
.map(|s| s.to_string());
211+
// Non-success responses may carry valid JSON-RPC error payloads that
212+
// should be surfaced as McpError rather than lost in TransportSend.
193213
if !status.is_success() {
194214
let body = response
195215
.text()
196216
.await
197217
.unwrap_or_else(|_| "<failed to read response body>".to_owned());
218+
if content_type
219+
.as_deref()
220+
.is_some_and(|ct| ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()))
221+
{
222+
match parse_json_rpc_error(&body) {
223+
Some(message) => {
224+
return Ok(StreamableHttpPostResponse::Json(message, session_id));
225+
}
226+
None => tracing::warn!(
227+
"HTTP {status}: could not parse JSON body as a JSON-RPC error"
228+
),
229+
}
230+
}
198231
return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned(
199232
format!("HTTP {status}: {body}"),
200233
)));
201234
}
202-
let content_type = response.headers().get(reqwest::header::CONTENT_TYPE);
203-
let session_id = response.headers().get(HEADER_SESSION_ID);
204-
let session_id = session_id
205-
.and_then(|v| v.to_str().ok())
206-
.map(|s| s.to_string());
207-
match content_type {
235+
match content_type.as_deref() {
208236
Some(ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => {
209237
let event_stream = SseStream::from_byte_stream(response.bytes_stream()).boxed();
210238
Ok(StreamableHttpPostResponse::Sse(event_stream, session_id))
@@ -226,9 +254,7 @@ impl StreamableHttpClient for reqwest::Client {
226254
_ => {
227255
// unexpected content type
228256
tracing::error!("unexpected content type: {:?}", content_type);
229-
Err(StreamableHttpError::UnexpectedContentType(
230-
content_type.map(|ct| String::from_utf8_lossy(ct.as_bytes()).to_string()),
231-
))
257+
Err(StreamableHttpError::UnexpectedContentType(content_type))
232258
}
233259
}
234260
}
@@ -308,8 +334,8 @@ fn extract_scope_from_header(header: &str) -> Option<String> {
308334

309335
#[cfg(test)]
310336
mod tests {
311-
use super::extract_scope_from_header;
312-
use crate::transport::streamable_http_client::InsufficientScopeError;
337+
use super::{extract_scope_from_header, parse_json_rpc_error};
338+
use crate::{model::JsonRpcMessage, transport::streamable_http_client::InsufficientScopeError};
313339

314340
#[test]
315341
fn extract_scope_quoted() {
@@ -356,4 +382,36 @@ mod tests {
356382
assert!(!without_scope.can_upgrade());
357383
assert_eq!(without_scope.get_required_scope(), None);
358384
}
385+
386+
#[test]
387+
fn parse_json_rpc_error_returns_error_variant() {
388+
let body =
389+
r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Invalid Request"}}"#;
390+
assert!(matches!(
391+
parse_json_rpc_error(body),
392+
Some(JsonRpcMessage::Error(_))
393+
));
394+
}
395+
396+
#[test]
397+
fn parse_json_rpc_error_rejects_non_error_request() {
398+
// A valid JSON-RPC request (method + id) must not be accepted as an error.
399+
let body = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#;
400+
assert!(parse_json_rpc_error(body).is_none());
401+
}
402+
403+
#[test]
404+
fn parse_json_rpc_error_rejects_notification() {
405+
// A notification (method, no id) must not be accepted as an error.
406+
let body =
407+
r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}"#;
408+
assert!(parse_json_rpc_error(body).is_none());
409+
}
410+
411+
#[test]
412+
fn parse_json_rpc_error_rejects_malformed_json() {
413+
assert!(parse_json_rpc_error("not json at all").is_none());
414+
assert!(parse_json_rpc_error("").is_none());
415+
assert!(parse_json_rpc_error(r#"{"broken":"#).is_none());
416+
}
359417
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
#![cfg(all(
2+
feature = "transport-streamable-http-client",
3+
feature = "transport-streamable-http-client-reqwest",
4+
not(feature = "local")
5+
))]
6+
7+
use std::{collections::HashMap, sync::Arc};
8+
9+
use rmcp::{
10+
model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId},
11+
transport::streamable_http_client::{
12+
StreamableHttpClient, StreamableHttpError, StreamableHttpPostResponse,
13+
},
14+
};
15+
16+
/// Spin up a minimal axum server that always responds with the given status,
17+
/// content-type, and body — no MCP logic involved.
18+
async fn spawn_mock_server(status: u16, content_type: &'static str, body: &'static str) -> String {
19+
use axum::{Router, body::Body, http::Response, routing::post};
20+
21+
let router = Router::new().route(
22+
"/mcp",
23+
post(move || async move {
24+
Response::builder()
25+
.status(status)
26+
.header("content-type", content_type)
27+
.body(Body::from(body))
28+
.unwrap()
29+
}),
30+
);
31+
32+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
33+
let addr = listener.local_addr().unwrap();
34+
tokio::spawn(async move {
35+
axum::serve(listener, router).await.unwrap();
36+
});
37+
38+
format!("http://{addr}/mcp")
39+
}
40+
41+
fn ping_message() -> ClientJsonRpcMessage {
42+
ClientJsonRpcMessage::request(
43+
ClientRequest::PingRequest(PingRequest::default()),
44+
RequestId::Number(1),
45+
)
46+
}
47+
48+
/// HTTP 4xx with Content-Type: application/json and a valid JSON-RPC error body
49+
/// must be surfaced as `StreamableHttpPostResponse::Json`, not swallowed as a
50+
/// transport error.
51+
#[tokio::test]
52+
async fn http_4xx_json_rpc_error_body_is_surfaced_as_json_response() {
53+
let body = r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Invalid Request"}}"#;
54+
let url = spawn_mock_server(400, "application/json", body).await;
55+
56+
let client = reqwest::Client::new();
57+
let result = client
58+
.post_message(
59+
Arc::from(url.as_str()),
60+
ping_message(),
61+
None,
62+
None,
63+
HashMap::new(),
64+
)
65+
.await;
66+
67+
match result {
68+
Ok(StreamableHttpPostResponse::Json(msg, _)) => {
69+
let json = serde_json::to_value(&msg).unwrap();
70+
assert_eq!(json["error"]["code"], -32600);
71+
assert_eq!(json["error"]["message"], "Invalid Request");
72+
}
73+
other => panic!("expected Json response, got: {other:?}"),
74+
}
75+
}
76+
77+
/// HTTP 4xx with non-JSON content-type must still return `UnexpectedServerResponse`
78+
/// (no regression on the original error path).
79+
#[tokio::test]
80+
async fn http_4xx_non_json_body_returns_unexpected_server_response() {
81+
let url = spawn_mock_server(400, "text/plain", "Bad Request").await;
82+
83+
let client = reqwest::Client::new();
84+
let result = client
85+
.post_message(
86+
Arc::from(url.as_str()),
87+
ping_message(),
88+
None,
89+
None,
90+
HashMap::new(),
91+
)
92+
.await;
93+
94+
match result {
95+
Err(StreamableHttpError::UnexpectedServerResponse(_)) => {}
96+
other => panic!("expected UnexpectedServerResponse, got: {other:?}"),
97+
}
98+
}
99+
100+
/// HTTP 4xx with Content-Type: application/json but a body that is NOT a valid
101+
/// JSON-RPC message must fall back to `UnexpectedServerResponse`.
102+
#[tokio::test]
103+
async fn http_4xx_malformed_json_body_falls_back_to_unexpected_server_response() {
104+
let url = spawn_mock_server(400, "application/json", r#"{"error":"not jsonrpc"}"#).await;
105+
106+
let client = reqwest::Client::new();
107+
let result = client
108+
.post_message(
109+
Arc::from(url.as_str()),
110+
ping_message(),
111+
None,
112+
None,
113+
HashMap::new(),
114+
)
115+
.await;
116+
117+
match result {
118+
Err(StreamableHttpError::UnexpectedServerResponse(_)) => {}
119+
other => panic!("expected UnexpectedServerResponse, got: {other:?}"),
120+
}
121+
}

0 commit comments

Comments
 (0)