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;
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 forced: Capabilities,
43 stdin: tokio::sync::Mutex<ChildStdin>,
44 pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Value>>>>,
45 next_id: AtomicU64,
46 _child: Child,
48}
49
50impl McpClient {
51 fn build_command(
60 cfg: &McpServerConfig,
61 sandbox: &Sandbox,
62 workspace: &Path,
63 ) -> Result<tokio::process::Command> {
64 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 c.current_dir(workspace);
98 c
99 };
100
101 command.env_clear();
105 command.envs(Sandbox::child_env(&cfg.env_passthrough));
106 command.envs(&cfg.env);
107
108 Ok(command)
109 }
110
111 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 .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 {
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 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 pub async fn list_tools(self: &Arc<Self>) -> Result<Vec<Arc<dyn Tool>>> {
264 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 read_only: hint("readOnlyHint") && !self.forced.destructive,
314 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 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) 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 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 self.read_only
412 }
413
414 fn capabilities(&self) -> Capabilities {
415 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 Err(e) => Ok(ToolOutput::err(format!("MCP call failed: {e}"))),
427 }
428 }
429}
430
431pub 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 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 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 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 #[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 #[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 let Some(passthrough) = ours.iter().find(|k| !BASE.contains(&k.as_str())).cloned() else {
569 return; };
571
572 let cfg = McpServerConfig {
573 name: "nosy".into(),
574 command: "/usr/bin/env".into(),
575 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}