Skip to content

Commit 1543a1a

Browse files
authored
fix(streamable-http): preserve progress in JSON mode (#990)
1 parent f3459fa commit 1543a1a

2 files changed

Lines changed: 157 additions & 24 deletions

File tree

crates/rmcp/src/transport/streamable_http_server/tower.rs

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use crate::{
1919
ClientCapabilities, ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData,
2020
GetExtensions, Implementation, InitializeRequest, InitializeRequestParams,
2121
InitializedNotification, JsonObject, JsonRpcError, ProtocolVersion, RequestId,
22+
ServerJsonRpcMessage,
2223
},
2324
serve_server,
2425
service::serve_directly,
@@ -48,10 +49,10 @@ pub struct StreamableHttpServerConfig {
4849
/// If true, the server will create a session for each request and keep it alive.
4950
/// When enabled, SSE priming events are sent to enable client reconnection.
5051
pub stateful_mode: bool,
51-
/// When true and `stateful_mode` is false, the server returns
52-
/// `Content-Type: application/json` directly instead of `text/event-stream`.
53-
/// This eliminates SSE framing overhead for simple request-response tools,
54-
/// allowed by the MCP Streamable HTTP spec (2025-06-18).
52+
/// When true and `stateful_mode` is false, the server prefers
53+
/// `Content-Type: application/json` for simple request-response tools.
54+
/// If the handler emits a notification or request before the final response,
55+
/// the server falls back to `text/event-stream` so no message is lost.
5556
pub json_response: bool,
5657
/// Cancellation token for the Streamable HTTP server.
5758
///
@@ -1352,31 +1353,47 @@ where
13521353
let _ = service.waiting().await;
13531354
});
13541355
if self.config.json_response {
1355-
// JSON-direct mode: await the single response and return as
1356-
// application/json, eliminating SSE framing overhead.
1357-
// Allowed by MCP Streamable HTTP spec (2025-06-18).
1356+
// Prefer JSON for a terminal first message. If the handler
1357+
// emits an intermediate notification or request, preserve
1358+
// the complete message sequence by falling back to SSE.
13581359
let cancel = self.config.cancellation_token.child_token();
1359-
match tokio::select! {
1360+
let Some(message) = (tokio::select! {
13601361
res = receiver.recv() => res,
13611362
_ = cancel.cancelled() => None,
1362-
} {
1363-
Some(message) => {
1364-
tracing::trace!(?message);
1365-
let body = serde_json::to_vec(&message).map_err(|e| {
1366-
internal_error_response("serialize json response")(e)
1367-
})?;
1368-
Ok(Response::builder()
1369-
.status(http::StatusCode::OK)
1370-
.header(http::header::CONTENT_TYPE, JSON_MIME_TYPE)
1371-
.body(Full::new(Bytes::from(body)).boxed())
1372-
.expect("valid response"))
1373-
}
1374-
None => Err(internal_error_response("empty response")(
1363+
}) else {
1364+
return Err(internal_error_response("empty response")(
13751365
std::io::Error::new(
13761366
std::io::ErrorKind::UnexpectedEof,
13771367
"no response message received from handler",
13781368
),
1379-
)),
1369+
));
1370+
};
1371+
tracing::trace!(?message);
1372+
if matches!(
1373+
message,
1374+
ServerJsonRpcMessage::Response(_) | ServerJsonRpcMessage::Error(_)
1375+
) {
1376+
let body = serde_json::to_vec(&message).map_err(|e| {
1377+
internal_error_response("serialize json response")(e)
1378+
})?;
1379+
Ok(Response::builder()
1380+
.status(http::StatusCode::OK)
1381+
.header(http::header::CONTENT_TYPE, JSON_MIME_TYPE)
1382+
.body(Full::new(Bytes::from(body)).boxed())
1383+
.expect("valid response"))
1384+
} else {
1385+
let first = futures::stream::once(async move {
1386+
ServerSseMessage::from_message(message)
1387+
});
1388+
let remaining = ReceiverStream::new(receiver).map(|message| {
1389+
tracing::trace!(?message);
1390+
ServerSseMessage::from_message(message)
1391+
});
1392+
Ok(sse_stream_response(
1393+
first.chain(remaining),
1394+
self.config.sse_keep_alive,
1395+
self.config.cancellation_token.child_token(),
1396+
))
13801397
}
13811398
} else {
13821399
// SSE mode (default): original behaviour preserved unchanged

crates/rmcp/tests/test_streamable_http_json_response.rs

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,52 @@
11
#![cfg(not(feature = "local"))]
2-
use rmcp::transport::streamable_http_server::{
3-
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
2+
use rmcp::{
3+
ErrorData, ServerHandler,
4+
model::{
5+
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock,
6+
ProgressNotificationParam, ServerCapabilities, ServerInfo,
7+
},
8+
service::RequestContext,
9+
transport::streamable_http_server::{
10+
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
11+
},
412
};
513
use tokio_util::sync::CancellationToken;
614

715
mod common;
816
use common::calculator::Calculator;
917

1018
const INIT_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#;
19+
const CALL_WITH_PROGRESS_BODY: &str = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"progress","arguments":{},"_meta":{"progressToken":"progress-test-1"}}}"#;
20+
21+
#[derive(Clone)]
22+
struct ProgressServer;
23+
24+
impl ServerHandler for ProgressServer {
25+
fn get_info(&self) -> ServerInfo {
26+
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
27+
}
28+
29+
async fn call_tool(
30+
&self,
31+
_request: CallToolRequestParams,
32+
context: RequestContext<rmcp::RoleServer>,
33+
) -> Result<CallToolResponse, ErrorData> {
34+
let progress_token = context
35+
.meta
36+
.get_progress_token()
37+
.expect("request includes progressToken");
38+
context
39+
.peer
40+
.notify_progress(
41+
ProgressNotificationParam::new(progress_token, 50.0)
42+
.with_total(100.0)
43+
.with_message("working"),
44+
)
45+
.await
46+
.expect("progress notification is delivered");
47+
Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into())
48+
}
49+
}
1150

