Skip to content

Commit 15821dd

Browse files
committed
fix: preserve negotiated progress responses
1 parent bc2c5f3 commit 15821dd

2 files changed

Lines changed: 156 additions & 15 deletions

File tree

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -891,7 +891,13 @@ where
891891
))
892892
})?;
893893

894-
if self.config.json_response || jsonrpc_http_status(&first) != http::StatusCode::OK {
894+
let terminal = matches!(
895+
&first,
896+
ServerJsonRpcMessage::Response(_) | ServerJsonRpcMessage::Error(_)
897+
);
898+
if terminal
899+
&& (self.config.json_response || jsonrpc_http_status(&first) != http::StatusCode::OK)
900+
{
895901
// This message is the whole reply, so `receiver` is dropped here and
896902
// anything the handler emits afterwards is undeliverable. Cancel it so
897903
// a still-running handler stops instead of running on unobserved: its

crates/rmcp/tests/test_streamable_http_json_response.rs

Lines changed: 149 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,41 @@ use common::calculator::Calculator;
1717

1818
const INIT_BODY: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#;
1919
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+
const NEGOTIATED_CALL_BODY: &str = r#"{
21+
"jsonrpc": "2.0",
22+
"id": 2,
23+
"method": "tools/call",
24+
"params": {
25+
"name": "terminal",
26+
"arguments": {},
27+
"_meta": {
28+
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
29+
"io.modelcontextprotocol/clientInfo": {
30+
"name": "test",
31+
"version": "1.0"
32+
},
33+
"io.modelcontextprotocol/clientCapabilities": {}
34+
}
35+
}
36+
}"#;
37+
const NEGOTIATED_CALL_WITH_PROGRESS_BODY: &str = r#"{
38+
"jsonrpc": "2.0",
39+
"id": 2,
40+
"method": "tools/call",
41+
"params": {
42+
"name": "progress",
43+
"arguments": {},
44+
"_meta": {
45+
"progressToken": "progress-test-1",
46+
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
47+
"io.modelcontextprotocol/clientInfo": {
48+
"name": "test",
49+
"version": "1.0"
50+
},
51+
"io.modelcontextprotocol/clientCapabilities": {}
52+
}
53+
}
54+
}"#;
2055

2156
#[derive(Clone)]
2257
struct ProgressServer;
@@ -28,22 +63,24 @@ impl ServerHandler for ProgressServer {
2863

2964
async fn call_tool(
3065
&self,
31-
_request: CallToolRequestParams,
66+
request: CallToolRequestParams,
3267
context: RequestContext<rmcp::RoleServer>,
3368
) -> 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");
69+
if request.name == "progress" {
70+
let progress_token = context
71+
.meta
72+
.get_progress_token()
73+
.expect("request includes progressToken");
74+
context
75+
.peer
76+
.notify_progress(
77+
ProgressNotificationParam::new(progress_token, 50.0)
78+
.with_total(100.0)
79+
.with_message("working"),
80+
)
81+
.await
82+
.expect("progress notification is delivered");
83+
}
4784
Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into())
4885
}
4986
}
@@ -140,6 +177,49 @@ async fn stateless_json_response_returns_application_json() -> anyhow::Result<()
140177
Ok(())
141178
}
142179

180+
#[tokio::test]
181+
async fn stateless_negotiated_terminal_response_returns_application_json() -> anyhow::Result<()> {
182+
let ct = CancellationToken::new();
183+
let (client, url, ct) = spawn_progress_server(
184+
StreamableHttpServerConfig::default()
185+
.with_stateful_mode(false)
186+
.with_json_response(true)
187+
.with_sse_keep_alive(None)
188+
.with_cancellation_token(ct.child_token()),
189+
)
190+
.await;
191+
192+
let response = client
193+
.post(&url)
194+
.header("Content-Type", "application/json")
195+
.header("Accept", "application/json, text/event-stream")
196+
.header("MCP-Protocol-Version", "2026-07-28")
197+
.header("Mcp-Method", "tools/call")
198+
.header("Mcp-Name", "terminal")
199+
.body(NEGOTIATED_CALL_BODY)
200+
.send()
201+
.await?;
202+
203+
assert_eq!(response.status(), 200);
204+
205+
let content_type = response
206+
.headers()
207+
.get("content-type")
208+
.and_then(|value| value.to_str().ok())
209+
.unwrap_or("");
210+
assert!(
211+
content_type.contains("application/json"),
212+
"Expected application/json, got: {content_type}"
213+
);
214+
215+
let body: serde_json::Value = response.json().await?;
216+
assert_eq!(body["id"], 2);
217+
assert!(body["result"].is_object(), "Expected result object");
218+
219+
ct.cancel();
220+
Ok(())
221+
}
222+
143223
#[tokio::test]
144224
async fn stateless_json_response_falls_back_to_sse_for_progress() -> anyhow::Result<()> {
145225
let ct = CancellationToken::new();
@@ -192,6 +272,61 @@ async fn stateless_json_response_falls_back_to_sse_for_progress() -> anyhow::Res
192272
Ok(())
193273
}
194274

275+
#[tokio::test]
276+
async fn stateless_negotiated_json_response_falls_back_to_sse_for_progress() -> anyhow::Result<()> {
277+
let ct = CancellationToken::new();
278+
let (client, url, ct) = spawn_progress_server(
279+
StreamableHttpServerConfig::default()
280+
.with_stateful_mode(false)
281+
.with_json_response(true)
282+
.with_sse_keep_alive(None)
283+
.with_cancellation_token(ct.child_token()),
284+
)
285+
.await;
286+
287+
let response = client
288+
.post(&url)
289+
.header("Content-Type", "application/json")
290+
.header("Accept", "application/json, text/event-stream")
291+
.header("MCP-Protocol-Version", "2026-07-28")
292+
.header("Mcp-Method", "tools/call")
293+
.header("Mcp-Name", "progress")
294+
.body(NEGOTIATED_CALL_WITH_PROGRESS_BODY)
295+
.send()
296+
.await?;
297+
298+
assert_eq!(response.status(), 200);
299+
300+
let content_type = response
301+
.headers()
302+
.get("content-type")
303+
.and_then(|value| value.to_str().ok())
304+
.unwrap_or("");
305+
assert!(
306+
content_type.contains("text/event-stream"),
307+
"Expected SSE fallback, got: {content_type}"
308+
);
309+
310+
let body = response.text().await?;
311+
let messages: Vec<serde_json::Value> = body
312+
.lines()
313+
.filter_map(|line| line.strip_prefix("data:"))
314+
.map(str::trim)
315+
.filter(|data| !data.is_empty())
316+
.map(serde_json::from_str)
317+
.collect::<Result<_, _>>()?;
318+
assert_eq!(messages.len(), 2, "Expected progress and result: {body}");
319+
assert_eq!(messages[0]["method"], "notifications/progress");
320+
assert_eq!(messages[1]["id"], 2);
321+
assert!(
322+
messages[1]["result"].is_object(),
323+
"Expected result object: {body}"
324+
);
325+
326+
ct.cancel();
327+
Ok(())
328+
}
329+
195330
#[tokio::test]
196331
async fn stateless_sse_mode_default_unchanged() -> anyhow::Result<()> {
197332
let ct = CancellationToken::new();

0 commit comments

Comments
 (0)