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 })
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 self.read_only
427 }
428
429 fn capabilities(&self) -> Capabilities {
430 self.capabilities
434 }
435
436 fn fixed_workspace(&self) -> Option<PathBuf> {
437 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 Err(e) => Ok(ToolOutput::err(format!("MCP call failed: {e}"))),
448 }
449 }
450}
451
452pub 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 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 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 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 #[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 #[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 let Some(passthrough) = ours.iter().find(|k| !BASE.contains(&k.as_str())).cloned() else {
590 return; };
592
593 let cfg = McpServerConfig {
594 name: "nosy".into(),
595 command: "/usr/bin/env".into(),
596 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}