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