1use crate::mcp::McpSession;
25use serde_json::json;
26use std::path::PathBuf;
27use std::sync::Arc;
28use std::time::Duration;
29use tokio::sync::Mutex;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct CommandOutput {
34 pub stdout: String,
35 pub stderr: String,
36 pub exit_code: i32,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum PathState {
44 Missing,
45 Exists,
46 Unknown(String),
47}
48
49#[async_trait::async_trait]
57pub trait Substrate: Send + Sync {
58 fn name(&self) -> &str;
60
61 async fn run_command(&self, cmd: &str, timeout_s: Option<f64>)
63 -> Result<CommandOutput, String>;
64
65 async fn read_text(&self, path: &str) -> Result<String, String>;
67
68 async fn write_text(&self, path: &str, content: &str) -> Result<(), String>;
70
71 async fn path_state(&self, _path: &str) -> PathState {
76 PathState::Unknown("substrate does not expose a path-presence check".into())
77 }
78
79 async fn read_bytes(
81 &self,
82 path: &str,
83 offset: Option<u64>,
84 len: Option<u64>,
85 ) -> Result<Vec<u8>, String>;
86
87 async fn write_bytes(&self, path: &str, bytes: &[u8]) -> Result<(), String>;
89
90 fn is_local(&self) -> bool {
95 false
96 }
97
98 fn display_path(&self, path: &str) -> String {
104 path.to_string()
105 }
106
107 async fn pty_start(&self, _cmd: &str) -> Result<String, String> {
110 Err("pty not supported by this substrate".into())
111 }
112 async fn pty_input(&self, _id: &str, _data: &str) -> Result<(), String> {
113 Err("pty not supported by this substrate".into())
114 }
115 async fn pty_read(&self, _id: &str) -> Result<String, String> {
116 Err("pty not supported by this substrate".into())
117 }
118 async fn pty_resize(&self, _id: &str, _rows: u16, _cols: u16) -> Result<(), String> {
119 Err("pty not supported by this substrate".into())
120 }
121 async fn pty_kill(&self, _id: &str) -> Result<(), String> {
122 Err("pty not supported by this substrate".into())
123 }
124}
125
126#[derive(Debug, Default, Clone)]
136pub struct LocalSubstrate;
137
138impl LocalSubstrate {
139 pub fn new() -> Self {
140 Self
141 }
142
143 pub fn resolve_path(path: &str) -> Result<PathBuf, String> {
146 let candidate = PathBuf::from(path);
147 if candidate.is_absolute() {
148 Ok(candidate)
149 } else {
150 std::env::current_dir()
151 .map(|cwd| cwd.join(candidate))
152 .map_err(|e| format!("failed to resolve working directory: {e}"))
153 }
154 }
155}
156
157#[async_trait::async_trait]
158impl Substrate for LocalSubstrate {
159 fn name(&self) -> &str {
160 "local"
161 }
162
163 fn is_local(&self) -> bool {
164 true
165 }
166
167 async fn run_command(
168 &self,
169 cmd: &str,
170 timeout_s: Option<f64>,
171 ) -> Result<CommandOutput, String> {
172 use tokio::process::Command;
173 let mut command = if cfg!(target_os = "windows") {
174 let mut c = Command::new("cmd");
175 c.arg("/C").arg(cmd);
176 if let Some(path) = crate::win_env::cmd_path_override() {
179 c.env("PATH", path);
180 }
181 c
182 } else {
183 let mut c = Command::new("sh");
184 c.arg("-c").arg(cmd);
185 c
186 };
187
188 let fut = command.output();
189 let output = match timeout_s {
190 Some(secs) if secs > 0.0 => {
191 let dur = std::time::Duration::from_secs_f64(secs);
192 match tokio::time::timeout(dur, fut).await {
193 Ok(res) => res.map_err(|e| format!("failed to run command: {e}"))?,
194 Err(_) => return Err(format!("command timed out after {secs}s")),
195 }
196 }
197 _ => fut
198 .await
199 .map_err(|e| format!("failed to run command: {e}"))?,
200 };
201
202 Ok(CommandOutput {
203 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
204 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
205 exit_code: output.status.code().unwrap_or(-1),
206 })
207 }
208
209 async fn read_text(&self, path: &str) -> Result<String, String> {
210 let full = Self::resolve_path(path)?;
211 std::fs::read_to_string(&full)
212 .map_err(|e| format!("failed to read file '{}': {e}", full.display()))
213 }
214
215 async fn write_text(&self, path: &str, content: &str) -> Result<(), String> {
216 let full = Self::resolve_path(path)?;
217 if let Some(parent) = full.parent() {
218 std::fs::create_dir_all(parent)
219 .map_err(|e| format!("failed to create parent dir '{}': {e}", parent.display()))?;
220 }
221 std::fs::write(&full, content)
222 .map_err(|e| format!("failed to write file '{}': {e}", full.display()))
223 }
224
225 async fn path_state(&self, path: &str) -> PathState {
226 let full = match Self::resolve_path(path) {
227 Ok(path) => path,
228 Err(error) => return PathState::Unknown(error),
229 };
230 match std::fs::symlink_metadata(&full) {
231 Ok(_) => PathState::Exists,
232 Err(error) if error.kind() == std::io::ErrorKind::NotFound => PathState::Missing,
233 Err(error) => {
234 PathState::Unknown(format!("failed to inspect '{}': {error}", full.display()))
235 }
236 }
237 }
238
239 async fn read_bytes(
240 &self,
241 path: &str,
242 offset: Option<u64>,
243 len: Option<u64>,
244 ) -> Result<Vec<u8>, String> {
245 let full = Self::resolve_path(path)?;
246 let bytes = std::fs::read(&full)
247 .map_err(|e| format!("failed to read file '{}': {e}", full.display()))?;
248 let start = offset.unwrap_or(0) as usize;
249 if start >= bytes.len() {
250 return Ok(Vec::new());
251 }
252 let end = match len {
253 Some(l) => (start + l as usize).min(bytes.len()),
254 None => bytes.len(),
255 };
256 Ok(bytes[start..end].to_vec())
257 }
258
259 async fn write_bytes(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
260 let full = Self::resolve_path(path)?;
261 if let Some(parent) = full.parent() {
262 std::fs::create_dir_all(parent)
263 .map_err(|e| format!("failed to create parent dir '{}': {e}", parent.display()))?;
264 }
265 std::fs::write(&full, bytes)
266 .map_err(|e| format!("failed to write file '{}': {e}", full.display()))
267 }
268
269 fn display_path(&self, path: &str) -> String {
270 match Self::resolve_path(path) {
271 Ok(p) => p.display().to_string(),
272 Err(_) => path.to_string(),
273 }
274 }
275}
276
277pub const SUBSTRATE_TRANSPORT_ERR_PREFIX: &str = "[substrate-transport] ";
305
306fn looks_like_transport_error(msg: &str) -> bool {
310 const SIGS: &[&str] = &[
311 "fetch failed",
312 "connection",
313 "econnrefused",
314 "econnreset",
315 "transport",
316 "timed out",
317 "timeout",
318 "socket hang",
319 "broken pipe",
320 "stream closed",
321 "channel closed",
322 "unexpected eof",
323 ];
324 let m = msg.to_ascii_lowercase();
325 SIGS.iter().any(|s| m.contains(s))
326}
327
328fn shell_quote(s: &str) -> String {
330 format!("'{}'", s.replace('\'', "'\\''"))
331}
332
333fn is_retryable_transport_error(msg: &str) -> bool {
349 const RETRYABLE: &[&str] = &[
350 "fetch failed",
351 "econnrefused",
352 "econnreset",
353 "connection refused",
354 "connection reset",
355 "broken pipe",
356 "stream closed",
357 "channel closed",
358 "unexpected eof",
359 "closed the connection",
360 ];
361 let m = msg.to_ascii_lowercase();
362 RETRYABLE.iter().any(|s| m.contains(s))
363}
364
365const SUBSTRATE_RETRY_ATTEMPTS: u32 = 3;
367
368pub struct McpSubstrate {
369 session: Arc<Mutex<dyn McpSession>>,
370 name: String,
371}
372
373impl McpSubstrate {
374 pub fn new(session: Arc<Mutex<dyn McpSession>>, name: impl Into<String>) -> Self {
377 Self {
378 session,
379 name: name.into(),
380 }
381 }
382
383 async fn call(&self, tool: &str, args: serde_json::Value) -> Result<serde_json::Value, String> {
384 self.call_timed(tool, args, None).await
385 }
386
387 async fn call_timed(
391 &self,
392 tool: &str,
393 args: serde_json::Value,
394 timeout: Option<Duration>,
395 ) -> Result<serde_json::Value, String> {
396 let mut last_err = String::new();
397 for attempt in 1..=SUBSTRATE_RETRY_ATTEMPTS {
398 let result = {
400 let mut guard = self.session.lock().await;
401 guard
402 .call_tool_with_timeout(tool, args.clone(), timeout)
403 .await
404 };
405 match result {
406 Ok(v) => return Ok(v),
407 Err(e)
411 if is_retryable_transport_error(&e) && attempt < SUBSTRATE_RETRY_ATTEMPTS =>
412 {
413 last_err = e;
414 tokio::time::sleep(Duration::from_millis(300 * attempt as u64)).await;
415 continue;
416 }
417 Err(e) => {
421 return Err(if looks_like_transport_error(&e) {
422 format!("{SUBSTRATE_TRANSPORT_ERR_PREFIX}{e}")
423 } else {
424 e
425 });
426 }
427 }
428 }
429 Err(format!("{SUBSTRATE_TRANSPORT_ERR_PREFIX}{last_err}"))
431 }
432}
433
434#[async_trait::async_trait]
435impl Substrate for McpSubstrate {
436 fn name(&self) -> &str {
437 &self.name
438 }
439
440 async fn run_command(
441 &self,
442 cmd: &str,
443 timeout_s: Option<f64>,
444 ) -> Result<CommandOutput, String> {
445 let mut args = json!({ "command": cmd });
446 if let Some(secs) = timeout_s {
447 args["timeout"] = json!(secs);
448 }
449 let await_timeout = timeout_s.map(|s| Duration::from_secs_f64(s + 30.0));
453 let result = self.call_timed("run_command", args, await_timeout).await?;
454
455 match &result {
458 serde_json::Value::String(s) => Ok(CommandOutput {
459 stdout: s.clone(),
460 stderr: String::new(),
461 exit_code: 0,
462 }),
463 serde_json::Value::Object(_) => Ok(CommandOutput {
464 stdout: result
465 .get("stdout")
466 .and_then(|v| v.as_str())
467 .unwrap_or("")
468 .to_string(),
469 stderr: result
470 .get("stderr")
471 .and_then(|v| v.as_str())
472 .unwrap_or("")
473 .to_string(),
474 exit_code: result
475 .get("exit_code")
476 .and_then(|v| v.as_i64())
477 .unwrap_or(0) as i32,
478 }),
479 other => Ok(CommandOutput {
480 stdout: other.to_string(),
481 stderr: String::new(),
482 exit_code: 0,
483 }),
484 }
485 }
486
487 async fn read_text(&self, path: &str) -> Result<String, String> {
488 let result = self.call("read_text", json!({ "path": path })).await?;
489 match result {
490 serde_json::Value::String(s) => Ok(s),
491 other => Ok(other
492 .get("content")
493 .and_then(|v| v.as_str())
494 .map(|s| s.to_string())
495 .unwrap_or_else(|| other.to_string())),
496 }
497 }
498
499 async fn write_text(&self, path: &str, content: &str) -> Result<(), String> {
500 self.call("write_text", json!({ "path": path, "content": content }))
501 .await
502 .map(|_| ())
503 }
504
505 async fn path_state(&self, path: &str) -> PathState {
506 let quoted = shell_quote(path);
507 match self
508 .run_command(
509 &format!("if [ -e {quoted} ] || [ -L {quoted} ]; then exit 0; else exit 1; fi"),
510 Some(5.0),
511 )
512 .await
513 {
514 Ok(output) if output.exit_code == 0 => PathState::Exists,
515 Ok(output) if output.exit_code == 1 => PathState::Missing,
516 Ok(output) => PathState::Unknown(format!(
517 "presence check exited {}: {}",
518 output.exit_code,
519 output.stderr.trim()
520 )),
521 Err(error) => PathState::Unknown(error),
522 }
523 }
524
525 async fn read_bytes(
526 &self,
527 path: &str,
528 offset: Option<u64>,
529 len: Option<u64>,
530 ) -> Result<Vec<u8>, String> {
531 let mut args = json!({ "path": path });
532 if let Some(o) = offset {
533 args["offset"] = json!(o);
534 }
535 if let Some(l) = len {
536 args["length"] = json!(l);
537 }
538 let result = self.call("read_bytes", args).await?;
539 let b64 = match &result {
540 serde_json::Value::String(s) => s.clone(),
541 other => other
542 .get("data")
543 .or_else(|| other.get("base64"))
544 .and_then(|v| v.as_str())
545 .map(|s| s.to_string())
546 .ok_or_else(|| "read_bytes: no base64 data in response".to_string())?,
547 };
548 base64_decode(b64.trim()).map_err(|e| format!("read_bytes: invalid base64: {e}"))
549 }
550
551 async fn write_bytes(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
552 let b64 = base64_encode(bytes);
553 self.call("write_bytes", json!({ "path": path, "content_b64": b64 }))
554 .await
555 .map(|_| ())
556 }
557
558 async fn pty_start(&self, cmd: &str) -> Result<String, String> {
559 let result = self.call("pty_start", json!({ "command": cmd })).await?;
560 if let Some(pid) = result.get("pid").and_then(|v| v.as_i64()) {
565 return Ok(pid.to_string());
566 }
567 let text = match &result {
568 serde_json::Value::String(s) => s.clone(),
569 other => other.to_string(),
570 };
571 text.lines()
572 .find_map(|l| {
573 l.trim_start()
574 .strip_prefix("pid:")
575 .map(|r| r.trim().to_string())
576 })
577 .filter(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
578 .ok_or_else(|| format!("pty_start: no pid in response: {text}"))
579 }
580
581 async fn pty_input(&self, id: &str, data: &str) -> Result<(), String> {
582 self.call("pty_input", json!({ "pid": pty_pid(id)?, "data": data }))
583 .await
584 .map(|_| ())
585 }
586
587 async fn pty_read(&self, id: &str) -> Result<String, String> {
588 let result = self
589 .call("pty_read", json!({ "pid": pty_pid(id)? }))
590 .await?;
591 match result {
592 serde_json::Value::String(s) => Ok(s),
593 other => Ok(other
594 .get("data")
595 .and_then(|v| v.as_str())
596 .map(|s| s.to_string())
597 .unwrap_or_else(|| other.to_string())),
598 }
599 }
600
601 async fn pty_resize(&self, id: &str, rows: u16, cols: u16) -> Result<(), String> {
602 self.call(
603 "pty_resize",
604 json!({ "pid": pty_pid(id)?, "rows": rows, "cols": cols }),
605 )
606 .await
607 .map(|_| ())
608 }
609
610 async fn pty_kill(&self, id: &str) -> Result<(), String> {
611 self.call("pty_kill", json!({ "pid": pty_pid(id)? }))
612 .await
613 .map(|_| ())
614 }
615}
616
617fn pty_pid(id: &str) -> Result<i64, String> {
620 id.trim()
621 .parse::<i64>()
622 .map_err(|_| format!("invalid pty id (expected numeric pid): {id:?}"))
623}
624
625const B64_ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
632
633fn base64_encode(bytes: &[u8]) -> String {
634 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
635 for chunk in bytes.chunks(3) {
636 let b0 = chunk[0] as u32;
637 let b1 = *chunk.get(1).unwrap_or(&0) as u32;
638 let b2 = *chunk.get(2).unwrap_or(&0) as u32;
639 let n = (b0 << 16) | (b1 << 8) | b2;
640 out.push(B64_ALPHABET[((n >> 18) & 0x3f) as usize] as char);
641 out.push(B64_ALPHABET[((n >> 12) & 0x3f) as usize] as char);
642 if chunk.len() > 1 {
643 out.push(B64_ALPHABET[((n >> 6) & 0x3f) as usize] as char);
644 } else {
645 out.push('=');
646 }
647 if chunk.len() > 2 {
648 out.push(B64_ALPHABET[(n & 0x3f) as usize] as char);
649 } else {
650 out.push('=');
651 }
652 }
653 out
654}
655
656fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
657 fn val(c: u8) -> Result<u32, String> {
658 match c {
659 b'A'..=b'Z' => Ok((c - b'A') as u32),
660 b'a'..=b'z' => Ok((c - b'a' + 26) as u32),
661 b'0'..=b'9' => Ok((c - b'0' + 52) as u32),
662 b'+' => Ok(62),
663 b'/' => Ok(63),
664 _ => Err(format!("invalid base64 char {:?}", c as char)),
665 }
666 }
667 let clean: Vec<u8> = s
668 .bytes()
669 .filter(|b| !b.is_ascii_whitespace() && *b != b'=')
670 .collect();
671 let mut out = Vec::with_capacity(clean.len() / 4 * 3);
672 for chunk in clean.chunks(4) {
673 let mut n = 0u32;
674 let mut bits = 0;
675 for &c in chunk {
676 n = (n << 6) | val(c)?;
677 bits += 6;
678 }
679 n <<= 24 - bits;
681 let nbytes = bits / 8;
682 for i in 0..nbytes {
683 out.push(((n >> (16 - i * 8)) & 0xff) as u8);
684 }
685 }
686 Ok(out)
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 #[test]
694 fn base64_roundtrip() {
695 for case in [
696 &b""[..],
697 b"f",
698 b"fo",
699 b"foo",
700 b"foob",
701 b"fooba",
702 b"foobar",
703 &[0u8, 1, 2, 255],
704 ] {
705 let enc = base64_encode(case);
706 let dec = base64_decode(&enc).unwrap();
707 assert_eq!(dec, case, "roundtrip failed for {case:?} (enc={enc})");
708 }
709 assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
710 assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
711 }
712
713 #[tokio::test]
714 async fn local_read_write_roundtrip() {
715 let dir = std::env::temp_dir().join(format!("car-substrate-{}", uuid_like()));
716 std::fs::create_dir_all(&dir).unwrap();
717 let path = dir.join("hello.txt");
718 let path_str = path.to_string_lossy().to_string();
719
720 let sub = LocalSubstrate::new();
721 assert_eq!(sub.name(), "local");
722
723 sub.write_text(&path_str, "hello world").await.unwrap();
724 let read = sub.read_text(&path_str).await.unwrap();
725 assert_eq!(read, "hello world");
726
727 let bytes = sub.read_bytes(&path_str, None, None).await.unwrap();
728 assert_eq!(bytes, b"hello world");
729
730 let window = sub.read_bytes(&path_str, Some(6), Some(5)).await.unwrap();
732 assert_eq!(window, b"world");
733
734 sub.write_bytes(&path_str, b"\x00\x01\x02").await.unwrap();
735 let raw = sub.read_bytes(&path_str, None, None).await.unwrap();
736 assert_eq!(raw, vec![0u8, 1, 2]);
737
738 std::fs::remove_dir_all(&dir).ok();
739 }
740
741 #[cfg(unix)]
742 #[tokio::test]
743 async fn local_path_state_treats_a_dangling_symlink_as_existing() {
744 let dir = std::env::temp_dir().join(format!("car-substrate-state-{}", uuid_like()));
745 std::fs::create_dir_all(&dir).unwrap();
746 let missing = dir.join("missing.txt");
747 let dangling = dir.join("dangling.txt");
748 std::os::unix::fs::symlink(&missing, &dangling).unwrap();
749
750 let sub = LocalSubstrate::new();
751 assert_eq!(
752 sub.path_state(&missing.to_string_lossy()).await,
753 PathState::Missing
754 );
755 assert_eq!(
756 sub.path_state(&dangling.to_string_lossy()).await,
757 PathState::Exists
758 );
759
760 std::fs::remove_dir_all(&dir).ok();
761 }
762
763 #[tokio::test]
764 async fn local_run_command_captures_output() {
765 let sub = LocalSubstrate::new();
766 let out = sub.run_command("echo hi", None).await.unwrap();
767 assert_eq!(out.stdout.trim(), "hi");
768 assert_eq!(out.exit_code, 0);
769 }
770
771 #[tokio::test]
772 async fn local_pty_defaults_to_unsupported() {
773 let sub = LocalSubstrate::new();
774 assert!(sub.pty_start("sh").await.is_err());
775 }
776
777 use crate::mcp::McpToolInfo;
780 use serde_json::Value;
781 use std::sync::Mutex as StdMutex;
782
783 struct MockSession {
784 name: String,
785 calls: Arc<StdMutex<Vec<(String, Value)>>>,
787 files: Arc<StdMutex<std::collections::HashMap<String, String>>>,
788 }
789
790 #[async_trait::async_trait]
791 impl McpSession for MockSession {
792 async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
793 Ok(vec![])
794 }
795 async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, String> {
796 self.calls
797 .lock()
798 .unwrap()
799 .push((name.to_string(), arguments.clone()));
800 match name {
801 "write_text" => {
802 let p = arguments["path"].as_str().unwrap().to_string();
803 let c = arguments["content"].as_str().unwrap().to_string();
804 self.files.lock().unwrap().insert(p, c);
805 Ok(Value::String("ok".into()))
806 }
807 "read_text" => {
808 let p = arguments["path"].as_str().unwrap();
809 let c = self
810 .files
811 .lock()
812 .unwrap()
813 .get(p)
814 .cloned()
815 .ok_or_else(|| "not found".to_string())?;
816 Ok(Value::String(c))
818 }
819 "run_command" => Ok(json!({
820 "stdout": "from-vm",
821 "stderr": "",
822 "exit_code": 0
823 })),
824 "read_bytes" => Ok(Value::String("aGk=".into())),
827 "write_bytes" => Ok(Value::String("ok".into())),
828 "pty_start" => Ok(Value::String("pid: 4242\ncols: 80\nrows: 24".into())),
830 "pty_read" => Ok(Value::String("pty-out".into())),
831 "pty_input" | "pty_resize" | "pty_kill" => Ok(Value::String("ok".into())),
832 _ => Err(format!("unknown tool {name}")),
833 }
834 }
835 fn name(&self) -> &str {
836 &self.name
837 }
838 }
839
840 #[tokio::test]
841 async fn mcp_substrate_routes_to_session() {
842 let calls = Arc::new(StdMutex::new(Vec::new()));
843 let files = Arc::new(StdMutex::new(std::collections::HashMap::new()));
844 let mock = MockSession {
845 name: "vm".into(),
846 calls: calls.clone(),
847 files: files.clone(),
848 };
849 let session: Arc<Mutex<dyn McpSession>> = Arc::new(Mutex::new(mock));
850 let sub = McpSubstrate::new(session, "vm");
851
852 assert_eq!(sub.name(), "vm");
853
854 sub.write_text("/tmp/a.txt", "vm-content").await.unwrap();
855 let read = sub.read_text("/tmp/a.txt").await.unwrap();
856 assert_eq!(read, "vm-content");
857
858 let out = sub.run_command("ls", None).await.unwrap();
859 assert_eq!(out.stdout, "from-vm");
860 assert_eq!(out.exit_code, 0);
861
862 let recorded = calls.lock().unwrap();
864 let names: Vec<&str> = recorded.iter().map(|(n, _)| n.as_str()).collect();
865 assert_eq!(names, vec!["write_text", "read_text", "run_command"]);
866 }
867
868 #[tokio::test]
876 async fn mcp_substrate_wire_args_match_canonical_bridge() {
877 let calls = Arc::new(StdMutex::new(Vec::new()));
878 let files = Arc::new(StdMutex::new(std::collections::HashMap::new()));
879 let mock = MockSession {
880 name: "vm".into(),
881 calls: calls.clone(),
882 files: files.clone(),
883 };
884 let session: Arc<Mutex<dyn McpSession>> = Arc::new(Mutex::new(mock));
885 let sub = McpSubstrate::new(session, "vm");
886
887 sub.run_command("ls", Some(5.0)).await.unwrap();
888 let bytes = sub.read_bytes("/f", Some(2), Some(4)).await.unwrap();
889 assert_eq!(bytes, b"hi"); sub.write_bytes("/f", b"hi").await.unwrap();
891 let pid = sub.pty_start("bash").await.unwrap();
893 assert_eq!(pid, "4242");
894 sub.pty_input(&pid, "echo\n").await.unwrap();
895 let out = sub.pty_read(&pid).await.unwrap();
896 assert_eq!(out, "pty-out");
897 sub.pty_resize(&pid, 40, 100).await.unwrap();
898 sub.pty_kill(&pid).await.unwrap();
899
900 let recorded = calls.lock().unwrap();
901 let by_tool =
902 |t: &str| -> Value { recorded.iter().find(|(n, _)| n == t).unwrap().1.clone() };
903
904 assert_eq!(
905 by_tool("run_command"),
906 json!({"command": "ls", "timeout": 5.0})
907 );
908 assert_eq!(
909 by_tool("read_bytes"),
910 json!({"path": "/f", "offset": 2, "length": 4})
911 );
912 let wb = by_tool("write_bytes");
914 assert!(
915 wb.get("content_b64").is_some(),
916 "write_bytes must use content_b64: {wb}"
917 );
918 assert!(
919 wb.get("data").is_none(),
920 "write_bytes must not use legacy `data`: {wb}"
921 );
922 assert_eq!(wb["path"], json!("/f"));
923 assert_eq!(by_tool("pty_start"), json!({"command": "bash"}));
924 assert_eq!(by_tool("pty_input"), json!({"pid": 4242, "data": "echo\n"}));
926 assert_eq!(by_tool("pty_read"), json!({"pid": 4242}));
927 assert_eq!(
928 by_tool("pty_resize"),
929 json!({"pid": 4242, "rows": 40, "cols": 100})
930 );
931 assert_eq!(by_tool("pty_kill"), json!({"pid": 4242}));
932 }
933
934 fn uuid_like() -> String {
935 use std::time::{SystemTime, UNIX_EPOCH};
936 let nanos = SystemTime::now()
937 .duration_since(UNIX_EPOCH)
938 .unwrap()
939 .as_nanos();
940 format!("{nanos}")
941 }
942
943 struct TimeoutSpySession {
947 last: Arc<StdMutex<Option<Option<Duration>>>>,
948 }
949
950 #[async_trait::async_trait]
951 impl McpSession for TimeoutSpySession {
952 async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
953 Ok(vec![])
954 }
955 async fn call_tool(&mut self, _n: &str, _a: Value) -> Result<Value, String> {
956 *self.last.lock().unwrap() = Some(None);
959 Ok(json!({"stdout": "x", "stderr": "", "exit_code": 0}))
960 }
961 async fn call_tool_with_timeout(
962 &mut self,
963 _n: &str,
964 _a: Value,
965 t: Option<Duration>,
966 ) -> Result<Value, String> {
967 *self.last.lock().unwrap() = Some(t);
968 Ok(json!({"stdout": "x", "stderr": "", "exit_code": 0}))
969 }
970 fn name(&self) -> &str {
971 "vm"
972 }
973 }
974
975 #[tokio::test]
976 async fn run_command_threads_its_timeout_else_backstop() {
977 let last = Arc::new(StdMutex::new(None));
978 let sub = McpSubstrate::new(
979 Arc::new(Mutex::new(TimeoutSpySession { last: last.clone() })),
980 "vm",
981 );
982
983 sub.run_command("echo hi", Some(120.0)).await.unwrap();
985 assert_eq!(
986 *last.lock().unwrap(),
987 Some(Some(Duration::from_secs_f64(150.0))),
988 "run_command must thread its own timeout (+margin) to the session",
989 );
990
991 sub.run_command("echo hi", None).await.unwrap();
993 assert_eq!(*last.lock().unwrap(), Some(None));
994
995 let _ = sub.read_text("/p").await;
997 assert_eq!(*last.lock().unwrap(), Some(None));
998 }
999
1000 struct FlakySession {
1002 err: String,
1003 succeed_at: u32,
1004 attempts: Arc<StdMutex<u32>>,
1005 }
1006
1007 #[async_trait::async_trait]
1008 impl McpSession for FlakySession {
1009 async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
1010 Ok(vec![])
1011 }
1012 async fn call_tool(&mut self, n: &str, a: Value) -> Result<Value, String> {
1013 self.call_tool_with_timeout(n, a, None).await
1014 }
1015 async fn call_tool_with_timeout(
1016 &mut self,
1017 _n: &str,
1018 _a: Value,
1019 _t: Option<Duration>,
1020 ) -> Result<Value, String> {
1021 let n = {
1022 let mut a = self.attempts.lock().unwrap();
1023 *a += 1;
1024 *a
1025 };
1026 if n >= self.succeed_at {
1027 Ok(json!({"stdout": "ok", "stderr": "", "exit_code": 0}))
1028 } else {
1029 Err(self.err.clone())
1030 }
1031 }
1032 fn name(&self) -> &str {
1033 "vm"
1034 }
1035 }
1036
1037 fn flaky(err: &str, succeed_at: u32) -> (McpSubstrate, Arc<StdMutex<u32>>) {
1038 let attempts = Arc::new(StdMutex::new(0));
1039 let sub = McpSubstrate::new(
1040 Arc::new(Mutex::new(FlakySession {
1041 err: err.to_string(),
1042 succeed_at,
1043 attempts: attempts.clone(),
1044 })),
1045 "vm",
1046 );
1047 (sub, attempts)
1048 }
1049
1050 #[tokio::test]
1051 async fn transient_connection_drop_is_retried_and_recovers() {
1052 let (sub, attempts) = flaky("fetch failed", 2);
1054 let out = sub.run_command("echo hi", None).await.unwrap();
1055 assert_eq!(out.stdout, "ok");
1056 assert_eq!(*attempts.lock().unwrap(), 2, "retried once then succeeded");
1057 }
1058
1059 #[tokio::test]
1060 async fn persistent_connection_drop_exhausts_and_is_tagged() {
1061 let (sub, attempts) = flaky("fetch failed", u32::MAX);
1062 let err = sub.run_command("echo hi", None).await.unwrap_err();
1063 assert!(
1064 err.starts_with(SUBSTRATE_TRANSPORT_ERR_PREFIX),
1065 "tagged: {err}"
1066 );
1067 assert_eq!(*attempts.lock().unwrap(), SUBSTRATE_RETRY_ATTEMPTS);
1068 }
1069
1070 #[tokio::test]
1071 async fn timeout_is_not_retried_but_is_tagged() {
1072 let (sub, attempts) = flaky("MCP request 'tools/call' timed out", u32::MAX);
1074 let err = sub.run_command("echo hi", None).await.unwrap_err();
1075 assert!(
1076 err.starts_with(SUBSTRATE_TRANSPORT_ERR_PREFIX),
1077 "tagged: {err}"
1078 );
1079 assert_eq!(*attempts.lock().unwrap(), 1, "timeout must not be retried");
1080 }
1081
1082 #[tokio::test]
1083 async fn task_error_is_not_retried_or_tagged() {
1084 let (sub, attempts) = flaky("no such file", u32::MAX);
1086 let err = sub.run_command("cat /nope", None).await.unwrap_err();
1087 assert!(
1088 !err.starts_with(SUBSTRATE_TRANSPORT_ERR_PREFIX),
1089 "not tagged: {err}"
1090 );
1091 assert_eq!(
1092 *attempts.lock().unwrap(),
1093 1,
1094 "task errors must not be retried"
1095 );
1096 }
1097}