1use 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
25fn 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
37pub struct McpClient {
39 name: String,
40 prefix_tools: bool,
43 forced: Capabilities,
46 stdin: tokio::sync::Mutex<ChildStdin>,
47 pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Value>>>>,
48 next_id: AtomicU64,
49 workspace: PathBuf,
53 _child: Child,
55}
56
57impl McpClient {
58 fn build_command(
67 cfg: &McpServerConfig,
68 sandbox: &Sandbox,
69 workspace: &Path,
70 ) -> Result<tokio::process::Command> {
71 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 c.current_dir(workspace);
105 c
106 };
107
108 command.env_clear();
112 command.envs(Sandbox::child_env(&cfg.env_passthrough));
113 command.envs(&cfg.env);
114
115 Ok(command)
116 }
117
118 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 .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 {
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 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 pub async fn list_tools(self: &Arc<Self>) -> Result<Vec<Arc<dyn Tool>>> {
273 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 read_only: hint("readOnlyHint") && !self.forced.destructive,
323 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 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) 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 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 refusal: false,
396 })
397 }
398}
399
400struct McpTool {
401 read_only: bool,
402 capabilities: Capabilities,
403 local_name: String,
404 remote_name: String,
405 description: String,
406 schema: Value,
407 client: Arc<McpClient>,
408}
409
410#[async_trait]
411impl Tool for McpTool {
412 fn name(&self) -> &str {
413 &self.local_name
414 }
415
416 fn description(&self) -> &str {
417 &self.description
418 }
419
420 fn input_schema(&self) -> Value {
421 self.schema.clone()
422 }
423
424 fn read_only(&self) -> bool {
425 self.read_only
428 }
429
430 fn capabilities(&self) -> Capabilities {
431 self.capabilities
435 }
436
437 fn fixed_workspace(&self) -> Option<PathBuf> {
438 Some(self.client.workspace.clone())
441 }
442
443 async fn call(&self, input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
444 match self.client.call_tool(&self.remote_name, input).await {
445 Ok(out) => Ok(out),
446 Err(e) => Ok(ToolOutput::err(format!("MCP call failed: {e}"))),
449 }
450 }
451}
452
453pub async fn connect_all(
456 configs: &[McpServerConfig],
457 sandbox: &Sandbox,
458 workspace: &Path,
459) -> (Vec<Arc<dyn Tool>>, Vec<Arc<McpClient>>, Vec<String>) {
460 let mut tools = Vec::new();
461 let mut clients = Vec::new();
462 let mut errors = Vec::new();
463
464 for cfg in configs.iter().filter(|c| !c.disabled) {
465 match McpClient::connect(cfg, sandbox, workspace).await {
466 Ok(client) => match client.list_tools().await {
467 Ok(mut t) => {
468 tools.append(&mut t);
469 clients.push(client);
470 }
471 Err(e) => errors.push(format!("{}: {e}", cfg.name)),
472 },
473 Err(e) => errors.push(format!("{}: {e}", cfg.name)),
474 }
475 }
476
477 (tools, clients, errors)
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use crate::sandbox::SandboxConfig;
484
485 const BASE: [&str; 5] = ["PATH", "HOME", "LANG", "LC_ALL", "TZ"];
487
488 fn unconfined() -> Sandbox {
489 Sandbox::new(SandboxConfig::default())
490 }
491
492 #[test]
493 fn a_response_id_is_accepted_however_the_server_spelled_it() {
494 use serde_json::json;
495 assert_eq!(response_id(&json!({"id": 7})), Some(7));
499 assert_eq!(response_id(&json!({"id": "7"})), Some(7));
500 assert_eq!(response_id(&json!({"id": "not-ours"})), None);
501 assert_eq!(response_id(&json!({"id": null})), None);
502 assert_eq!(response_id(&json!({})), None);
503 }
504
505 #[test]
506 fn asking_for_confinement_with_no_backend_is_an_error_not_a_warning() {
507 let cfg = McpServerConfig {
508 name: "nosy".into(),
509 command: "/usr/bin/env".into(),
510 sandbox: true,
511 ..Default::default()
512 };
513
514 let err = McpClient::build_command(&cfg, &unconfined(), Path::new("/tmp"))
518 .unwrap_err()
519 .to_string();
520 assert!(
521 err.contains("no sandbox backend is set"),
522 "unexpected error: {err}"
523 );
524 assert!(
525 err.contains("nosy"),
526 "the error should name the server: {err}"
527 );
528 }
529
530 #[test]
531 fn an_unconfined_server_is_spawned_directly_rather_than_wrapped() {
532 let cfg = McpServerConfig {
533 name: "plain".into(),
534 command: "/usr/bin/env".into(),
535 args: vec!["-0".into()],
536 ..Default::default()
537 };
538
539 let cmd = McpClient::build_command(&cfg, &unconfined(), Path::new("/tmp")).unwrap();
540 let std = cmd.as_std();
541
542 assert_eq!(std.get_program(), "/usr/bin/env");
543 let args: Vec<_> = std
544 .get_args()
545 .map(|a| a.to_string_lossy().to_string())
546 .collect();
547 assert_eq!(args, vec!["-0"]);
548 }
549
550 #[test]
560 fn an_unconfined_server_still_starts_in_the_workspace() {
561 let cfg = McpServerConfig {
562 name: "plain".into(),
563 command: "/usr/bin/env".into(),
564 ..Default::default()
565 };
566
567 let workspace = Path::new("/tmp");
568 let cmd = McpClient::build_command(&cfg, &unconfined(), workspace).unwrap();
569
570 assert_eq!(
571 cmd.as_std().get_current_dir(),
572 Some(workspace),
573 "an unconfined server must start in the workspace, not in mecha's cwd"
574 );
575 }
576
577 #[tokio::test]
585 async fn the_child_environment_is_an_allowlist_not_an_inheritance() {
586 let ours: std::collections::BTreeSet<String> = std::env::vars().map(|(k, _)| k).collect();
587
588 let Some(passthrough) = ours.iter().find(|k| !BASE.contains(&k.as_str())).cloned() else {
591 return; };
593
594 let cfg = McpServerConfig {
595 name: "nosy".into(),
596 command: "/usr/bin/env".into(),
597 args: vec!["-0".into()],
600 env: [("MECHA_EXPLICIT_TOKEN".to_string(), "granted".to_string())]
601 .into_iter()
602 .collect(),
603 env_passthrough: vec![passthrough.clone()],
604 ..Default::default()
605 };
606
607 let mut cmd = McpClient::build_command(&cfg, &unconfined(), Path::new("/tmp")).unwrap();
608 let out = cmd
609 .stdout(std::process::Stdio::piped())
610 .output()
611 .await
612 .unwrap();
613 assert!(out.status.success(), "env did not run");
614
615 let child: std::collections::BTreeSet<String> = String::from_utf8_lossy(&out.stdout)
616 .split('\0')
617 .filter(|s| !s.is_empty())
618 .filter_map(|entry| entry.split_once('=').map(|(k, _)| k.to_string()))
619 .collect();
620
621 let allowed: std::collections::BTreeSet<String> = BASE
622 .iter()
623 .map(|s| s.to_string())
624 .chain([passthrough.clone(), "MECHA_EXPLICIT_TOKEN".to_string()])
625 .collect();
626
627 let leaked: Vec<_> = child.difference(&allowed).collect();
628 assert!(
629 leaked.is_empty(),
630 "these crossed without being named: {leaked:?}"
631 );
632
633 assert!(
634 child.contains(&passthrough),
635 "a named passthrough did not cross"
636 );
637 assert!(
638 child.contains("MECHA_EXPLICIT_TOKEN"),
639 "an explicit value did not cross"
640 );
641 assert!(
642 child.len() < ours.len(),
643 "the child holds as much as we do ({} vs {}) — the environment was inherited",
644 child.len(),
645 ours.len()
646 );
647 }
648}