Skip to main content

mecha_core/
mcp.rs

1//! Minimal MCP client over stdio.
2//!
3//! Speaks JSON-RPC 2.0 line-by-line to a child process and exposes whatever
4//! tools it advertises as ordinary [`Tool`] implementations. That's the whole
5//! point: an MCP server's tools and mecha's built-ins are indistinguishable to
6//! the agent loop.
7
8use crate::config::McpServerConfig;
9use crate::sandbox::Sandbox;
10use crate::tool::{Capabilities, Tool, ToolCtx, ToolOutput};
11use anyhow::{anyhow, bail, Context, Result};
12use async_trait::async_trait;
13use serde_json::{json, Value};
14use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::sync::{Arc, Mutex};
18use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
19use tokio::process::{Child, ChildStdin};
20use tokio::sync::oneshot;
21
22const PROTOCOL_VERSION: &str = "2025-06-18";
23const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
24
25/// The id of a response, however the server spelled it. We always send
26/// numeric ids, but JSON-RPC allows string ids and real servers echo numbers
27/// back as strings — refusing those would leave every call to time out
28/// against a server that is answering.
29fn response_id(msg: &Value) -> Option<u64> {
30    match msg.get("id")? {
31        Value::Number(n) => n.as_u64(),
32        Value::String(s) => s.parse().ok(),
33        _ => None,
34    }
35}
36
37/// A live connection to one MCP server.
38pub struct McpClient {
39    name: String,
40    /// Whether tools register as `<name>__<tool>` (the collision-proof
41    /// default) or under their raw names. See [`McpServerConfig::prefix_tools`].
42    prefix_tools: bool,
43    /// Capabilities forced onto every tool from this server, unioned with what
44    /// it declares. See [`McpServerConfig::capabilities`].
45    forced: Capabilities,
46    stdin: tokio::sync::Mutex<ChildStdin>,
47    pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Value>>>>,
48    next_id: AtomicU64,
49    /// The directory the server was spawned in — where its relative paths
50    /// resolve, however the per-run workspace moves (see
51    /// [`Tool::fixed_workspace`]).
52    workspace: PathBuf,
53    /// Held so the child is killed when the client drops.
54    _child: Child,
55}
56
57impl McpClient {
58    /// The child process as it will be spawned: confinement decided, and the
59    /// environment built.
60    ///
61    /// Split out from [`McpClient::connect`] so the environment policy can be
62    /// asserted on. It is worth asserting on: `envs()` *adds to* the inherited
63    /// environment rather than replacing it, so the bug this prevents looks
64    /// entirely correct at the call site while every server on the machine
65    /// quietly holds your provider keys.
66    fn build_command(
67        cfg: &McpServerConfig,
68        sandbox: &Sandbox,
69        workspace: &Path,
70    ) -> Result<tokio::process::Command> {
71        // Same rule as `shell`: asking to be confined and silently not being
72        // confined is the worst outcome, because the decision was made on the
73        // belief that it held.
74        if cfg.sandbox && !sandbox.is_enabled() {
75            bail!(
76                "MCP server `{}` is configured with `sandbox = true`, but no sandbox \
77                 backend is set. Set [sandbox] kind = \"bwrap\" or \"docker\", or drop \
78                 `sandbox = true` to accept that it runs unconfined.",
79                cfg.name
80            );
81        }
82
83        let mut command = if cfg.sandbox {
84            let confined = match cfg.network {
85                Some(network) => sandbox.with_network(network),
86                None => sandbox.clone(),
87            };
88            confined
89                .wrap_argv(&cfg.command, &cfg.args, workspace, workspace)
90                .with_context(|| format!("confining MCP server `{}`", cfg.name))?
91        } else {
92            let mut c = tokio::process::Command::new(&cfg.command);
93            c.args(&cfg.args);
94            // The workspace, whether or not we confine. A confined server gets
95            // it as the only writable mount and `--chdir`s there; an
96            // unconfined one used to inherit *mecha's* working directory,
97            // which is wherever the user happened to launch it. That is not a
98            // containment hole — an unconfined server can reach anything
99            // regardless — but it silently breaks every server that resolves a
100            // relative path, because the model's paths are relative to the run
101            // workspace and the server's are not. `mecha-factory-publish`
102            // documents `--root` as defaulting to the working directory on
103            // exactly this assumption.
104            c.current_dir(workspace);
105            c
106        };
107
108        // Clear first, then add back. `envs()` alone layers on top of the
109        // inherited environment, which is how a server ends up holding your
110        // provider keys without anyone deciding it should.
111        command.env_clear();
112        command.envs(Sandbox::child_env(&cfg.env_passthrough));
113        command.envs(&cfg.env);
114
115        Ok(command)
116    }
117
118    /// Spawn the server, perform the initialize handshake, and return a client.
119    ///
120    /// An MCP server is third-party code running on your machine, which makes
121    /// it a larger hole than `shell` ever was: `shell` at least runs commands a
122    /// model asked for out loud, where a server runs whatever its author wrote.
123    /// So it gets the same treatment — a named environment rather than an
124    /// inherited one, and optional confinement.
125    pub async fn connect(
126        cfg: &McpServerConfig,
127        sandbox: &Sandbox,
128        workspace: &Path,
129    ) -> Result<Arc<Self>> {
130        let mut command = Self::build_command(cfg, sandbox, workspace)?;
131
132        command
133            .stdin(std::process::Stdio::piped())
134            .stdout(std::process::Stdio::piped())
135            // The MCP convention is that stderr is the server's log, not part
136            // of the protocol. It used to inherit ours, but a raw share of
137            // the terminal garbles a full-screen front-end mid-frame — so it
138            // flows through tracing instead, tagged with the server's name
139            // and visible under MECHA_LOG.
140            .stderr(std::process::Stdio::piped());
141
142        let mut child = command
143            .spawn()
144            .with_context(|| format!("spawning MCP server `{}` ({})", cfg.name, cfg.command))?;
145
146        let stdin = child.stdin.take().ok_or_else(|| anyhow!("no stdin"))?;
147        let stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?;
148        if let Some(stderr) = child.stderr.take() {
149            let server = cfg.name.clone();
150            tokio::spawn(async move {
151                let mut lines = BufReader::new(stderr).lines();
152                while let Ok(Some(line)) = lines.next_line().await {
153                    tracing::debug!(server = %server, "{line}");
154                }
155            });
156        }
157
158        let pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Value>>>> =
159            Arc::new(Mutex::new(HashMap::new()));
160
161        // Reader task: route each response to whoever is awaiting that id.
162        // Server-initiated notifications have no id and are ignored.
163        {
164            let pending = Arc::clone(&pending);
165            let server = cfg.name.clone();
166            tokio::spawn(async move {
167                let mut lines = BufReader::new(stdout).lines();
168                while let Ok(Some(line)) = lines.next_line().await {
169                    let line = line.trim();
170                    if line.is_empty() {
171                        continue;
172                    }
173                    let Ok(msg) = serde_json::from_str::<Value>(line) else {
174                        tracing::warn!(server, line, "MCP server sent non-JSON on stdout");
175                        continue;
176                    };
177                    let Some(id) = response_id(&msg) else {
178                        continue;
179                    };
180                    if let Some(tx) = pending.lock().unwrap().remove(&id) {
181                        let _ = tx.send(msg);
182                    }
183                }
184                // Stdout closed: the server exited. Wake everyone still waiting
185                // rather than leaving them to time out one by one.
186                pending.lock().unwrap().clear();
187            });
188        }
189
190        let client = Arc::new(McpClient {
191            name: cfg.name.clone(),
192            prefix_tools: cfg.prefix_tools.unwrap_or(true),
193            forced: cfg.capabilities.into(),
194            stdin: tokio::sync::Mutex::new(stdin),
195            pending,
196            next_id: AtomicU64::new(1),
197            workspace: workspace.to_path_buf(),
198            _child: child,
199        });
200
201        client
202            .request(
203                "initialize",
204                json!({
205                    "protocolVersion": PROTOCOL_VERSION,
206                    "capabilities": {},
207                    "clientInfo": {"name": "mecha", "version": env!("CARGO_PKG_VERSION")},
208                }),
209            )
210            .await
211            .with_context(|| format!("MCP handshake with `{}` failed", cfg.name))?;
212
213        client
214            .notify("notifications/initialized", json!({}))
215            .await?;
216        Ok(client)
217    }
218
219    pub fn name(&self) -> &str {
220        &self.name
221    }
222
223    async fn send_line(&self, msg: &Value) -> Result<()> {
224        let mut line = serde_json::to_string(msg)?;
225        line.push('\n');
226        let mut stdin = self.stdin.lock().await;
227        stdin.write_all(line.as_bytes()).await?;
228        stdin.flush().await?;
229        Ok(())
230    }
231
232    async fn notify(&self, method: &str, params: Value) -> Result<()> {
233        self.send_line(&json!({"jsonrpc": "2.0", "method": method, "params": params}))
234            .await
235    }
236
237    async fn request(&self, method: &str, params: Value) -> Result<Value> {
238        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
239        let (tx, rx) = oneshot::channel();
240        self.pending.lock().unwrap().insert(id, tx);
241
242        self.send_line(&json!({
243            "jsonrpc": "2.0",
244            "id": id,
245            "method": method,
246            "params": params,
247        }))
248        .await?;
249
250        let response = match tokio::time::timeout(REQUEST_TIMEOUT, rx).await {
251            Err(_) => {
252                self.pending.lock().unwrap().remove(&id);
253                bail!("MCP server `{}` did not answer {method} in time", self.name);
254            }
255            Ok(Err(_)) => bail!("MCP server `{}` exited during {method}", self.name),
256            Ok(Ok(v)) => v,
257        };
258
259        if let Some(err) = response.get("error") {
260            bail!(
261                "MCP server `{}` returned an error for {method}: {}",
262                self.name,
263                err.get("message")
264                    .and_then(Value::as_str)
265                    .unwrap_or(&err.to_string())
266            );
267        }
268        Ok(response.get("result").cloned().unwrap_or(Value::Null))
269    }
270
271    /// Ask the server what it can do, and wrap each answer as a [`Tool`].
272    pub async fn list_tools(self: &Arc<Self>) -> Result<Vec<Arc<dyn Tool>>> {
273        // Paged: a server with more tools than one page returns a
274        // `nextCursor`, and stopping at page one silently shrinks its
275        // surface — tools the config counted on simply would not exist.
276        // Bounded, so a server that hands out cursors forever cannot wedge
277        // startup.
278        let mut tools = Vec::new();
279        let mut cursor: Option<String> = None;
280        for _ in 0..100 {
281            let params = match cursor.take() {
282                Some(c) => json!({"cursor": c}),
283                None => json!({}),
284            };
285            let result = self.request("tools/list", params).await?;
286            tools.extend(
287                result
288                    .get("tools")
289                    .and_then(Value::as_array)
290                    .cloned()
291                    .unwrap_or_default(),
292            );
293            match result.get("nextCursor").and_then(Value::as_str) {
294                Some(c) if !c.is_empty() => cursor = Some(c.to_string()),
295                _ => break,
296            }
297        }
298        if cursor.is_some() {
299            tracing::warn!(
300                server = %self.name,
301                "tools/list still paging after 100 pages; taking what arrived"
302            );
303        }
304
305        Ok(tools
306            .into_iter()
307            .filter_map(|t| {
308                let remote_name = t.get("name")?.as_str()?.to_string();
309                let hints = t.get("annotations").cloned().unwrap_or(Value::Null);
310                let hint = |k: &str| hints.get(k).and_then(Value::as_bool).unwrap_or(false);
311
312                Some(Arc::new(McpTool {
313                    // Only a forced `destructive` contradicts a read-only
314                    // claim; the others are orthogonal to it and dropping the
315                    // exemption for them was wrong. `untrusted_input` says the
316                    // content coming *out* may be attacker-influenced, and
317                    // `external_send` says data can leave — neither implies the
318                    // tool changes anything, and `http_fetch` is read-only
319                    // while being a send sink for exactly that reason. Blanket
320                    // narrowing here made every pkg retrieval prompt for
321                    // approval, which is unusable for memory read at turn start.
322                    read_only: hint("readOnlyHint") && !self.forced.destructive,
323                    // `openWorldHint` means the tool talks to the wider world:
324                    // that makes it both a source of attacker-influenced content
325                    // and a way for data to leave.
326                    capabilities: Capabilities {
327                        private_data: true,
328                        untrusted_input: hint("openWorldHint"),
329                        external_send: hint("openWorldHint"),
330                        destructive: hint("destructiveHint"),
331                    }
332                    .union(self.forced),
333                    // Namespaced so two servers can each expose a `search` —
334                    // unless the config says this server's tools carry their
335                    // own namespace, in which case the raw name is the name.
336                    local_name: if self.prefix_tools {
337                        format!("{}__{}", self.name, remote_name)
338                    } else {
339                        remote_name.clone()
340                    },
341                    remote_name,
342                    description: t
343                        .get("description")
344                        .and_then(Value::as_str)
345                        .unwrap_or_default()
346                        .to_string(),
347                    schema: t
348                        .get("inputSchema")
349                        .cloned()
350                        .unwrap_or_else(|| json!({"type": "object"})),
351                    client: Arc::clone(self),
352                }) as Arc<dyn Tool>)
353            })
354            .collect())
355    }
356
357    // pub(crate) for `distill`, which pushes episodes through a graph
358    // server's `kg_upsert` without a run (and so without a `ToolCtx`).
359    pub(crate) async fn call_tool(&self, name: &str, arguments: Value) -> Result<ToolOutput> {
360        let result = self
361            .request("tools/call", json!({"name": name, "arguments": arguments}))
362            .await?;
363
364        // Content is a list of typed parts; we flatten the text ones and note
365        // anything else rather than silently dropping it.
366        let mut text = Vec::new();
367        for part in result
368            .get("content")
369            .and_then(Value::as_array)
370            .unwrap_or(&vec![])
371        {
372            match part.get("type").and_then(Value::as_str) {
373                Some("text") => text.push(
374                    part.get("text")
375                        .and_then(Value::as_str)
376                        .unwrap_or("")
377                        .to_string(),
378                ),
379                Some(other) => text.push(format!("[{other} content omitted]")),
380                None => {}
381            }
382        }
383
384        Ok(ToolOutput {
385            content: if text.is_empty() {
386                "(no content)".into()
387            } else {
388                text.join("\n")
389            },
390            is_error: result
391                .get("isError")
392                .and_then(Value::as_bool)
393                .unwrap_or(false),
394            external: true,
395        })
396    }
397}
398
399struct McpTool {
400    read_only: bool,
401    capabilities: Capabilities,
402    local_name: String,
403    remote_name: String,
404    description: String,
405    schema: Value,
406    client: Arc<McpClient>,
407}
408
409#[async_trait]
410impl Tool for McpTool {
411    fn name(&self) -> &str {
412        &self.local_name
413    }
414
415    fn description(&self) -> &str {
416        &self.description
417    }
418
419    fn input_schema(&self) -> Value {
420        self.schema.clone()
421    }
422
423    fn read_only(&self) -> bool {
424        // `readOnlyHint` is advisory and often omitted, so an unannotated tool
425        // is assumed to change things.
426        self.read_only
427    }
428
429    fn capabilities(&self) -> Capabilities {
430        // An unannotated server tool is assumed to return private data — that
431        // is what most of them exist to do — but not to reach the open world,
432        // because assuming otherwise would arm the interlock on every call.
433        self.capabilities
434    }
435
436    fn fixed_workspace(&self) -> Option<PathBuf> {
437        // The server was spawned once, in one directory; its relative paths
438        // resolve there no matter which per-run workspace the call carries.
439        Some(self.client.workspace.clone())
440    }
441
442    async fn call(&self, input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
443        match self.client.call_tool(&self.remote_name, input).await {
444            Ok(out) => Ok(out),
445            // A transport failure is the agent's problem to route around, not a
446            // reason to abort the run.
447            Err(e) => Ok(ToolOutput::err(format!("MCP call failed: {e}"))),
448        }
449    }
450}
451
452/// Connect every enabled server in config. A server that fails to start is
453/// reported and skipped — one broken entry shouldn't sink the whole session.
454pub async fn connect_all(
455    configs: &[McpServerConfig],
456    sandbox: &Sandbox,
457    workspace: &Path,
458) -> (Vec<Arc<dyn Tool>>, Vec<Arc<McpClient>>, Vec<String>) {
459    let mut tools = Vec::new();
460    let mut clients = Vec::new();
461    let mut errors = Vec::new();
462
463    for cfg in configs.iter().filter(|c| !c.disabled) {
464        match McpClient::connect(cfg, sandbox, workspace).await {
465            Ok(client) => match client.list_tools().await {
466                Ok(mut t) => {
467                    tools.append(&mut t);
468                    clients.push(client);
469                }
470                Err(e) => errors.push(format!("{}: {e}", cfg.name)),
471            },
472            Err(e) => errors.push(format!("{}: {e}", cfg.name)),
473        }
474    }
475
476    (tools, clients, errors)
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use crate::sandbox::SandboxConfig;
483
484    /// Always passed through, because most runtimes cannot start without them.
485    const BASE: [&str; 5] = ["PATH", "HOME", "LANG", "LC_ALL", "TZ"];
486
487    fn unconfined() -> Sandbox {
488        Sandbox::new(SandboxConfig::default())
489    }
490
491    #[test]
492    fn a_response_id_is_accepted_however_the_server_spelled_it() {
493        use serde_json::json;
494        // We send numbers; a compliant server echoes numbers, a common
495        // dialect echoes them as strings. Both must route, or every call
496        // waits out the full timeout against a server that answered.
497        assert_eq!(response_id(&json!({"id": 7})), Some(7));
498        assert_eq!(response_id(&json!({"id": "7"})), Some(7));
499        assert_eq!(response_id(&json!({"id": "not-ours"})), None);
500        assert_eq!(response_id(&json!({"id": null})), None);
501        assert_eq!(response_id(&json!({})), None);
502    }
503
504    #[test]
505    fn asking_for_confinement_with_no_backend_is_an_error_not_a_warning() {
506        let cfg = McpServerConfig {
507            name: "nosy".into(),
508            command: "/usr/bin/env".into(),
509            sandbox: true,
510            ..Default::default()
511        };
512
513        // Same rule as `shell`: running unconfined after being told to confine
514        // would have every downstream decision resting on a belief nothing is
515        // enforcing.
516        let err = McpClient::build_command(&cfg, &unconfined(), Path::new("/tmp"))
517            .unwrap_err()
518            .to_string();
519        assert!(
520            err.contains("no sandbox backend is set"),
521            "unexpected error: {err}"
522        );
523        assert!(
524            err.contains("nosy"),
525            "the error should name the server: {err}"
526        );
527    }
528
529    #[test]
530    fn an_unconfined_server_is_spawned_directly_rather_than_wrapped() {
531        let cfg = McpServerConfig {
532            name: "plain".into(),
533            command: "/usr/bin/env".into(),
534            args: vec!["-0".into()],
535            ..Default::default()
536        };
537
538        let cmd = McpClient::build_command(&cfg, &unconfined(), Path::new("/tmp")).unwrap();
539        let std = cmd.as_std();
540
541        assert_eq!(std.get_program(), "/usr/bin/env");
542        let args: Vec<_> = std
543            .get_args()
544            .map(|a| a.to_string_lossy().to_string())
545            .collect();
546        assert_eq!(args, vec!["-0"]);
547    }
548
549    /// Confined or not, a server starts in the run's workspace.
550    ///
551    /// The confined branch has always done this — the workspace is its only
552    /// writable mount, and `wrap_argv` `--chdir`s into it. The unconfined
553    /// branch inherited mecha's own working directory, so a server that
554    /// resolves relative paths resolved them against wherever the user
555    /// launched mecha. Nothing about confinement changes: an unconfined server
556    /// could always reach the whole filesystem. What changes is that the two
557    /// branches now agree about where the model's paths point.
558    #[test]
559    fn an_unconfined_server_still_starts_in_the_workspace() {
560        let cfg = McpServerConfig {
561            name: "plain".into(),
562            command: "/usr/bin/env".into(),
563            ..Default::default()
564        };
565
566        let workspace = Path::new("/tmp");
567        let cmd = McpClient::build_command(&cfg, &unconfined(), workspace).unwrap();
568
569        assert_eq!(
570            cmd.as_std().get_current_dir(),
571            Some(workspace),
572            "an unconfined server must start in the workspace, not in mecha's cwd"
573        );
574    }
575
576    /// The measurement that motivated `env_clear()`, as a test: spawn a server
577    /// that reports its own environment and check what actually crossed.
578    ///
579    /// Asserted as a subset rather than against a hand-listed set of secrets,
580    /// because the leak was never about one variable — `envs()` layered onto
581    /// the inherited environment, so *everything* crossed, provider keys
582    /// included, and the call site looked right.
583    #[tokio::test]
584    async fn the_child_environment_is_an_allowlist_not_an_inheritance() {
585        let ours: std::collections::BTreeSet<String> = std::env::vars().map(|(k, _)| k).collect();
586
587        // Something we hold that is not in the base set — under `cargo test`
588        // there are many. Naming it makes it cross; its neighbours must not.
589        let Some(passthrough) = ours.iter().find(|k| !BASE.contains(&k.as_str())).cloned() else {
590            return; // An environment this bare has nothing to leak.
591        };
592
593        let cfg = McpServerConfig {
594            name: "nosy".into(),
595            command: "/usr/bin/env".into(),
596            // NUL-separated: a value containing a newline cannot be mistaken
597            // for another variable.
598            args: vec!["-0".into()],
599            env: [("MECHA_EXPLICIT_TOKEN".to_string(), "granted".to_string())]
600                .into_iter()
601                .collect(),
602            env_passthrough: vec![passthrough.clone()],
603            ..Default::default()
604        };
605
606        let mut cmd = McpClient::build_command(&cfg, &unconfined(), Path::new("/tmp")).unwrap();
607        let out = cmd
608            .stdout(std::process::Stdio::piped())
609            .output()
610            .await
611            .unwrap();
612        assert!(out.status.success(), "env did not run");
613
614        let child: std::collections::BTreeSet<String> = String::from_utf8_lossy(&out.stdout)
615            .split('\0')
616            .filter(|s| !s.is_empty())
617            .filter_map(|entry| entry.split_once('=').map(|(k, _)| k.to_string()))
618            .collect();
619
620        let allowed: std::collections::BTreeSet<String> = BASE
621            .iter()
622            .map(|s| s.to_string())
623            .chain([passthrough.clone(), "MECHA_EXPLICIT_TOKEN".to_string()])
624            .collect();
625
626        let leaked: Vec<_> = child.difference(&allowed).collect();
627        assert!(
628            leaked.is_empty(),
629            "these crossed without being named: {leaked:?}"
630        );
631
632        assert!(
633            child.contains(&passthrough),
634            "a named passthrough did not cross"
635        );
636        assert!(
637            child.contains("MECHA_EXPLICIT_TOKEN"),
638            "an explicit value did not cross"
639        );
640        assert!(
641            child.len() < ours.len(),
642            "the child holds as much as we do ({} vs {}) — the environment was inherited",
643            child.len(),
644            ours.len()
645        );
646    }
647}