Skip to content

Commit dbda50c

Browse files
authored
fix: don't respond to cancelled requests (#957)
* fix: don't respond to cancelled requests * chore: remove redundant comment * fix: update SSE stream constructor
1 parent 45f2f72 commit dbda50c

3 files changed

Lines changed: 218 additions & 5 deletions

File tree

crates/rmcp/src/service.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,9 +1103,11 @@ where
11031103
JsonRpcMessage::Error(error) => error.id.as_ref(),
11041104
_ => None,
11051105
} {
1106-
if let Some(ct) = local_ct_pool.remove(id) {
1107-
ct.cancel();
1108-
}
1106+
let Some(ct) = local_ct_pool.remove(id) else {
1107+
tracing::debug!(%id, "dropping response for cancelled request");
1108+
continue;
1109+
};
1110+
ct.cancel();
11091111
let send = transport.send(m);
11101112
let current_span = tracing::Span::current();
11111113
response_send_tasks.spawn(async move {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ impl StreamableHttpClient for reqwest::Client {
8484
return Err(StreamableHttpError::UnexpectedContentType(None));
8585
}
8686
}
87-
let event_stream = SseStream::from_byte_stream(response.bytes_stream()).boxed();
87+
let event_stream = SseStream::from_bytes_stream(response.bytes_stream()).boxed();
8888
Ok(event_stream)
8989
}
9090

@@ -223,7 +223,7 @@ impl StreamableHttpClient for reqwest::Client {
223223
}
224224
match content_type.as_deref() {
225225
Some(ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => {
226-
let event_stream = SseStream::from_byte_stream(response.bytes_stream()).boxed();
226+
let event_stream = SseStream::from_bytes_stream(response.bytes_stream()).boxed();
227227
Ok(StreamableHttpPostResponse::Sse(event_stream, session_id))
228228
}
229229
Some(ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => {
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
//! A receiver SHOULD NOT send a response for a request it has already been told
2+
//! to cancel. This drives a real stdio server with raw JSON-RPC: the tool blocks
3+
//! until the request is cancelled, so its result is only produced *after* the
4+
//! cancellation — the service loop must drop it rather than write it to the wire.
5+
6+
use std::{collections::BTreeSet, process::Stdio, time::Duration};
7+
8+
use rmcp::{
9+
ErrorData as McpError, RoleServer, ServerHandler, ServiceExt,
10+
model::{CallToolRequestParams, CallToolResult, ContentBlock, ServerCapabilities, ServerInfo},
11+
service::RequestContext,
12+
};
13+
use serde_json::{Value, json};
14+
use tokio::{
15+
io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader},
16+
process::{Child, Command},
17+
};
18+
19+
const HELPER_ENV: &str = "RMCP_CANCELLED_RESPONSE_HELPER";
20+
const READ_TIMEOUT: Duration = Duration::from_secs(10);
21+
22+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
23+
async fn cancelled_request_receives_no_response() -> anyhow::Result<()> {
24+
let mut child = spawn_helper();
25+
let mut writer = child.stdin.take().expect("helper stdin");
26+
let stdout = child.stdout.take().expect("helper stdout");
27+
let mut reader = BufReader::new(stdout);
28+
29+
send_json(
30+
&mut writer,
31+
&json!({
32+
"jsonrpc": "2.0",
33+
"id": 1,
34+
"method": "initialize",
35+
"params": {
36+
"protocolVersion": "2024-11-05",
37+
"capabilities": {},
38+
"clientInfo": { "name": "raw-test-client", "version": "0.0.0" }
39+
}
40+
}),
41+
)
42+
.await?;
43+
collect_ids_until(&mut reader, 1, READ_TIMEOUT).await?;
44+
send_json(
45+
&mut writer,
46+
&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }),
47+
)
48+
.await?;
49+
50+
// Start a request that blocks until cancelled, then cancel it. Its response is
51+
// produced only after the cancellation arrives, so it must be suppressed.
52+
send_json(
53+
&mut writer,
54+
&json!({
55+
"jsonrpc": "2.0",
56+
"id": 2,
57+
"method": "tools/call",
58+
"params": { "name": "wait-for-cancel", "arguments": {} }
59+
}),
60+
)
61+
.await?;
62+
send_json(
63+
&mut writer,
64+
&json!({
65+
"jsonrpc": "2.0",
66+
"method": "notifications/cancelled",
67+
"params": { "requestId": 2 }
68+
}),
69+
)
70+
.await?;
71+
// A ping proves the server is alive past the cancellation, so the absence of
72+
// an id=2 response is genuine suppression rather than a dead connection.
73+
send_json(
74+
&mut writer,
75+
&json!({ "jsonrpc": "2.0", "id": 3, "method": "ping" }),
76+
)
77+
.await?;
78+
79+
let seen = collect_ids_until(&mut reader, 3, READ_TIMEOUT).await?;
80+
assert!(seen.contains(&3));
81+
assert!(!seen.contains(&2));
82+
83+
drop(writer);
84+
wait_for_child(&mut child).await;
85+
Ok(())
86+
}
87+
88+
struct WaitForCancelServer;
89+
90+
impl ServerHandler for WaitForCancelServer {
91+
fn get_info(&self) -> ServerInfo {
92+
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
93+
}
94+
95+
async fn call_tool(
96+
&self,
97+
_request: CallToolRequestParams,
98+
context: RequestContext<RoleServer>,
99+
) -> Result<CallToolResult, McpError> {
100+
context.ct.cancelled().await;
101+
Ok(CallToolResult::success(vec![ContentBlock::text(
102+
"late response",
103+
)]))
104+
}
105+
}
106+
107+
#[tokio::test]
108+
async fn cancelled_response_helper() -> anyhow::Result<()> {
109+
if std::env::var(HELPER_ENV).as_deref() != Ok("1") {
110+
return Ok(());
111+
}
112+
run_helper_server().await?;
113+
Ok(())
114+
}
115+
116+
#[cfg(feature = "local")]
117+
async fn run_helper_server() -> anyhow::Result<()> {
118+
tokio::task::LocalSet::new()
119+
.run_until(serve_helper_stdio())
120+
.await
121+
}
122+
123+
#[cfg(not(feature = "local"))]
124+
async fn run_helper_server() -> anyhow::Result<()> {
125+
serve_helper_stdio().await
126+
}
127+
128+
async fn serve_helper_stdio() -> anyhow::Result<()> {
129+
let server = WaitForCancelServer.serve(rmcp::transport::stdio()).await?;
130+
server.waiting().await?;
131+
Ok(())
132+
}
133+
134+
fn spawn_helper() -> Child {
135+
let exe = std::env::current_exe().expect("current test exe");
136+
Command::new(exe)
137+
.arg("--exact")
138+
.arg("cancelled_response_helper")
139+
.arg("--quiet")
140+
.arg("--nocapture")
141+
.arg("--test-threads")
142+
.arg("1")
143+
.env(HELPER_ENV, "1")
144+
.stdin(Stdio::piped())
145+
.stdout(Stdio::piped())
146+
.stderr(Stdio::null())
147+
.kill_on_drop(true)
148+
.spawn()
149+
.expect("spawn helper")
150+
}
151+
152+
async fn wait_for_child(child: &mut Child) {
153+
let _ = tokio::time::timeout(Duration::from_secs(2), child.wait()).await;
154+
if child.id().is_some() {
155+
let _ = child.kill().await;
156+
}
157+
}
158+
159+
async fn send_json<W>(writer: &mut W, message: &Value) -> anyhow::Result<()>
160+
where
161+
W: AsyncWrite + Unpin,
162+
{
163+
let serialized = serde_json::to_string(message)?;
164+
writer.write_all(serialized.as_bytes()).await?;
165+
writer.write_all(b"\n").await?;
166+
writer.flush().await?;
167+
Ok(())
168+
}
169+
170+
/// Read response lines, collecting every message id seen, until `stop_id` is seen
171+
/// (then a short grace read to catch any straggler) or the timeout elapses.
172+
async fn collect_ids_until<R>(
173+
reader: &mut BufReader<R>,
174+
stop_id: u64,
175+
timeout: Duration,
176+
) -> anyhow::Result<BTreeSet<u64>>
177+
where
178+
R: tokio::io::AsyncRead + Unpin,
179+
{
180+
let mut seen = BTreeSet::new();
181+
let mut deadline = tokio::time::Instant::now() + timeout;
182+
loop {
183+
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
184+
if remaining.is_zero() {
185+
break;
186+
}
187+
let mut line = String::new();
188+
let Ok(read_result) = tokio::time::timeout(remaining, reader.read_line(&mut line)).await
189+
else {
190+
break;
191+
};
192+
if read_result? == 0 {
193+
break;
194+
}
195+
let trimmed = line.trim();
196+
if trimmed.is_empty() {
197+
continue;
198+
}
199+
let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
200+
continue;
201+
};
202+
if let Some(id) = value.get("id").and_then(Value::as_u64) {
203+
seen.insert(id);
204+
if id == stop_id {
205+
// Give any late (incorrectly-sent) response a brief window to arrive.
206+
deadline = tokio::time::Instant::now() + Duration::from_millis(300);
207+
}
208+
}
209+
}
210+
Ok(seen)
211+
}

0 commit comments

Comments
 (0)