Skip to main content

car_engine/
mcp.rs

1//! MCP (Model Context Protocol) server integration.
2//!
3//! Discovers tools from MCP servers via stdin/stdout JSON-RPC and registers
4//! them into the canonical tool registry. MCP tools participate in the same
5//! capability/permission/policy flow as all other tools.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{Arc, Mutex as StdMutex};
12use std::time::Duration;
13use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader};
14use tokio::process::{Child, ChildStderr};
15use tokio::sync::{oneshot, Mutex};
16
17/// Configuration for an MCP server.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct McpServerConfig {
20    /// Display name for this server.
21    pub name: String,
22    /// Command to launch the server.
23    pub command: String,
24    /// Arguments for the command.
25    #[serde(default)]
26    pub args: Vec<String>,
27    /// Environment variables.
28    #[serde(default)]
29    pub env: HashMap<String, String>,
30    /// Working directory.
31    pub cwd: Option<String>,
32}
33
34/// Map of in-flight request id → the waiter to deliver its response to.
35type Pending = Arc<StdMutex<HashMap<u64, oneshot::Sender<McpResponse>>>>;
36
37/// A running MCP server connection.
38///
39/// A background **reader task** owns stdout and demultiplexes responses by id
40/// into per-request `oneshot` channels. `send_request` writes to stdin and then
41/// awaits its channel — never `read_line` directly. So a request timeout (or any
42/// cancellation of the caller's future) only drops a receiver; the reader keeps
43/// consuming the stream and discards the now-orphaned response. This makes the
44/// transport cancel-safe by construction: there is no read to interrupt
45/// mid-line, hence no desync and no poison/recovery dance.
46pub struct McpServer {
47    config: McpServerConfig,
48    child: Child,
49    stdin: tokio::io::BufWriter<tokio::process::ChildStdin>,
50    next_id: u64,
51    pending: Pending,
52    /// Background reader task handle (aborted on reconnect/drop).
53    reader: tokio::task::JoinHandle<()>,
54    /// Background stderr-drain task (aborted on reconnect/drop). Must exist:
55    /// stderr is piped, so an undrained chatty server fills the pipe buffer and
56    /// deadlocks its own stdout writes.
57    stderr_reader: tokio::task::JoinHandle<()>,
58    /// Cleared when the reader exits (EOF / read error) — i.e. the connection is
59    /// dead. The next `send_request` reconnects.
60    alive: Arc<AtomicBool>,
61    /// Backstop timeout for awaiting any single response. Callers usually impose
62    /// their own (per-action `timeout_ms` in the executor); this bounds requests
63    /// that don't, so a silent server can't hang a call forever.
64    request_timeout: Duration,
65}
66
67impl Drop for McpServer {
68    fn drop(&mut self) {
69        // tokio doesn't kill children or abort tasks on drop by default. Do it
70        // explicitly so a dropped server (e.g. `shutdown_all` draining the map)
71        // doesn't leak the reader/stderr tasks or the child process.
72        self.reader.abort();
73        self.stderr_reader.abort();
74        let _ = self.child.start_kill();
75    }
76}
77
78/// An MCP tool discovered from a server.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct McpToolInfo {
81    pub name: String,
82    pub description: Option<String>,
83    #[serde(rename = "inputSchema")]
84    pub input_schema: Option<Value>,
85}
86
87/// Transport-agnostic MCP client session.
88///
89/// The stdio [`McpServer`] (subprocess transport) implements this
90/// directly; the HTTP-streamable session in `car-connectors` (remote
91/// connectors) implements it too. [`McpToolExecutor`] holds sessions
92/// as `dyn McpSession` so it can route a tool call to either transport
93/// without knowing which one backs a given server. This is the seam
94/// that lets remote MCP connectors reuse the exact routing, fallback,
95/// and registration machinery the stdio path already has.
96#[async_trait::async_trait]
97pub trait McpSession: Send {
98    /// Discover the tools this server exposes.
99    async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String>;
100    /// Invoke a tool by its bare (server-side) name.
101    async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, String>;
102    /// Invoke a tool, bounding the response await by `timeout` (else the
103    /// session's backstop). Default ignores `timeout` (back-compat for sessions
104    /// where calls are short); the stdio [`McpServer`] honors it so a
105    /// long-running `run_command` isn't cut off mid-execution.
106    async fn call_tool_with_timeout(
107        &mut self,
108        name: &str,
109        arguments: Value,
110        _timeout: Option<Duration>,
111    ) -> Result<Value, String> {
112        self.call_tool(name, arguments).await
113    }
114    /// The server's registered name (the routing key).
115    fn name(&self) -> &str;
116}
117
118#[async_trait::async_trait]
119impl McpSession for McpServer {
120    async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
121        McpServer::list_tools(self).await
122    }
123    async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, String> {
124        McpServer::call_tool(self, name, arguments).await
125    }
126    async fn call_tool_with_timeout(
127        &mut self,
128        name: &str,
129        arguments: Value,
130        timeout: Option<Duration>,
131    ) -> Result<Value, String> {
132        McpServer::call_tool_with_timeout(self, name, arguments, timeout).await
133    }
134    fn name(&self) -> &str {
135        McpServer::name(self)
136    }
137}
138
139/// MCP JSON-RPC request.
140#[derive(Debug, Serialize)]
141struct McpRequest {
142    jsonrpc: &'static str,
143    method: String,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    params: Option<Value>,
146    id: u64,
147}
148
149/// MCP JSON-RPC response.
150#[derive(Debug, Deserialize)]
151struct McpResponse {
152    result: Option<Value>,
153    error: Option<McpError>,
154    /// Echoed request id — used to route the response to its waiter.
155    id: Option<u64>,
156}
157
158#[derive(Debug, Deserialize)]
159struct McpError {
160    #[allow(dead_code)]
161    code: Option<i64>,
162    message: String,
163}
164
165/// Route one stdout line to its waiting request, by id. Unparseable lines and
166/// notifications (no id) are ignored; an id with no waiter (a late response from
167/// a request whose caller already gave up) is discarded — the stream stays
168/// synchronized either way. Pure over `pending`, so the demux is unit-testable
169/// without spawning a subprocess.
170fn route_line(line: &str, pending: &StdMutex<HashMap<u64, oneshot::Sender<McpResponse>>>) {
171    let resp: McpResponse = match serde_json::from_str(line) {
172        Ok(r) => r,
173        Err(_) => return, // notification / log noise / partial line — ignore
174    };
175    if let Some(id) = resp.id {
176        if let Some(tx) = pending.lock().unwrap().remove(&id) {
177            // Receiver may be gone (caller timed out / was cancelled) — fine.
178            let _ = tx.send(resp);
179        }
180        // Unknown id → orphaned/duplicate response; discard.
181    }
182}
183
184/// Background task: read newline-delimited responses and route each by id until
185/// the stream closes. On exit, mark the connection dead and drop all waiters
186/// (their `recv()` then errors out).
187async fn reader_loop<R: AsyncBufRead + Unpin>(
188    mut stdout: R,
189    pending: Pending,
190    alive: Arc<AtomicBool>,
191    server_name: String,
192) {
193    let mut line = String::new();
194    loop {
195        line.clear();
196        match stdout.read_line(&mut line).await {
197            Ok(0) | Err(_) => break, // EOF or read error → connection dead
198            Ok(_) => route_line(&line, &pending),
199        }
200    }
201    alive.store(false, Ordering::SeqCst);
202    pending.lock().unwrap().clear(); // dropping senders wakes waiters with an error
203    tracing::debug!(server = %server_name, "MCP reader exited; connection closed");
204}
205
206/// Drain the child's stderr to the log so a verbose server can't fill the pipe
207/// buffer and deadlock its own stdout writes.
208async fn stderr_drain_loop(stderr: ChildStderr, server_name: String) {
209    let mut lines = BufReader::new(stderr).lines();
210    while let Ok(Some(line)) = lines.next_line().await {
211        tracing::debug!(server = %server_name, "mcp stderr: {line}");
212    }
213}
214
215impl McpServer {
216    /// Start an MCP server and initialize the connection.
217    pub async fn start(config: McpServerConfig) -> Result<Self, String> {
218        // `program_command` routes a Windows `.cmd`/`.bat` npm shim (npx/npm/
219        // yarn — the canonical MCP launcher shape) through `cmd /C`; a bare
220        // `Command::new("npx")` fails with os error 193 on Windows.
221        let mut cmd = crate::spawn::program_command(&config.command);
222        cmd.args(&config.args)
223            .stdin(std::process::Stdio::piped())
224            .stdout(std::process::Stdio::piped())
225            .stderr(std::process::Stdio::piped());
226
227        if let Some(ref cwd) = config.cwd {
228            cmd.current_dir(cwd);
229        }
230        for (k, v) in &config.env {
231            cmd.env(k, v);
232        }
233
234        let mut child = cmd
235            .spawn()
236            .map_err(|e| format!("failed to start MCP server '{}': {}", config.name, e))?;
237
238        let stdin = child
239            .stdin
240            .take()
241            .ok_or_else(|| "MCP server has no stdin".to_string())?;
242        let stdout = child
243            .stdout
244            .take()
245            .ok_or_else(|| "MCP server has no stdout".to_string())?;
246        let stderr = child
247            .stderr
248            .take()
249            .ok_or_else(|| "MCP server has no stderr".to_string())?;
250
251        let pending: Pending = Arc::new(StdMutex::new(HashMap::new()));
252        let alive = Arc::new(AtomicBool::new(true));
253        let reader = tokio::spawn(reader_loop(
254            BufReader::new(stdout),
255            Arc::clone(&pending),
256            Arc::clone(&alive),
257            config.name.clone(),
258        ));
259        let stderr_reader = tokio::spawn(stderr_drain_loop(stderr, config.name.clone()));
260
261        let mut server = Self {
262            config,
263            child,
264            stdin: tokio::io::BufWriter::new(stdin),
265            next_id: 1,
266            pending,
267            reader,
268            stderr_reader,
269            alive,
270            // Backstop for calls with no explicit per-call timeout (list_tools,
271            // fast fs ops, connector tools). Long-running calls (run_command)
272            // pass their own timeout via call_tool_with_timeout and are not
273            // bound by this. Raised from 120s: 120 spuriously cut compute-task
274            // run_commands still executing server-side (ALE: 37 false timeouts).
275            request_timeout: Duration::from_secs(600),
276        };
277
278        // Send initialize
279        server
280            .send_request(
281                "initialize",
282                Some(serde_json::json!({
283                    "protocolVersion": "2024-11-05",
284                    "capabilities": {},
285                    "clientInfo": {
286                        "name": "car-runtime",
287                        "version": env!("CARGO_PKG_VERSION")
288                    }
289                })),
290            )
291            .await?;
292
293        // Send initialized notification (no id, per MCP spec)
294        let notification = serde_json::json!({
295            "jsonrpc": "2.0",
296            "method": "notifications/initialized"
297        });
298        let msg =
299            serde_json::to_string(&notification).map_err(|e| format!("serialize error: {e}"))?;
300        server.write_message(&msg).await?;
301
302        Ok(server)
303    }
304
305    /// Respawn the child and reader, replacing a dead session in place.
306    async fn reconnect(&mut self) -> Result<(), String> {
307        tracing::warn!(server = %self.config.name, "MCP connection closed; reconnecting (server-side state is lost)");
308        self.reader.abort(); // stop the old reader + stderr drain tasks
309        self.stderr_reader.abort();
310        let _ = self.child.kill().await;
311        // Box the recursive future to break the *type-size* recursion: start()
312        // handshakes via send_request. This cannot loop at runtime — start()
313        // builds a fresh, alive server, so its handshake never re-enters
314        // reconnect; a failing handshake propagates Err instead.
315        let fresh = Box::pin(Self::start(self.config.clone())).await?;
316        *self = fresh;
317        Ok(())
318    }
319
320    /// Write one newline-delimited JSON message to the server.
321    async fn write_message(&mut self, msg: &str) -> Result<(), String> {
322        self.stdin
323            .write_all(msg.as_bytes())
324            .await
325            .map_err(|e| format!("write to MCP server: {e}"))?;
326        self.stdin
327            .write_all(b"\n")
328            .await
329            .map_err(|e| format!("write newline: {e}"))?;
330        self.stdin
331            .flush()
332            .await
333            .map_err(|e| format!("flush: {e}"))?;
334        Ok(())
335    }
336
337    async fn send_request(&mut self, method: &str, params: Option<Value>) -> Result<Value, String> {
338        self.send_request_with_timeout(method, params, None).await
339    }
340
341    /// Like [`send_request`], but bounds the response await by `timeout` when
342    /// given (else the `request_timeout` backstop). A long-running tool call
343    /// (e.g. `run_command` with a large `timeout`) must not be cut off by the
344    /// generic backstop while the command is still executing on the server.
345    async fn send_request_with_timeout(
346        &mut self,
347        method: &str,
348        params: Option<Value>,
349        timeout: Option<Duration>,
350    ) -> Result<Value, String> {
351        let await_timeout = timeout.unwrap_or(self.request_timeout);
352        // Reconnect if the reader has exited (connection died).
353        if !self.alive.load(Ordering::SeqCst) {
354            self.reconnect().await.map_err(|e| {
355                format!(
356                    "MCP session '{}' is dead and reconnect failed: {e}",
357                    self.config.name
358                )
359            })?;
360        }
361
362        let id = self.next_id;
363        self.next_id += 1;
364
365        // Register our waiter BEFORE writing, so a fast response can't arrive
366        // before the reader knows where to route it.
367        let (tx, rx) = oneshot::channel();
368        self.pending.lock().unwrap().insert(id, tx);
369
370        let req = McpRequest {
371            jsonrpc: "2.0",
372            method: method.to_string(),
373            params,
374            id,
375        };
376        let msg = serde_json::to_string(&req).map_err(|e| format!("serialize error: {e}"))?;
377
378        if let Err(e) = self.write_message(&msg).await {
379            self.pending.lock().unwrap().remove(&id);
380            self.alive.store(false, Ordering::SeqCst); // broken pipe → dead
381            return Err(e);
382        }
383
384        // Await the channel — NOT a read. A timeout (or upstream cancellation
385        // dropping this future) just drops the receiver; the reader still
386        // consumes and discards the eventual response, so the stream never
387        // desyncs. We only clean up our pending entry on timeout.
388        let resp = match tokio::time::timeout(await_timeout, rx).await {
389            Ok(Ok(resp)) => resp,
390            Ok(Err(_)) => {
391                return Err(format!(
392                    "MCP server '{}' closed the connection",
393                    self.config.name
394                ))
395            }
396            Err(_) => {
397                self.pending.lock().unwrap().remove(&id);
398                return Err(format!("MCP request '{method}' timed out"));
399            }
400        };
401
402        if let Some(err) = resp.error {
403            return Err(format!("MCP error: {}", err.message));
404        }
405        resp.result
406            .ok_or_else(|| "MCP server returned no result".to_string())
407    }
408
409    /// Discover tools from this MCP server.
410    pub async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
411        let result = self.send_request("tools/list", None).await?;
412        let tools = result
413            .get("tools")
414            .and_then(|t| t.as_array())
415            .cloned()
416            .unwrap_or_default();
417
418        tools
419            .into_iter()
420            .map(|t| serde_json::from_value(t).map_err(|e| format!("invalid tool definition: {e}")))
421            .collect()
422    }
423
424    /// Call a tool on this MCP server.
425    pub async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, String> {
426        self.call_tool_with_timeout(name, arguments, None).await
427    }
428
429    /// Call a tool, bounding the response await by `timeout` (else the backstop).
430    /// Callers that know a tool can run long (e.g. the substrate's `run_command`
431    /// carrying its own `timeout`) pass it here so CAR doesn't abandon a call
432    /// that is still executing server-side.
433    pub async fn call_tool_with_timeout(
434        &mut self,
435        name: &str,
436        arguments: Value,
437        timeout: Option<Duration>,
438    ) -> Result<Value, String> {
439        let result = self
440            .send_request_with_timeout(
441                "tools/call",
442                Some(serde_json::json!({
443                    "name": name,
444                    "arguments": arguments,
445                })),
446                timeout,
447            )
448            .await?;
449
450        parse_tool_result(result)
451    }
452
453    /// Shut down the MCP server gracefully.
454    pub async fn shutdown(mut self) {
455        let _ = self.stdin.shutdown().await;
456        let _ = self.child.kill().await;
457        let _ = self.child.wait().await;
458    }
459
460    /// Get the server name.
461    pub fn name(&self) -> &str {
462        &self.config.name
463    }
464}
465
466/// MCP tool executor -- routes tool calls to the appropriate MCP server.
467///
468/// Servers are held as `dyn McpSession`, so a stdio subprocess
469/// ([`McpServer`]) and a remote HTTP connector (`car-connectors`)
470/// coexist in the same routing table.
471pub struct McpToolExecutor {
472    servers: Arc<Mutex<HashMap<String, Arc<Mutex<dyn McpSession>>>>>,
473    /// Maps tool_name -> server_name for routing.
474    tool_routes: Arc<Mutex<HashMap<String, String>>>,
475    /// Optional fallback for non-MCP tools.
476    fallback: Option<Arc<dyn super::ToolExecutor>>,
477}
478
479impl McpToolExecutor {
480    pub fn new() -> Self {
481        Self {
482            servers: Arc::new(Mutex::new(HashMap::new())),
483            tool_routes: Arc::new(Mutex::new(HashMap::new())),
484            fallback: None,
485        }
486    }
487
488    pub fn with_fallback(mut self, fallback: Arc<dyn super::ToolExecutor>) -> Self {
489        self.fallback = Some(fallback);
490        self
491    }
492
493    /// Add an MCP server and discover its tools.
494    /// Returns the list of discovered tool names (canonical form: `mcp_{server}_{tool}`).
495    pub async fn add_server(&self, mut server: McpServer) -> Result<Vec<String>, String> {
496        let server_name = server.config.name.clone();
497        let tools = server.list_tools().await?;
498
499        let tool_names: Vec<String> = tools
500            .iter()
501            .map(|t| format!("mcp_{}_{}", server_name, t.name))
502            .collect();
503
504        // Register tool routes
505        {
506            let mut routes = self.tool_routes.lock().await;
507            for (info, canonical_name) in tools.iter().zip(tool_names.iter()) {
508                routes.insert(canonical_name.clone(), server_name.clone());
509                // Also register the bare name for convenience
510                routes.insert(info.name.clone(), server_name.clone());
511            }
512        }
513
514        // Store server
515        let session: Arc<Mutex<dyn McpSession>> = Arc::new(Mutex::new(server));
516        self.servers.lock().await.insert(server_name, session);
517
518        Ok(tool_names)
519    }
520
521    /// Register an already-connected session under `name` **without**
522    /// adding any tool routes. Unlike [`add_server`](crate::mcp::McpToolExecutor::add_server), routes are added
523    /// explicitly via [`set_route`](crate::mcp::McpToolExecutor::set_route) so the caller can gate exactly
524    /// which of a server's tools become callable — the connector
525    /// manager uses this to keep discovered-but-not-yet-enabled tools
526    /// invisible and unroutable.
527    pub async fn add_session(&self, name: impl Into<String>, session: Arc<Mutex<dyn McpSession>>) {
528        self.servers.lock().await.insert(name.into(), session);
529    }
530
531    /// Route the canonical tool name `tool` (the namespaced name the
532    /// model sees, e.g. `mcp_github_create_issue`) to `server`. On
533    /// dispatch the bare server-side name is recovered by stripping the
534    /// `mcp_{server}_` prefix.
535    pub async fn set_route(&self, tool: impl Into<String>, server: impl Into<String>) {
536        self.tool_routes
537            .lock()
538            .await
539            .insert(tool.into(), server.into());
540    }
541
542    /// Drop the session registered under `name`, and every route that
543    /// pointed at it, only while that entry is still `session`. A
544    /// different session under the same name, registered since `session`
545    /// was, is left alone with its routes, so a cleanup that outlasts a
546    /// remove-then-add of the same name cannot take the newer one down.
547    /// Returns whether `session` was the one registered.
548    pub async fn remove_session(&self, name: &str, session: &Arc<Mutex<dyn McpSession>>) -> bool {
549        let mut servers = self.servers.lock().await;
550        if !servers
551            .get(name)
552            .is_some_and(|current| Arc::ptr_eq(current, session))
553        {
554            return false;
555        }
556        servers.remove(name);
557        // Under the server lock: a newer session cannot be registered, and
558        // so cannot have been routed, between the check and this.
559        self.tool_routes.lock().await.retain(|_, v| v != name);
560        true
561    }
562
563    /// Drop a session and every route that pointed at it.
564    pub async fn remove_server(&self, name: &str) {
565        self.servers.lock().await.remove(name);
566        self.tool_routes.lock().await.retain(|_, v| v != name);
567    }
568
569    /// Drop only the routes for `server` (e.g. when disabling its
570    /// tools) while keeping the underlying connection alive.
571    pub async fn clear_routes_for_server(&self, name: &str) {
572        self.tool_routes.lock().await.retain(|_, v| v != name);
573    }
574
575    /// Drop a single tool route by its canonical (model-visible) name,
576    /// keeping the connection and the server's other routes intact.
577    /// Used when disabling one tool of a connector.
578    pub async fn remove_route(&self, tool: &str) {
579        self.tool_routes.lock().await.remove(tool);
580    }
581
582    /// True if some connected server currently handles `tool`.
583    pub async fn handles(&self, tool: &str) -> bool {
584        self.tool_routes.lock().await.contains_key(tool)
585    }
586
587    /// Retrieve a live MCP session by its server name, if connected.
588    ///
589    /// Returns the same `Arc<Mutex<dyn McpSession>>` handle stored by
590    /// [`add_session`](crate::mcp::McpToolExecutor::add_session)/[`add_server`](crate::mcp::McpToolExecutor::add_server), so a caller (e.g. the daemon binding a
591    /// connector-driven session to an [`crate::substrate::McpSubstrate`]) can
592    /// wrap the *existing* connection rather than dialing a second one. The
593    /// substrate and the `mcp_{name}_*` routes then share one session.
594    pub async fn session(&self, name: &str) -> Option<Arc<Mutex<dyn McpSession>>> {
595        self.servers.lock().await.get(name).cloned()
596    }
597
598    /// Build a view over the same shared server/route state with a
599    /// per-caller `fallback`. The daemon keeps one shared executor for
600    /// all connectors and hands each WS session its own view whose
601    /// fallback is that session's WS tool executor.
602    pub fn share_with_fallback(&self, fallback: Arc<dyn super::ToolExecutor>) -> Self {
603        Self {
604            servers: Arc::clone(&self.servers),
605            tool_routes: Arc::clone(&self.tool_routes),
606            fallback: Some(fallback),
607        }
608    }
609
610    /// Get tool schemas from all connected MCP servers.
611    pub async fn tool_schemas(&self) -> Vec<(String, car_ir::ToolSchema)> {
612        let mut schemas = Vec::new();
613        // Snapshot the sessions and let the server lock go before any of
614        // them is asked over the network, so a slow server does not hold
615        // up registering, removing or calling every other one.
616        let servers: Vec<(String, Arc<Mutex<dyn McpSession>>)> = self
617            .servers
618            .lock()
619            .await
620            .iter()
621            .map(|(name, session)| (name.clone(), Arc::clone(session)))
622            .collect();
623        for (server_name, server) in servers.iter() {
624            let mut srv = server.lock().await;
625            if let Ok(tools) = srv.list_tools().await {
626                for tool in tools {
627                    let canonical_name = format!("mcp_{}_{}", server_name, tool.name);
628                    schemas.push((
629                        server_name.clone(),
630                        car_ir::ToolSchema {
631                            name: canonical_name,
632                            source: car_ir::ToolSourceKind::Mcp,
633                            description: tool.description.unwrap_or_default(),
634                            parameters: tool
635                                .input_schema
636                                .unwrap_or(serde_json::json!({"type": "object"})),
637                            returns: None,
638                            idempotent: false,
639                            cache_ttl_secs: None,
640                            rate_limit: None,
641                        },
642                    ));
643                }
644            }
645        }
646        schemas
647    }
648
649    /// The session `tool` routes to, with its server name. The session is
650    /// cloned out, so the server lock is not held across a call. The route
651    /// is checked again once the session is in hand: between the lookups the
652    /// server can be removed and another registered under its name, and a
653    /// call that resolved the old route must not run on the new session
654    /// unless the new one routes this tool too.
655    async fn routed_session(&self, tool: &str) -> Option<(String, Arc<Mutex<dyn McpSession>>)> {
656        let server_name = self.tool_routes.lock().await.get(tool).cloned()?;
657        let server = self.servers.lock().await.get(&server_name).cloned()?;
658        self.tool_routes
659            .lock()
660            .await
661            .get(tool)
662            .is_some_and(|routed| *routed == server_name)
663            .then_some((server_name, server))
664    }
665
666    /// Shut down all MCP servers.
667    pub async fn shutdown_all(&self) {
668        let mut servers = self.servers.lock().await;
669        // Dropping the Arc<Mutex<McpServer>> will drop the Child, killing the process.
670        servers.drain();
671    }
672}
673
674impl Default for McpToolExecutor {
675    fn default() -> Self {
676        Self::new()
677    }
678}
679
680#[async_trait::async_trait]
681impl super::ToolExecutor for McpToolExecutor {
682    async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
683        self.execute_with_action(tool, params, "", None).await
684    }
685
686    async fn execute_with_action(
687        &self,
688        tool: &str,
689        params: &Value,
690        action_id: &str,
691        timeout_ms: Option<u64>,
692    ) -> Result<Value, String> {
693        self.execute_with_action_in_session(tool, params, action_id, timeout_ms, None, 1)
694            .await
695    }
696
697    async fn execute_with_action_in_session(
698        &self,
699        tool: &str,
700        params: &Value,
701        action_id: &str,
702        timeout_ms: Option<u64>,
703        session_id: Option<&str>,
704        attempt: u32,
705    ) -> Result<Value, String> {
706        // Find which server handles this tool
707        if let Some((server_name, server)) = self.routed_session(tool).await {
708            let mut srv = server.lock().await;
709            // Strip the mcp_{server}_ prefix to get the bare tool name
710            let bare_name = tool
711                .strip_prefix(&format!("mcp_{}_", server_name))
712                .unwrap_or(tool);
713            // Honor the action's declared budget instead of discarding it
714            // (Parslee-ai/car#259): `timeout_ms` arrived here and was
715            // dropped, so an MCP call was bound only by the session's
716            // backstop and a short-budget action could never cut it off.
717            // With no declared budget we pass `None`, which is exactly the
718            // previous `call_tool` behavior (it delegates to
719            // `call_tool_with_timeout(.., None)` and falls back to the
720            // backstop).
721            return srv
722                .call_tool_with_timeout(
723                    bare_name,
724                    params.clone(),
725                    timeout_ms.map(Duration::from_millis),
726                )
727                .await;
728        }
729
730        // Fallback
731        if let Some(ref fallback) = self.fallback {
732            return fallback
733                .execute_with_action_in_session(
734                    tool, params, action_id, timeout_ms, session_id, attempt,
735                )
736                .await;
737        }
738
739        Err(format!("unknown MCP tool: '{}'", tool))
740    }
741
742    async fn execute_with_action_state_in_session(
743        &self,
744        tool: &str,
745        params: &Value,
746        action_id: &str,
747        timeout_ms: Option<u64>,
748        session_id: Option<&str>,
749        attempt: u32,
750        expected_effects: &std::collections::HashMap<String, Value>,
751        return_schema: Option<&Value>,
752    ) -> Result<super::ToolExecution, String> {
753        let routed_to_live_mcp = self.routed_session(tool).await.is_some();
754        if routed_to_live_mcp {
755            return self
756                .execute_with_action_in_session(
757                    tool, params, action_id, timeout_ms, session_id, attempt,
758                )
759                .await
760                .map(super::ToolExecution::output_only);
761        }
762        if let Some(ref fallback) = self.fallback {
763            return fallback
764                .execute_with_action_state_in_session(
765                    tool,
766                    params,
767                    action_id,
768                    timeout_ms,
769                    session_id,
770                    attempt,
771                    expected_effects,
772                    return_schema,
773                )
774                .await;
775        }
776        Err(format!("unknown MCP tool: '{}'", tool))
777    }
778
779    async fn execute_classified(
780        &self,
781        tool: &str,
782        params: &Value,
783        action_id: &str,
784        timeout_ms: Option<u64>,
785        session_id: Option<&str>,
786        attempt: u32,
787        expected_effects: &std::collections::HashMap<String, Value>,
788        return_schema: Option<&Value>,
789    ) -> Result<super::ToolExecution, car_ir::ToolFailure> {
790        // Retain the selected session. A second route lookup could race
791        // remove_route/remove_server and silently enter the legacy String
792        // fallback, erasing an explicitly terminal callback failure.
793        if let Some((server_name, server)) = self.routed_session(tool).await {
794            let bare_name = tool
795                .strip_prefix(&format!("mcp_{server_name}_"))
796                .unwrap_or(tool);
797            return server
798                .lock()
799                .await
800                .call_tool_with_timeout(
801                    bare_name,
802                    params.clone(),
803                    timeout_ms.map(Duration::from_millis),
804                )
805                .await
806                .map(super::ToolExecution::output_only)
807                // `ordinary`: the text is the MCP server's, and a server
808                // answering "unknown tool" must not reach the runtime's
809                // built-ins through a bare-name route (Parslee-ai/car#3104).
810                .map_err(car_ir::ToolFailure::ordinary);
811        }
812        if let Some(ref fallback) = self.fallback {
813            return fallback
814                .execute_classified(
815                    tool,
816                    params,
817                    action_id,
818                    timeout_ms,
819                    session_id,
820                    attempt,
821                    expected_effects,
822                    return_schema,
823                )
824                .await;
825        }
826        Err(car_ir::ToolFailure::ordinary(format!(
827            "unknown MCP tool: '{tool}'"
828        )))
829    }
830}
831
832/// Parse an MCP `tools/call` result into the joined text content (`Ok`) or a
833/// tool-level error (`Err`), honoring the spec `isError` flag.
834///
835/// MCP distinguishes two failure channels: a JSON-RPC protocol error (handled in
836/// [`McpServer::call_tool`] before this is reached) and a *tool-level* error
837/// returned as a normal response with `isError: true`. The latter MUST propagate
838/// as `Err` — otherwise a failed tool call is surfaced to the caller (and the
839/// model) as success. Regression this guards: the `vm` substrate bridge returned
840/// `isError:true` + "fetch failed" when its transport to the VM was down, and CAR
841/// handed the model `{exit_code:0, stdout:"fetch failed"}` — a dead channel
842/// reported as a successful command, burning ~20 agent turns before the model
843/// guessed something was wrong.
844fn parse_tool_result(result: Value) -> Result<Value, String> {
845    let text = result
846        .get("content")
847        .and_then(|c| c.as_array())
848        .and_then(|content| {
849            let texts: Vec<&str> = content
850                .iter()
851                .filter_map(|block| {
852                    if block.get("type").and_then(|t| t.as_str()) == Some("text") {
853                        block.get("text").and_then(|t| t.as_str())
854                    } else {
855                        None
856                    }
857                })
858                .collect();
859            if texts.is_empty() {
860                None
861            } else {
862                Some(texts.join("\n"))
863            }
864        });
865
866    if result.get("isError").and_then(|v| v.as_bool()) == Some(true) {
867        return Err(
868            text.unwrap_or_else(|| "tool returned isError with no text content".to_string())
869        );
870    }
871
872    if let Some(text) = text {
873        return Ok(Value::String(text));
874    }
875
876    Ok(result)
877}
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882    use serde_json::json;
883
884    #[test]
885    fn tool_result_iserror_true_propagates_as_err() {
886        // The exact shape the vm substrate bridge returns on a dead transport.
887        let r = json!({
888            "content": [{"type": "text", "text": "fetch failed"}],
889            "isError": true
890        });
891        assert_eq!(parse_tool_result(r), Err("fetch failed".to_string()));
892    }
893
894    #[test]
895    fn tool_result_iserror_true_without_text_still_errs() {
896        let r = json!({ "content": [], "isError": true });
897        assert!(parse_tool_result(r).is_err());
898    }
899
900    #[test]
901    fn tool_result_success_returns_joined_text() {
902        let r = json!({
903            "content": [{"type": "text", "text": "line1"}, {"type": "text", "text": "line2"}],
904            "isError": false
905        });
906        assert_eq!(
907            parse_tool_result(r),
908            Ok(Value::String("line1\nline2".to_string()))
909        );
910    }
911
912    #[test]
913    fn tool_result_no_iserror_field_is_success() {
914        // Most servers omit isError on success; absence must NOT be treated as error.
915        let r = json!({ "content": [{"type": "text", "text": "ok"}] });
916        assert_eq!(parse_tool_result(r), Ok(Value::String("ok".to_string())));
917    }
918
919    #[test]
920    fn tool_result_structured_no_text_passes_through() {
921        let r = json!({ "structuredContent": {"x": 1} });
922        assert_eq!(parse_tool_result(r.clone()), Ok(r));
923    }
924
925    fn pending() -> StdMutex<HashMap<u64, oneshot::Sender<McpResponse>>> {
926        StdMutex::new(HashMap::new())
927    }
928
929    #[tokio::test]
930    async fn routes_response_to_matching_waiter() {
931        let p = pending();
932        let (tx, rx) = oneshot::channel();
933        p.lock().unwrap().insert(7, tx);
934        route_line(r#"{"jsonrpc":"2.0","id":7,"result":{"value":42}}"#, &p);
935        let resp = rx.await.expect("waiter delivered");
936        assert!(resp.result.is_some());
937        // Entry consumed.
938        assert!(p.lock().unwrap().is_empty());
939    }
940
941    #[tokio::test]
942    async fn unknown_id_is_discarded_without_disturbing_other_waiters() {
943        let p = pending();
944        let (tx, _rx) = oneshot::channel();
945        p.lock().unwrap().insert(1, tx);
946        // A late/orphaned response for an id nobody is waiting on.
947        route_line(r#"{"jsonrpc":"2.0","id":999,"result":{}}"#, &p);
948        // The id-1 waiter is untouched — stream stays synchronized.
949        assert!(p.lock().unwrap().contains_key(&1));
950    }
951
952    #[test]
953    fn notifications_and_garbage_are_ignored() {
954        let p = pending();
955        // No panic, no routing for an id-less notification or unparseable noise.
956        route_line(
957            r#"{"jsonrpc":"2.0","method":"notifications/progress","params":{}}"#,
958            &p,
959        );
960        route_line("not json at all", &p);
961        assert!(p.lock().unwrap().is_empty());
962    }
963
964    #[tokio::test]
965    async fn error_response_is_routed_for_send_request_to_surface() {
966        let p = pending();
967        let (tx, rx) = oneshot::channel();
968        p.lock().unwrap().insert(3, tx);
969        route_line(
970            r#"{"jsonrpc":"2.0","id":3,"error":{"code":-1,"message":"tool failed"}}"#,
971            &p,
972        );
973        let resp = rx.await.unwrap();
974        assert!(resp.error.is_some());
975        assert_eq!(resp.error.unwrap().message, "tool failed");
976    }
977
978    #[tokio::test]
979    async fn reader_loop_routes_then_marks_dead_and_clears_on_eof() {
980        let pending: Pending = Arc::new(StdMutex::new(HashMap::new()));
981        let alive = Arc::new(AtomicBool::new(true));
982        let (tx, rx) = oneshot::channel();
983        // A second waiter that never gets a response — must be swept on EOF.
984        let (tx2, rx2) = oneshot::channel();
985        pending.lock().unwrap().insert(1, tx);
986        pending.lock().unwrap().insert(2, tx2);
987
988        let input = b"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n";
989        reader_loop(
990            BufReader::new(&input[..]),
991            Arc::clone(&pending),
992            Arc::clone(&alive),
993            "t".into(),
994        )
995        .await;
996
997        assert!(rx.await.unwrap().result.is_some(), "id 1 routed");
998        assert!(!alive.load(Ordering::SeqCst), "EOF marks the session dead");
999        assert!(pending.lock().unwrap().is_empty(), "waiters swept on EOF");
1000        // The unanswered waiter's receiver now errors (sender dropped).
1001        assert!(rx2.await.is_err());
1002    }
1003
1004    #[tokio::test]
1005    async fn reader_loop_skips_noise_without_desync() {
1006        // Garbage and an id-less notification precede the real reply — the reader
1007        // must still deliver id 5 (no stream desync). This is the regression the
1008        // reader-task design fixes vs. the old cancel-unsafe read_line.
1009        let pending: Pending = Arc::new(StdMutex::new(HashMap::new()));
1010        let alive = Arc::new(AtomicBool::new(true));
1011        let (tx, rx) = oneshot::channel();
1012        pending.lock().unwrap().insert(5, tx);
1013
1014        let input = b"garbage not json\n\
1015            {\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{}}\n\
1016            {\"jsonrpc\":\"2.0\",\"id\":5,\"result\":{\"done\":true}}\n";
1017        reader_loop(
1018            BufReader::new(&input[..]),
1019            Arc::clone(&pending),
1020            alive,
1021            "t".into(),
1022        )
1023        .await;
1024
1025        assert!(
1026            rx.await.unwrap().result.is_some(),
1027            "id 5 delivered past noise"
1028        );
1029    }
1030
1031    /// A session that records the `timeout` every call arrives with, so we can
1032    /// assert what the executor forwarded rather than how long a call took.
1033    struct RecordingSession {
1034        name: String,
1035        seen: Arc<StdMutex<Vec<Option<Duration>>>>,
1036    }
1037
1038    #[async_trait::async_trait]
1039    impl McpSession for RecordingSession {
1040        async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
1041            Ok(vec![])
1042        }
1043        async fn call_tool(&mut self, _name: &str, _arguments: Value) -> Result<Value, String> {
1044            // The default `call_tool_with_timeout` forwards here with the
1045            // timeout DISCARDED; recording `None` makes a regression (the
1046            // executor calling `call_tool` directly again) fail loudly.
1047            self.seen.lock().unwrap().push(None);
1048            Ok(json!({ "ok": true }))
1049        }
1050        async fn call_tool_with_timeout(
1051            &mut self,
1052            _name: &str,
1053            _arguments: Value,
1054            timeout: Option<Duration>,
1055        ) -> Result<Value, String> {
1056            self.seen.lock().unwrap().push(timeout);
1057            Ok(json!({ "ok": true }))
1058        }
1059        fn name(&self) -> &str {
1060            &self.name
1061        }
1062    }
1063
1064    /// An MCP server whose every call fails with text the server chose.
1065    struct ForgingSession;
1066
1067    #[async_trait::async_trait]
1068    impl McpSession for ForgingSession {
1069        async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
1070            Ok(vec![])
1071        }
1072        async fn call_tool(&mut self, name: &str, _arguments: Value) -> Result<Value, String> {
1073            Err(format!("unknown tool: {name}"))
1074        }
1075        fn name(&self) -> &str {
1076            "forger"
1077        }
1078    }
1079
1080    /// Parslee-ai/car#3104 sibling: an MCP server is another process, so its
1081    /// "unknown tool" error is relayed as an ordinary failure. Routed by a bare
1082    /// built-in name, it must not hand the call to the runtime's host file
1083    /// tools.
1084    #[tokio::test]
1085    async fn mcp_server_unknown_tool_text_never_runs_the_builtin() {
1086        let dir = tempfile::tempdir().unwrap();
1087        let secret = dir.path().join("host.txt");
1088        std::fs::write(&secret, "HOST-CONTENT-MCP").unwrap();
1089
1090        let exec = McpToolExecutor::new();
1091        exec.add_session("forger", Arc::new(Mutex::new(ForgingSession)))
1092            .await;
1093        exec.set_route("read_file", "forger").await;
1094        let rt = crate::Runtime::new().with_executor(Arc::new(exec));
1095        rt.register_agent_basics().await;
1096
1097        let mut action = car_ir::Action::new(car_ir::ActionType::ToolCall);
1098        action.id = "a0".into();
1099        action.tool = Some("read_file".into());
1100        action.parameters = [("path".to_string(), json!(secret))].into();
1101        let proposal = car_ir::ActionProposal {
1102            id: "p0".into(),
1103            source: "test".into(),
1104            actions: vec![action],
1105            timestamp: chrono::Utc::now(),
1106            context: HashMap::new(),
1107        };
1108        let result = rt.execute(&proposal).await;
1109
1110        assert_eq!(result.results[0].status, car_ir::ActionStatus::Failed);
1111        assert_eq!(
1112            result.results[0].error.as_deref(),
1113            Some("unknown tool: read_file")
1114        );
1115        assert!(
1116            result.results[0].output.is_none(),
1117            "{:?}",
1118            result.results[0]
1119        );
1120    }
1121
1122    struct TerminalFallback;
1123
1124    #[async_trait::async_trait]
1125    impl crate::ToolExecutor for TerminalFallback {
1126        async fn execute(&self, _tool: &str, _params: &Value) -> Result<Value, String> {
1127            Err("terminal callback".into())
1128        }
1129
1130        async fn execute_classified(
1131            &self,
1132            _tool: &str,
1133            _params: &Value,
1134            _action_id: &str,
1135            _timeout_ms: Option<u64>,
1136            _session_id: Option<&str>,
1137            _attempt: u32,
1138            _expected_effects: &HashMap<String, Value>,
1139            _return_schema: Option<&Value>,
1140        ) -> Result<crate::ToolExecution, car_ir::ToolFailure> {
1141            Err(car_ir::ToolFailure::terminal("terminal callback"))
1142        }
1143    }
1144
1145    #[tokio::test]
1146    async fn classified_dispatch_survives_route_removal_without_erasing_terminal_failure() {
1147        use crate::ToolExecutor;
1148        use std::task::Poll;
1149
1150        let seen = Arc::new(StdMutex::new(Vec::new()));
1151        let exec = McpToolExecutor::new().with_fallback(Arc::new(TerminalFallback));
1152        exec.add_session(
1153            "srv",
1154            Arc::new(Mutex::new(RecordingSession {
1155                name: "srv".into(),
1156                seen: Arc::clone(&seen),
1157            })),
1158        )
1159        .await;
1160        exec.set_route("mcp_srv_run", "srv").await;
1161        let params = json!({});
1162        let effects = HashMap::new();
1163        let servers = exec.servers.lock().await;
1164        let mut call = Box::pin(exec.execute_classified(
1165            "mcp_srv_run",
1166            &params,
1167            "a0",
1168            Some(321),
1169            None,
1170            0,
1171            &effects,
1172            None,
1173        ));
1174        // Advance past route selection, stopping precisely at the server lookup.
1175        assert!(futures::poll!(call.as_mut()).is_pending());
1176        let mut routes = exec.tool_routes.lock().await;
1177        drop(servers);
1178        // A legacy second route lookup blocks here. The retained session can finish.
1179        let outcome = futures::poll!(call.as_mut());
1180        routes.remove("mcp_srv_run");
1181        drop(routes);
1182        let result = match outcome {
1183            Poll::Ready(result) => result,
1184            Poll::Pending => call.await,
1185        };
1186        match result {
1187            Ok(_) => assert_eq!(
1188                *seen.lock().unwrap(),
1189                vec![Some(Duration::from_millis(321))]
1190            ),
1191            Err(failure) => assert_eq!(failure, car_ir::ToolFailure::terminal("terminal callback")),
1192        }
1193        // Once the route is gone, the classified fallback must remain terminal.
1194        let failure = exec
1195            .execute_classified("mcp_srv_run", &params, "a1", None, None, 0, &effects, None)
1196            .await
1197            .unwrap_err();
1198        assert_eq!(failure, car_ir::ToolFailure::terminal("terminal callback"));
1199    }
1200
1201    /// #259: the local-server branch of `execute_with_action_in_session` used to
1202    /// `call_tool(..)` and drop the per-action `timeout_ms` on the floor, so a
1203    /// declared budget never reached the MCP transport (only the 600s backstop
1204    /// bounded the call). It must now route through `call_tool_with_timeout`
1205    /// carrying the budget — and pass `None` when the action declared none, which
1206    /// is exactly the old `call_tool` behavior.
1207    #[tokio::test]
1208    async fn action_budget_reaches_the_mcp_session() {
1209        use crate::ToolExecutor;
1210
1211        let seen = Arc::new(StdMutex::new(Vec::new()));
1212        let exec = McpToolExecutor::new();
1213        exec.add_session(
1214            "srv",
1215            Arc::new(Mutex::new(RecordingSession {
1216                name: "srv".to_string(),
1217                seen: Arc::clone(&seen),
1218            })),
1219        )
1220        .await;
1221        exec.set_route("mcp_srv_run", "srv").await;
1222
1223        // A declared budget must arrive at the session as a Duration.
1224        exec.execute_with_action("mcp_srv_run", &json!({}), "a0", Some(90_000))
1225            .await
1226            .expect("call succeeds");
1227        // No budget declared: `None`, i.e. the session's own backstop applies.
1228        exec.execute("mcp_srv_run", &json!({}))
1229            .await
1230            .expect("call succeeds");
1231
1232        assert_eq!(
1233            *seen.lock().unwrap(),
1234            vec![Some(Duration::from_millis(90_000)), None],
1235            "the action budget must reach the session, not be discarded"
1236        );
1237    }
1238
1239    fn recording(name: &str) -> Arc<Mutex<dyn McpSession>> {
1240        Arc::new(Mutex::new(RecordingSession {
1241            name: name.to_string(),
1242            seen: Arc::new(StdMutex::new(Vec::new())),
1243        }))
1244    }
1245
1246    /// #2950: a cleanup scheduled for one session must not remove a newer
1247    /// session registered under the same name since, nor its routes; the
1248    /// newer session's own cleanup still removes it.
1249    #[tokio::test]
1250    async fn removing_a_replaced_session_leaves_its_replacement_routed() {
1251        use crate::ToolExecutor;
1252
1253        let exec = McpToolExecutor::new();
1254        let old = recording("demo");
1255        exec.add_session("demo", Arc::clone(&old)).await;
1256        exec.set_route("mcp_demo_run", "demo").await;
1257        let replacement = recording("demo");
1258        exec.add_session("demo", Arc::clone(&replacement)).await;
1259        exec.set_route("mcp_demo_run", "demo").await;
1260
1261        assert!(!exec.remove_session("demo", &old).await);
1262        let current = exec.session("demo").await.expect("replacement kept");
1263        assert!(Arc::ptr_eq(&current, &replacement));
1264        exec.execute("mcp_demo_run", &json!({}))
1265            .await
1266            .expect("the replacement still runs its tool");
1267
1268        assert!(exec.remove_session("demo", &replacement).await);
1269        assert!(exec.session("demo").await.is_none());
1270        assert!(!exec.handles("mcp_demo_run").await);
1271    }
1272
1273    /// A cleanup whose session is still the registered one removes it and
1274    /// every route to it, and leaves other servers' routes alone.
1275    #[tokio::test]
1276    async fn removing_the_registered_session_drops_it_and_only_its_routes() {
1277        let exec = McpToolExecutor::new();
1278        let demo = recording("demo");
1279        exec.add_session("demo", Arc::clone(&demo)).await;
1280        exec.set_route("mcp_demo_run", "demo").await;
1281        exec.add_session("other", recording("other")).await;
1282        exec.set_route("mcp_other_run", "other").await;
1283
1284        assert!(exec.remove_session("demo", &demo).await);
1285        assert!(exec.session("demo").await.is_none());
1286        assert!(!exec.handles("mcp_demo_run").await);
1287        assert!(exec.handles("mcp_other_run").await);
1288    }
1289
1290    /// A session that says when it is asked something (`entered`), and
1291    /// answers nothing until `released` turns true.
1292    struct Stalled {
1293        entered: tokio::sync::watch::Sender<bool>,
1294        released: tokio::sync::watch::Receiver<bool>,
1295    }
1296
1297    impl Stalled {
1298        async fn stall(&mut self) {
1299            self.entered.send_replace(true);
1300            let _ = self.released.wait_for(|released| *released).await;
1301        }
1302    }
1303
1304    #[async_trait::async_trait]
1305    impl McpSession for Stalled {
1306        async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
1307            self.stall().await;
1308            Ok(vec![])
1309        }
1310        async fn call_tool(&mut self, _name: &str, _arguments: Value) -> Result<Value, String> {
1311            self.stall().await;
1312            Ok(json!({}))
1313        }
1314        fn name(&self) -> &str {
1315            "slow"
1316        }
1317    }
1318
1319    /// #2950: a call that resolved a route before the server was removed,
1320    /// and another registered under the same name without routing this
1321    /// tool, must not run on the new session.
1322    #[tokio::test]
1323    async fn a_call_that_resolved_a_removed_servers_route_does_not_run_on_its_replacement() {
1324        use crate::ToolExecutor;
1325
1326        let exec = McpToolExecutor::new();
1327        exec.add_session("demo", recording("old")).await;
1328        exec.set_route("mcp_demo_run", "demo").await;
1329        let replacement_calls = Arc::new(StdMutex::new(Vec::new()));
1330        let replacement: Arc<Mutex<dyn McpSession>> = Arc::new(Mutex::new(RecordingSession {
1331            name: "new".into(),
1332            seen: Arc::clone(&replacement_calls),
1333        }));
1334
1335        let mut servers = exec.servers.lock().await;
1336        let params = json!({});
1337        let mut call = Box::pin(exec.execute("mcp_demo_run", &params));
1338        // The call has read the route and waits for the server table.
1339        assert!(futures::poll!(call.as_mut()).is_pending());
1340        exec.tool_routes.lock().await.remove("mcp_demo_run");
1341        servers.insert("demo".to_string(), replacement);
1342        drop(servers);
1343        let result = call.await;
1344
1345        assert!(result.is_err(), "{result:?}");
1346        assert!(
1347            replacement_calls.lock().unwrap().is_empty(),
1348            "the call ran on the replacement session"
1349        );
1350    }
1351
1352    /// Whether `fast` can be registered and called while `in_flight` (a tool
1353    /// call or a listing on a stalled server) waits on the network.
1354    async fn another_server_works_while<F, T>(in_flight: F) -> bool
1355    where
1356        F: FnOnce(Arc<McpToolExecutor>) -> T,
1357        T: std::future::Future + Send + 'static,
1358        T::Output: Send + 'static,
1359    {
1360        use crate::ToolExecutor;
1361
1362        let (release, released) = tokio::sync::watch::channel(false);
1363        let (entered, mut asked) = tokio::sync::watch::channel(false);
1364        let exec = Arc::new(McpToolExecutor::new());
1365        exec.add_session("slow", Arc::new(Mutex::new(Stalled { entered, released })))
1366            .await;
1367        exec.set_route("mcp_slow_run", "slow").await;
1368        let waiting = tokio::spawn(in_flight(Arc::clone(&exec)));
1369        // Wait until the slow server is being asked, so the in-flight work
1370        // holds whatever it holds across the network.
1371        let _ = asked.wait_for(|asked| *asked).await;
1372
1373        let unblocked = tokio::time::timeout(crate::LOADED_WAIT, async {
1374            exec.add_session("fast", recording("fast")).await;
1375            exec.set_route("mcp_fast_run", "fast").await;
1376            exec.execute("mcp_fast_run", &json!({})).await
1377        })
1378        .await;
1379        release.send(true).unwrap();
1380        let _ = waiting.await;
1381        matches!(unblocked, Ok(Ok(_)))
1382    }
1383
1384    /// A tool call or a listing waiting on one server must not hold the
1385    /// server table, so another server can be registered and called meanwhile.
1386    #[tokio::test]
1387    async fn a_slow_server_does_not_hold_up_another() {
1388        use crate::ToolExecutor;
1389
1390        assert!(
1391            another_server_works_while(|exec| async move {
1392                exec.execute("mcp_slow_run", &json!({})).await
1393            })
1394            .await,
1395            "a tool call in flight held the server table"
1396        );
1397        assert!(
1398            another_server_works_while(|exec| async move { exec.tool_schemas().await }).await,
1399            "a listing in flight held the server table"
1400        );
1401    }
1402}