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 a session and every route that pointed at it.
543    pub async fn remove_server(&self, name: &str) {
544        self.servers.lock().await.remove(name);
545        self.tool_routes.lock().await.retain(|_, v| v != name);
546    }
547
548    /// Drop only the routes for `server` (e.g. when disabling its
549    /// tools) while keeping the underlying connection alive.
550    pub async fn clear_routes_for_server(&self, name: &str) {
551        self.tool_routes.lock().await.retain(|_, v| v != name);
552    }
553
554    /// Drop a single tool route by its canonical (model-visible) name,
555    /// keeping the connection and the server's other routes intact.
556    /// Used when disabling one tool of a connector.
557    pub async fn remove_route(&self, tool: &str) {
558        self.tool_routes.lock().await.remove(tool);
559    }
560
561    /// True if some connected server currently handles `tool`.
562    pub async fn handles(&self, tool: &str) -> bool {
563        self.tool_routes.lock().await.contains_key(tool)
564    }
565
566    /// Retrieve a live MCP session by its server name, if connected.
567    ///
568    /// Returns the same `Arc<Mutex<dyn McpSession>>` handle stored by
569    /// [`add_session`](crate::mcp::McpToolExecutor::add_session)/[`add_server`](crate::mcp::McpToolExecutor::add_server), so a caller (e.g. the daemon binding a
570    /// connector-driven session to an [`crate::substrate::McpSubstrate`]) can
571    /// wrap the *existing* connection rather than dialing a second one. The
572    /// substrate and the `mcp_{name}_*` routes then share one session.
573    pub async fn session(&self, name: &str) -> Option<Arc<Mutex<dyn McpSession>>> {
574        self.servers.lock().await.get(name).cloned()
575    }
576
577    /// Build a view over the same shared server/route state with a
578    /// per-caller `fallback`. The daemon keeps one shared executor for
579    /// all connectors and hands each WS session its own view whose
580    /// fallback is that session's WS tool executor.
581    pub fn share_with_fallback(&self, fallback: Arc<dyn super::ToolExecutor>) -> Self {
582        Self {
583            servers: Arc::clone(&self.servers),
584            tool_routes: Arc::clone(&self.tool_routes),
585            fallback: Some(fallback),
586        }
587    }
588
589    /// Get tool schemas from all connected MCP servers.
590    pub async fn tool_schemas(&self) -> Vec<(String, car_ir::ToolSchema)> {
591        let mut schemas = Vec::new();
592        let servers = self.servers.lock().await;
593        for (server_name, server) in servers.iter() {
594            let mut srv = server.lock().await;
595            if let Ok(tools) = srv.list_tools().await {
596                for tool in tools {
597                    let canonical_name = format!("mcp_{}_{}", server_name, tool.name);
598                    schemas.push((
599                        server_name.clone(),
600                        car_ir::ToolSchema {
601                            name: canonical_name,
602                            source: car_ir::ToolSourceKind::Mcp,
603                            description: tool.description.unwrap_or_default(),
604                            parameters: tool
605                                .input_schema
606                                .unwrap_or(serde_json::json!({"type": "object"})),
607                            returns: None,
608                            idempotent: false,
609                            cache_ttl_secs: None,
610                            rate_limit: None,
611                        },
612                    ));
613                }
614            }
615        }
616        schemas
617    }
618
619    /// Shut down all MCP servers.
620    pub async fn shutdown_all(&self) {
621        let mut servers = self.servers.lock().await;
622        // Dropping the Arc<Mutex<McpServer>> will drop the Child, killing the process.
623        servers.drain();
624    }
625}
626
627impl Default for McpToolExecutor {
628    fn default() -> Self {
629        Self::new()
630    }
631}
632
633#[async_trait::async_trait]
634impl super::ToolExecutor for McpToolExecutor {
635    async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
636        self.execute_with_action(tool, params, "", None).await
637    }
638
639    async fn execute_with_action(
640        &self,
641        tool: &str,
642        params: &Value,
643        action_id: &str,
644        timeout_ms: Option<u64>,
645    ) -> Result<Value, String> {
646        self.execute_with_action_in_session(tool, params, action_id, timeout_ms, None, 1)
647            .await
648    }
649
650    async fn execute_with_action_in_session(
651        &self,
652        tool: &str,
653        params: &Value,
654        action_id: &str,
655        timeout_ms: Option<u64>,
656        session_id: Option<&str>,
657        attempt: u32,
658    ) -> Result<Value, String> {
659        // Find which server handles this tool
660        let server_name = {
661            let routes = self.tool_routes.lock().await;
662            routes.get(tool).cloned()
663        };
664
665        if let Some(server_name) = server_name {
666            let servers = self.servers.lock().await;
667            if let Some(server) = servers.get(&server_name) {
668                let mut srv = server.lock().await;
669                // Strip the mcp_{server}_ prefix to get the bare tool name
670                let bare_name = tool
671                    .strip_prefix(&format!("mcp_{}_", server_name))
672                    .unwrap_or(tool);
673                // Honor the action's declared budget instead of discarding it
674                // (Parslee-ai/car#259): `timeout_ms` arrived here and was
675                // dropped, so an MCP call was bound only by the session's
676                // backstop and a short-budget action could never cut it off.
677                // With no declared budget we pass `None`, which is exactly the
678                // previous `call_tool` behavior (it delegates to
679                // `call_tool_with_timeout(.., None)` and falls back to the
680                // backstop).
681                return srv
682                    .call_tool_with_timeout(
683                        bare_name,
684                        params.clone(),
685                        timeout_ms.map(Duration::from_millis),
686                    )
687                    .await;
688            }
689        }
690
691        // Fallback
692        if let Some(ref fallback) = self.fallback {
693            return fallback
694                .execute_with_action_in_session(
695                    tool, params, action_id, timeout_ms, session_id, attempt,
696                )
697                .await;
698        }
699
700        Err(format!("unknown MCP tool: '{}'", tool))
701    }
702
703    async fn execute_with_action_state_in_session(
704        &self,
705        tool: &str,
706        params: &Value,
707        action_id: &str,
708        timeout_ms: Option<u64>,
709        session_id: Option<&str>,
710        attempt: u32,
711        expected_effects: &std::collections::HashMap<String, Value>,
712        return_schema: Option<&Value>,
713    ) -> Result<super::ToolExecution, String> {
714        let server_name = {
715            let routes = self.tool_routes.lock().await;
716            routes.get(tool).cloned()
717        };
718        let routed_to_live_mcp = if let Some(server_name) = server_name.as_deref() {
719            self.servers.lock().await.contains_key(server_name)
720        } else {
721            false
722        };
723        if routed_to_live_mcp {
724            return self
725                .execute_with_action_in_session(
726                    tool, params, action_id, timeout_ms, session_id, attempt,
727                )
728                .await
729                .map(super::ToolExecution::output_only);
730        }
731        if let Some(ref fallback) = self.fallback {
732            return fallback
733                .execute_with_action_state_in_session(
734                    tool,
735                    params,
736                    action_id,
737                    timeout_ms,
738                    session_id,
739                    attempt,
740                    expected_effects,
741                    return_schema,
742                )
743                .await;
744        }
745        Err(format!("unknown MCP tool: '{}'", tool))
746    }
747
748    async fn execute_classified(
749        &self,
750        tool: &str,
751        params: &Value,
752        action_id: &str,
753        timeout_ms: Option<u64>,
754        session_id: Option<&str>,
755        attempt: u32,
756        expected_effects: &std::collections::HashMap<String, Value>,
757        return_schema: Option<&Value>,
758    ) -> Result<super::ToolExecution, car_ir::ToolFailure> {
759        let server_name = {
760            let routes = self.tool_routes.lock().await;
761            routes.get(tool).cloned()
762        };
763        // Retain the selected session. A second route lookup could race
764        // remove_route/remove_server and silently enter the legacy String
765        // fallback, erasing an explicitly terminal callback failure.
766        if let Some(server_name) = server_name {
767            let server = self.servers.lock().await.get(&server_name).cloned();
768            if let Some(server) = server {
769                let bare_name = tool
770                    .strip_prefix(&format!("mcp_{server_name}_"))
771                    .unwrap_or(tool);
772                return server
773                    .lock()
774                    .await
775                    .call_tool_with_timeout(
776                        bare_name,
777                        params.clone(),
778                        timeout_ms.map(Duration::from_millis),
779                    )
780                    .await
781                    .map(super::ToolExecution::output_only)
782                    .map_err(car_ir::ToolFailure::ordinary);
783            }
784        }
785        if let Some(ref fallback) = self.fallback {
786            return fallback
787                .execute_classified(
788                    tool,
789                    params,
790                    action_id,
791                    timeout_ms,
792                    session_id,
793                    attempt,
794                    expected_effects,
795                    return_schema,
796                )
797                .await;
798        }
799        Err(car_ir::ToolFailure::ordinary(format!(
800            "unknown MCP tool: '{tool}'"
801        )))
802    }
803}
804
805/// Parse an MCP `tools/call` result into the joined text content (`Ok`) or a
806/// tool-level error (`Err`), honoring the spec `isError` flag.
807///
808/// MCP distinguishes two failure channels: a JSON-RPC protocol error (handled in
809/// [`McpServer::call_tool`] before this is reached) and a *tool-level* error
810/// returned as a normal response with `isError: true`. The latter MUST propagate
811/// as `Err` — otherwise a failed tool call is surfaced to the caller (and the
812/// model) as success. Regression this guards: the `vm` substrate bridge returned
813/// `isError:true` + "fetch failed" when its transport to the VM was down, and CAR
814/// handed the model `{exit_code:0, stdout:"fetch failed"}` — a dead channel
815/// reported as a successful command, burning ~20 agent turns before the model
816/// guessed something was wrong.
817fn parse_tool_result(result: Value) -> Result<Value, String> {
818    let text = result
819        .get("content")
820        .and_then(|c| c.as_array())
821        .and_then(|content| {
822            let texts: Vec<&str> = content
823                .iter()
824                .filter_map(|block| {
825                    if block.get("type").and_then(|t| t.as_str()) == Some("text") {
826                        block.get("text").and_then(|t| t.as_str())
827                    } else {
828                        None
829                    }
830                })
831                .collect();
832            if texts.is_empty() {
833                None
834            } else {
835                Some(texts.join("\n"))
836            }
837        });
838
839    if result.get("isError").and_then(|v| v.as_bool()) == Some(true) {
840        return Err(
841            text.unwrap_or_else(|| "tool returned isError with no text content".to_string())
842        );
843    }
844
845    if let Some(text) = text {
846        return Ok(Value::String(text));
847    }
848
849    Ok(result)
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855    use serde_json::json;
856
857    #[test]
858    fn tool_result_iserror_true_propagates_as_err() {
859        // The exact shape the vm substrate bridge returns on a dead transport.
860        let r = json!({
861            "content": [{"type": "text", "text": "fetch failed"}],
862            "isError": true
863        });
864        assert_eq!(parse_tool_result(r), Err("fetch failed".to_string()));
865    }
866
867    #[test]
868    fn tool_result_iserror_true_without_text_still_errs() {
869        let r = json!({ "content": [], "isError": true });
870        assert!(parse_tool_result(r).is_err());
871    }
872
873    #[test]
874    fn tool_result_success_returns_joined_text() {
875        let r = json!({
876            "content": [{"type": "text", "text": "line1"}, {"type": "text", "text": "line2"}],
877            "isError": false
878        });
879        assert_eq!(
880            parse_tool_result(r),
881            Ok(Value::String("line1\nline2".to_string()))
882        );
883    }
884
885    #[test]
886    fn tool_result_no_iserror_field_is_success() {
887        // Most servers omit isError on success; absence must NOT be treated as error.
888        let r = json!({ "content": [{"type": "text", "text": "ok"}] });
889        assert_eq!(parse_tool_result(r), Ok(Value::String("ok".to_string())));
890    }
891
892    #[test]
893    fn tool_result_structured_no_text_passes_through() {
894        let r = json!({ "structuredContent": {"x": 1} });
895        assert_eq!(parse_tool_result(r.clone()), Ok(r));
896    }
897
898    fn pending() -> StdMutex<HashMap<u64, oneshot::Sender<McpResponse>>> {
899        StdMutex::new(HashMap::new())
900    }
901
902    #[tokio::test]
903    async fn routes_response_to_matching_waiter() {
904        let p = pending();
905        let (tx, rx) = oneshot::channel();
906        p.lock().unwrap().insert(7, tx);
907        route_line(r#"{"jsonrpc":"2.0","id":7,"result":{"value":42}}"#, &p);
908        let resp = rx.await.expect("waiter delivered");
909        assert!(resp.result.is_some());
910        // Entry consumed.
911        assert!(p.lock().unwrap().is_empty());
912    }
913
914    #[tokio::test]
915    async fn unknown_id_is_discarded_without_disturbing_other_waiters() {
916        let p = pending();
917        let (tx, _rx) = oneshot::channel();
918        p.lock().unwrap().insert(1, tx);
919        // A late/orphaned response for an id nobody is waiting on.
920        route_line(r#"{"jsonrpc":"2.0","id":999,"result":{}}"#, &p);
921        // The id-1 waiter is untouched — stream stays synchronized.
922        assert!(p.lock().unwrap().contains_key(&1));
923    }
924
925    #[test]
926    fn notifications_and_garbage_are_ignored() {
927        let p = pending();
928        // No panic, no routing for an id-less notification or unparseable noise.
929        route_line(
930            r#"{"jsonrpc":"2.0","method":"notifications/progress","params":{}}"#,
931            &p,
932        );
933        route_line("not json at all", &p);
934        assert!(p.lock().unwrap().is_empty());
935    }
936
937    #[tokio::test]
938    async fn error_response_is_routed_for_send_request_to_surface() {
939        let p = pending();
940        let (tx, rx) = oneshot::channel();
941        p.lock().unwrap().insert(3, tx);
942        route_line(
943            r#"{"jsonrpc":"2.0","id":3,"error":{"code":-1,"message":"tool failed"}}"#,
944            &p,
945        );
946        let resp = rx.await.unwrap();
947        assert!(resp.error.is_some());
948        assert_eq!(resp.error.unwrap().message, "tool failed");
949    }
950
951    #[tokio::test]
952    async fn reader_loop_routes_then_marks_dead_and_clears_on_eof() {
953        let pending: Pending = Arc::new(StdMutex::new(HashMap::new()));
954        let alive = Arc::new(AtomicBool::new(true));
955        let (tx, rx) = oneshot::channel();
956        // A second waiter that never gets a response — must be swept on EOF.
957        let (tx2, rx2) = oneshot::channel();
958        pending.lock().unwrap().insert(1, tx);
959        pending.lock().unwrap().insert(2, tx2);
960
961        let input = b"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n";
962        reader_loop(
963            BufReader::new(&input[..]),
964            Arc::clone(&pending),
965            Arc::clone(&alive),
966            "t".into(),
967        )
968        .await;
969
970        assert!(rx.await.unwrap().result.is_some(), "id 1 routed");
971        assert!(!alive.load(Ordering::SeqCst), "EOF marks the session dead");
972        assert!(pending.lock().unwrap().is_empty(), "waiters swept on EOF");
973        // The unanswered waiter's receiver now errors (sender dropped).
974        assert!(rx2.await.is_err());
975    }
976
977    #[tokio::test]
978    async fn reader_loop_skips_noise_without_desync() {
979        // Garbage and an id-less notification precede the real reply — the reader
980        // must still deliver id 5 (no stream desync). This is the regression the
981        // reader-task design fixes vs. the old cancel-unsafe read_line.
982        let pending: Pending = Arc::new(StdMutex::new(HashMap::new()));
983        let alive = Arc::new(AtomicBool::new(true));
984        let (tx, rx) = oneshot::channel();
985        pending.lock().unwrap().insert(5, tx);
986
987        let input = b"garbage not json\n\
988            {\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{}}\n\
989            {\"jsonrpc\":\"2.0\",\"id\":5,\"result\":{\"done\":true}}\n";
990        reader_loop(
991            BufReader::new(&input[..]),
992            Arc::clone(&pending),
993            alive,
994            "t".into(),
995        )
996        .await;
997
998        assert!(
999            rx.await.unwrap().result.is_some(),
1000            "id 5 delivered past noise"
1001        );
1002    }
1003
1004    /// A session that records the `timeout` every call arrives with, so we can
1005    /// assert what the executor forwarded rather than how long a call took.
1006    struct RecordingSession {
1007        name: String,
1008        seen: Arc<StdMutex<Vec<Option<Duration>>>>,
1009    }
1010
1011    #[async_trait::async_trait]
1012    impl McpSession for RecordingSession {
1013        async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
1014            Ok(vec![])
1015        }
1016        async fn call_tool(&mut self, _name: &str, _arguments: Value) -> Result<Value, String> {
1017            // The default `call_tool_with_timeout` forwards here with the
1018            // timeout DISCARDED; recording `None` makes a regression (the
1019            // executor calling `call_tool` directly again) fail loudly.
1020            self.seen.lock().unwrap().push(None);
1021            Ok(json!({ "ok": true }))
1022        }
1023        async fn call_tool_with_timeout(
1024            &mut self,
1025            _name: &str,
1026            _arguments: Value,
1027            timeout: Option<Duration>,
1028        ) -> Result<Value, String> {
1029            self.seen.lock().unwrap().push(timeout);
1030            Ok(json!({ "ok": true }))
1031        }
1032        fn name(&self) -> &str {
1033            &self.name
1034        }
1035    }
1036
1037    struct TerminalFallback;
1038
1039    #[async_trait::async_trait]
1040    impl crate::ToolExecutor for TerminalFallback {
1041        async fn execute(&self, _tool: &str, _params: &Value) -> Result<Value, String> {
1042            Err("terminal callback".into())
1043        }
1044
1045        async fn execute_classified(
1046            &self,
1047            _tool: &str,
1048            _params: &Value,
1049            _action_id: &str,
1050            _timeout_ms: Option<u64>,
1051            _session_id: Option<&str>,
1052            _attempt: u32,
1053            _expected_effects: &HashMap<String, Value>,
1054            _return_schema: Option<&Value>,
1055        ) -> Result<crate::ToolExecution, car_ir::ToolFailure> {
1056            Err(car_ir::ToolFailure::terminal("terminal callback"))
1057        }
1058    }
1059
1060    #[tokio::test]
1061    async fn classified_dispatch_survives_route_removal_without_erasing_terminal_failure() {
1062        use crate::ToolExecutor;
1063        use std::task::Poll;
1064
1065        let seen = Arc::new(StdMutex::new(Vec::new()));
1066        let exec = McpToolExecutor::new().with_fallback(Arc::new(TerminalFallback));
1067        exec.add_session(
1068            "srv",
1069            Arc::new(Mutex::new(RecordingSession {
1070                name: "srv".into(),
1071                seen: Arc::clone(&seen),
1072            })),
1073        )
1074        .await;
1075        exec.set_route("mcp_srv_run", "srv").await;
1076        let params = json!({});
1077        let effects = HashMap::new();
1078        let servers = exec.servers.lock().await;
1079        let mut call = Box::pin(exec.execute_classified(
1080            "mcp_srv_run",
1081            &params,
1082            "a0",
1083            Some(321),
1084            None,
1085            0,
1086            &effects,
1087            None,
1088        ));
1089        // Advance past route selection, stopping precisely at the server lookup.
1090        assert!(futures::poll!(call.as_mut()).is_pending());
1091        let mut routes = exec.tool_routes.lock().await;
1092        drop(servers);
1093        // A legacy second route lookup blocks here. The retained session can finish.
1094        let outcome = futures::poll!(call.as_mut());
1095        routes.remove("mcp_srv_run");
1096        drop(routes);
1097        let result = match outcome {
1098            Poll::Ready(result) => result,
1099            Poll::Pending => call.await,
1100        };
1101        match result {
1102            Ok(_) => assert_eq!(
1103                *seen.lock().unwrap(),
1104                vec![Some(Duration::from_millis(321))]
1105            ),
1106            Err(failure) => assert_eq!(failure, car_ir::ToolFailure::terminal("terminal callback")),
1107        }
1108        // Once the route is gone, the classified fallback must remain terminal.
1109        let failure = exec
1110            .execute_classified("mcp_srv_run", &params, "a1", None, None, 0, &effects, None)
1111            .await
1112            .unwrap_err();
1113        assert_eq!(failure, car_ir::ToolFailure::terminal("terminal callback"));
1114    }
1115
1116    /// #259: the local-server branch of `execute_with_action_in_session` used to
1117    /// `call_tool(..)` and drop the per-action `timeout_ms` on the floor, so a
1118    /// declared budget never reached the MCP transport (only the 600s backstop
1119    /// bounded the call). It must now route through `call_tool_with_timeout`
1120    /// carrying the budget — and pass `None` when the action declared none, which
1121    /// is exactly the old `call_tool` behavior.
1122    #[tokio::test]
1123    async fn action_budget_reaches_the_mcp_session() {
1124        use crate::ToolExecutor;
1125
1126        let seen = Arc::new(StdMutex::new(Vec::new()));
1127        let exec = McpToolExecutor::new();
1128        exec.add_session(
1129            "srv",
1130            Arc::new(Mutex::new(RecordingSession {
1131                name: "srv".to_string(),
1132                seen: Arc::clone(&seen),
1133            })),
1134        )
1135        .await;
1136        exec.set_route("mcp_srv_run", "srv").await;
1137
1138        // A declared budget must arrive at the session as a Duration.
1139        exec.execute_with_action("mcp_srv_run", &json!({}), "a0", Some(90_000))
1140            .await
1141            .expect("call succeeds");
1142        // No budget declared: `None`, i.e. the session's own backstop applies.
1143        exec.execute("mcp_srv_run", &json!({}))
1144            .await
1145            .expect("call succeeds");
1146
1147        assert_eq!(
1148            *seen.lock().unwrap(),
1149            vec![Some(Duration::from_millis(90_000)), None],
1150            "the action budget must reach the session, not be discarded"
1151        );
1152    }
1153}