diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 863ace29..90bedd94 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -94,6 +94,13 @@ impl Backend for Remote { format!("VectorCraft app at {}: `{method}` failed: {e}; operation may have completed; inspect state before retrying", self.addr) })?; let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?; + if v.get("id").is_none() && v.get("ok").and_then(Value::as_bool) == Some(false) { + // The app's refusals carry no id (too many connections, a line it would not read): + // it ran nothing and closes the connection, so the next call starts a fresh one. + self.conn = None; + let why = v.get("error").and_then(Value::as_str).unwrap_or("unknown error"); + return Err(format!("VectorCraft app at {} refused `{method}`: {why}; nothing was run", self.addr)); + } if v.get("id") != Some(&json!(id)) { // Not the answer to this call: the stream is desynchronised (or the endpoint faulty), // so the connection is dropped and the next call starts a fresh one. The operation may diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 7682f380..603b486c 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -542,6 +542,35 @@ fn remote_rejects_a_reply_with_a_mismatched_id_and_reconnects() { assert_ne!(seen[0]["id"], seen[1]["id"]); } +/// The app's refusals carry no id (a 17th connection gets one and is closed). The call reports the +/// app's reason, not a mismatch, says nothing ran, and the next call starts a fresh connection. +#[test] +fn remote_reports_the_reason_of_a_refusal_without_an_id() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let h = std::thread::spawn(move || { + let (mut refused, _) = listener.accept().unwrap(); + // The request is read first, so the refusal answers it and the close is orderly. + refused.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + let _ = BufReader::new(refused.try_clone().unwrap()).read_line(&mut String::new()); + writeln!(refused, "{}", json!({"ok": false, "error": "too many control connections"})).unwrap(); + drop(refused); + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + let mut out = stream.try_clone().unwrap(); + let mut line = String::new(); + BufReader::new(stream).read_line(&mut line).unwrap(); + let msg: Value = serde_json::from_str(&line).unwrap(); + writeln!(out, "{}", json!({"ok": true, "result": 7, "id": msg["id"]})).unwrap(); + }); + let mut remote = Remote::connect(&addr).unwrap(); + let err = remote.call("document.inspect", json!({})).expect_err("a refusal is an error"); + assert!(err.contains("too many control connections") && err.contains("nothing was run"), "{err}"); + assert!(!err.contains("mismatch") && !err.contains("may have completed"), "{err}"); + assert_eq!(remote.call("document.inspect", json!({})), Ok(json!(7)), "the next call reconnects"); + h.join().unwrap(); +} + #[test] fn remote_connect_fails_fast() { // A port this test holds, bound but not listening, so connecting to it is refused. (A port