1251
async fn spawn_server(
1352
config: StreamableHttpServerConfig,
@@ -34,6 +73,31 @@ async fn spawn_server(
3473
(client, base_url, ct)
3574
}
3675

76+
async fn spawn_progress_server(
77+
config: StreamableHttpServerConfig,
78+
) -> (reqwest::Client, String, CancellationToken) {
79+
let ct = config.cancellation_token.clone();
80+
let service: StreamableHttpService<ProgressServer, LocalSessionManager> =
81+
StreamableHttpService::new(|| Ok(ProgressServer), Default::default(), config);
82+
83+
let router = axum::Router::new().nest_service("/mcp", service);
84+
let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
85+
let addr = tcp_listener.local_addr().unwrap();
86+
87+
tokio::spawn({
88+
let ct = ct.clone();
89+
async move {
90+
let _ = axum::serve(tcp_listener, router)
91+
.with_graceful_shutdown(async move { ct.cancelled_owned().await })
92+
.await;
93+
}
94+
});
95+
96+
let client = reqwest::Client::new();
97+
let base_url = format!("http://{addr}/mcp");
98+
(client, base_url, ct)
99+
}
100+
37101
#[tokio::test]
38102
async fn stateless_json_response_returns_application_json() -> anyhow::Result<()> {
39103
let ct = CancellationToken::new();
@@ -76,6 +140,58 @@ async fn stateless_json_response_returns_application_json() -> anyhow::Result<()
76140
Ok(())
77141
}
78142

143+
#[tokio::test]
144+
async fn stateless_json_response_falls_back_to_sse_for_progress() -> anyhow::Result<()> {
145+
let ct = CancellationToken::new();
146+
let (client, url, ct) = spawn_progress_server(
147+
StreamableHttpServerConfig::default()
148+
.with_stateful_mode(false)
149+
.with_json_response(true)
150+
.with_sse_keep_alive(None)
151+
.with_cancellation_token(ct.child_token()),
152+
)
153+
.await;
154+
155+
let response = client
156+
.post(&url)
157+
.header("Content-Type", "application/json")
158+
.header("Accept", "application/json, text/event-stream")
159+
.body(CALL_WITH_PROGRESS_BODY)
160+
.send()
161+
.await?;
162+
163+
assert_eq!(response.status(), 200);
164+
165+
let content_type = response
166+
.headers()
167+
.get("content-type")
168+
.and_then(|value| value.to_str().ok())
169+
.unwrap_or("");
170+
assert!(
171+
content_type.contains("text/event-stream"),
172+
"Expected SSE fallback, got: {content_type}"
173+
);
174+
175+
let body = response.text().await?;
176+
let messages: Vec<serde_json::Value> = body
177+
.lines()
178+
.filter_map(|line| line.strip_prefix("data:"))
179+
.map(str::trim)
180+
.filter(|data| !data.is_empty())
181+
.map(serde_json::from_str)
182+
.collect::<Result<_, _>>()?;
183+
assert_eq!(messages.len(), 2, "Expected progress and result: {body}");
184+
assert_eq!(messages[0]["method"], "notifications/progress");
185+
assert_eq!(messages[1]["id"], 2);
186+
assert!(
187+
messages[1]["result"].is_object(),
188+
"Expected result object: {body}"
189+
);
190+
191+
ct.cancel();
192+
Ok(())
193+
}
194+
79195
#[tokio::test]
80196
async fn stateless_sse_mode_default_unchanged() -> anyhow::Result<()> {
81197
let ct = CancellationToken::new();

0 commit comments

Comments
 (0)