rust-fs-mcp 0.2.0

Rust stdio MCP server compatible with fs-mcp public tool contracts.
Documentation
//! external.rs
//! core::external
//!
//! Wrapper that spawns external CLIs (ripgrep, fd, git) resolved from PATH and collects stdout / stderr.
//! Uses reader threads and an mpsc channel to handle EOF and timeout in a single loop.
//!

use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ExternalTool {
  Rg,
  Fd,
  Git,
  Obscura,
}
impl ExternalTool {
  // Command name resolved from PATH. The system PATH binary is invoked directly.
  fn command_name(self) -> &'static str {
    match self {
      Self::Rg => "rg",
      Self::Fd => "fd",
      Self::Git => "git",
      Self::Obscura => "obscura",
    }
  }
  pub fn backend_name(self) -> &'static str {
    match self {
      Self::Rg => "path-rg",
      Self::Fd => "path-fd",
      Self::Git => "path-git",
      Self::Obscura => "path-obscura",
    }
  }
  // obscura is a fixed-path headless-browser CLI, not a PATH tool. Prefer the env override,
  // then the known install path, then fall back to a bare PATH lookup for portability.
  fn resolve_command(self) -> String {
    match self {
      Self::Obscura => {
        if let Some(path) = crate::core::config::env_value("OBSCURA_BIN") {
          if !path.trim().is_empty() {
            return path;
          }
        }
        let default = "C:/JUNGHO/0.Tools/obscura.exe";
        if Path::new(default).exists() {
          default.to_string()
        }
        else {
        	self.command_name().to_string()
        }
      }
      other => other.command_name().to_string(),
    }
  }
}
#[derive(Debug)]
pub struct ToolOutput {
  pub status_code: Option<i32>,
  pub stdout: String,
  pub stderr: String,
  pub backend: &'static str,
}
// Runs a PATH command and returns its collected stdout / stderr.
pub fn run_external(tool: ExternalTool, args: &[String], cwd: Option<&Path>, timeout_ms: Option<u64>) -> Result<ToolOutput, String> {
  let timeout = Duration::from_millis(timeout_ms.unwrap_or(120_000));
  run_path(tool, args, cwd, timeout)
}
fn run_path(tool: ExternalTool, args: &[String], cwd: Option<&Path>, timeout: Duration) -> Result<ToolOutput, String> {
  let mut command = Command::new(tool.resolve_command());
  command.args(args);
  command.stdin(Stdio::null());
  command.stdout(Stdio::piped());
  command.stderr(Stdio::piped());

  if let Some(cwd) = cwd {
    command.current_dir(cwd);
  }
  let mut child = command.spawn().map_err(|error| format!("Failed to start {} from PATH: {error}. Ensure '{}' is installed and on PATH.", tool.backend_name(), tool.command_name()))?;
  let stdout = child.stdout.take().ok_or_else(|| "Failed to capture stdout".to_string())?;
  let stderr = child.stderr.take().ok_or_else(|| "Failed to capture stderr".to_string())?;
  // Drops the 5ms busy-poll (`try_wait + sleep`) and uses a channel so reader threads
  // wake the main loop immediately on pipe EOF. Once both pipes close the child is effectively
  // done so child.wait() returns at once. Timeout is handled by mpsc's recv_timeout in one go.
  let (done_tx, done_rx) = mpsc::channel::<()>();
  let stdout_done = done_tx.clone();
  let stderr_done = done_tx.clone();
  drop(done_tx);
  let stdout_handle = thread::spawn(move || {
    let result = read_pipe(stdout);
    let _ = stdout_done.send(());
    result
  });
  let stderr_handle = thread::spawn(move || {
    let result = read_pipe(stderr);
    let _ = stderr_done.send(());
    result
  });

  let deadline = Instant::now() + timeout;
  let mut closed = 0usize;
  let mut timed_out = false;
  while closed < 2 {
    let remaining = deadline.saturating_duration_since(Instant::now());
    if remaining.is_zero() {
      timed_out = true;
      break;
    }
    match done_rx.recv_timeout(remaining) {
      Ok(()) => closed += 1,
      Err(mpsc::RecvTimeoutError::Timeout) => {
        timed_out = true;
        break;
      }
      Err(mpsc::RecvTimeoutError::Disconnected) => break,
    }
  }
  if timed_out {
    let _ = child.kill();
    let _ = child.wait();
    return Err(format!("{} timed out after {}ms", tool.backend_name(), timeout.as_millis()));
  }
  let status = match child.wait() {
    Ok(status) => status,
    Err(error) => {
      return Err(format!("Failed to wait for {}: {error}", tool.backend_name()));
    }
  };

  let stdout = join_pipe(stdout_handle)?;
  let stderr = join_pipe(stderr_handle)?;

  Ok(ToolOutput { status_code: status.code(), stdout: String::from_utf8_lossy(&stdout).into_owned(), stderr: String::from_utf8_lossy(&stderr).into_owned(), backend: tool.backend_name() })
}
fn read_pipe<R: Read>(mut pipe: R) -> std::io::Result<Vec<u8>> {
  let mut bytes = Vec::new();
  pipe.read_to_end(&mut bytes)?;
  Ok(bytes)
}
fn join_pipe(handle: thread::JoinHandle<std::io::Result<Vec<u8>>>) -> Result<Vec<u8>, String> {
  handle.join().map_err(|_| "Failed to join external tool reader thread".to_string())?.map_err(|error| format!("Failed to read external tool output: {error}"))
}