|
| 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