use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Write};
use std::os::unix::process::CommandExt;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc;
use std::time::Duration;
static GLOBAL_ID: AtomicU64 = AtomicU64::new(1);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
pub struct McpClient {
child: Child,
rx: mpsc::Receiver<String>,
stdin: Option<std::process::ChildStdin>,
#[allow(dead_code)]
pub backend: String,
}
impl McpClient {
pub fn new(backend: &str) -> Self {
let binary = std::env::var("FERRIDRIVER_BIN").unwrap_or_else(|_| {
let base = format!("{}/../../target", env!("CARGO_MANIFEST_DIR"));
let debug = format!("{base}/debug/ferridriver");
let release = format!("{base}/release/ferridriver");
if std::path::Path::new(&debug).exists() {
debug
} else {
release
}
});
let mut cmd = Command::new(&binary);
cmd.arg("mcp").arg("--backend").arg(backend);
if std::env::var("FERRIDRIVER_HEADED").is_err() {
cmd.arg("--headless");
}
let stderr_target = match std::env::var("FERRIDRIVER_MCP_STDERR_LOG") {
Ok(path) => {
let f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.unwrap_or_else(|e| panic!("open MCP stderr log {path}: {e}"));
Stdio::from(f)
},
Err(_) => Stdio::null(),
};
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(stderr_target)
.env(
"RUST_LOG",
std::env::var("RUST_LOG").unwrap_or_else(|_| "ferridriver=debug,ferridriver_mcp=debug".into()),
)
.process_group(0)
.spawn()
.unwrap_or_else(|e| panic!("Failed to start: {binary}: {e}"));
let stdout = child.stdout.take().unwrap();
let stdin = child.stdin.take().unwrap();
let (tx, rx) = mpsc::channel();
let backend_for_thread = backend.to_string();
std::thread::Builder::new()
.name(format!("mcp-stdout-reader-{backend}"))
.spawn(move || {
let mut reader = BufReader::new(stdout);
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {
if tx.send(line).is_err() {
break;
}
},
Err(e) => {
let _ = tx.send(format!("__READ_ERR__ backend={backend_for_thread}: {e}"));
break;
},
}
}
})
.expect("spawn mcp stdout reader thread");
let mut c = McpClient {
child,
rx,
stdin: Some(stdin),
backend: backend.to_string(),
};
c.initialize();
c.send_initialized_notification();
c
}
fn send_raw(&mut self, msg: &Value) {
let stdin = self.stdin.as_mut().expect("stdin already closed");
writeln!(stdin, "{}", serde_json::to_string(msg).unwrap()).unwrap();
stdin.flush().unwrap();
}
fn read_response_with_deadline(&mut self, ctx: &str, deadline: std::time::Instant) -> Value {
loop {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
assert!(
!remaining.is_zero(),
"MCP request timed out after {REQUEST_TIMEOUT:?} (backend={}, {ctx})",
self.backend
);
let line = match self.rx.recv_timeout(remaining) {
Ok(l) => l,
Err(mpsc::RecvTimeoutError::Timeout) => {
panic!(
"MCP request timed out after {REQUEST_TIMEOUT:?} (backend={}, {ctx})",
self.backend
);
},
Err(mpsc::RecvTimeoutError::Disconnected) => {
panic!(
"ferridriver MCP child closed stdout before responding (backend={}, {ctx}). \
Check $FERRIDRIVER_MCP_STDERR_LOG for child stderr.",
self.backend
);
},
};
assert!(
!line.starts_with("__READ_ERR__"),
"MCP stdout read error (backend={}, {ctx}): {}",
self.backend,
line.trim()
);
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(val) = serde_json::from_str::<Value>(trimmed) {
return val;
}
}
}
pub fn send_request(&mut self, method: &str, params: Value) -> Value {
let id = GLOBAL_ID.fetch_add(1, Ordering::SeqCst);
let tool = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
let start = std::time::Instant::now();
let deadline = start + REQUEST_TIMEOUT;
let trace = std::env::var("FERRIDRIVER_TEST_VERBOSE").is_ok();
if trace {
eprintln!(">>> [{}] id={id} method={method} tool={tool}", self.backend);
}
self.send_raw(&json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}));
loop {
let ctx = format!("id={id} method={method} tool={tool}");
let resp = self.read_response_with_deadline(&ctx, deadline);
if resp.get("id").and_then(|v| v.as_u64()) == Some(id) {
if trace {
eprintln!(
"<<< [{}] id={id} method={method} tool={tool} ms={}",
self.backend,
start.elapsed().as_millis()
);
}
return resp;
}
}
}
fn initialize(&mut self) -> Value {
self.send_request(
"initialize",
json!({
"protocolVersion":"2024-11-05","capabilities":{},
"clientInfo":{"name":"test","version":"1.0.0"}
}),
)
}
fn send_initialized_notification(&mut self) {
self.send_raw(&json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
}
pub fn call_tool(&mut self, name: &str, args: Value) -> Value {
self.send_request("tools/call", json!({"name":name,"arguments":args}))
}
pub fn tool_text(&mut self, name: &str, args: Value) -> String {
extract_text(&self.call_tool(name, args))
}
pub fn nav(&mut self, html: &str) {
self.call_tool("navigate", json!({"url": data_url(html)}));
}
pub fn nav_url(&mut self, url: &str) {
self.call_tool("navigate", json!({"url": url}));
}
pub fn script(&mut self, source: &str) -> Value {
self.script_with_args(source, json!([]))
}
pub fn script_with_args(&mut self, source: &str, args: Value) -> Value {
let resp = self.call_tool("run_script", json!({"source": source, "args": args}));
ok(&resp, "run_script");
extract_script_payload(&resp).expect("script response should carry a JSON payload")
}
pub fn script_value(&mut self, source: &str) -> Value {
let payload = self.script(source);
assert_eq!(payload["status"].as_str(), Some("ok"), "script failed: {payload}");
payload["value"].clone()
}
pub fn script_value_with_args(&mut self, source: &str, args: Value) -> Value {
let payload = self.script_with_args(source, args);
assert_eq!(payload["status"].as_str(), Some("ok"), "script failed: {payload}");
payload["value"].clone()
}
pub fn script_with_timeout(&mut self, source: &str, timeout_ms: u64) -> Value {
let resp = self.call_tool(
"run_script",
json!({"source": source, "args": [], "timeout_ms": timeout_ms}),
);
ok(&resp, "run_script");
extract_script_payload(&resp).expect("script response should carry a JSON payload")
}
}
impl Drop for McpClient {
fn drop(&mut self) {
drop(self.stdin.take());
#[allow(clippy::cast_possible_wrap)]
let pgid_arg = -(self.child.id() as i32);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
let mut graceful = false;
loop {
match self.child.try_wait() {
Ok(Some(_)) => {
graceful = true;
break;
},
Ok(None) if std::time::Instant::now() < deadline => {
std::thread::sleep(std::time::Duration::from_millis(25));
},
_ => break,
}
}
if !graceful {
#[allow(unsafe_code)]
unsafe {
libc::kill(pgid_arg, libc::SIGTERM);
}
let term_deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
while std::time::Instant::now() < term_deadline {
match self.child.try_wait() {
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(25)),
_ => break,
}
}
}
#[allow(unsafe_code)]
unsafe {
libc::kill(pgid_arg, libc::SIGKILL);
}
let _ = self.child.wait();
let _ = std::process::Command::new("pkill")
.args(["-9", "-f", "ferridriver-pipe-|ferridriver-raw-|ferridriver-firefox-"])
.stderr(Stdio::null())
.stdout(Stdio::null())
.status();
}
}
pub fn data_url(html: &str) -> String {
format!("data:text/html,{}", urlenc(html))
}
pub fn urlenc(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'!' | b'\'' | b'(' | b')' | b'*' => {
out.push(b as char)
},
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
pub fn extract_text(resp: &Value) -> String {
resp["result"]["content"]
.as_array()
.and_then(|a| a.first())
.and_then(|c| c["text"].as_str())
.unwrap_or("")
.to_string()
}
pub fn extract_image_b64(resp: &Value) -> String {
resp["result"]["content"]
.as_array()
.and_then(|a| a.iter().find(|c| c["type"].as_str() == Some("image")))
.and_then(|c| c["data"].as_str())
.unwrap_or("")
.to_string()
}
pub fn extract_script_payload(resp: &Value) -> Option<Value> {
let contents = resp["result"]["content"].as_array()?;
for c in contents {
if let Some(text) = c["text"].as_str() {
if let Ok(parsed) = serde_json::from_str::<Value>(text) {
if parsed.get("status").is_some() {
return Some(parsed);
}
}
}
}
None
}
pub fn is_error(resp: &Value) -> bool {
resp.get("error").is_some() || resp["result"]["isError"].as_bool().unwrap_or(false)
}
pub fn ok(resp: &Value, ctx: &str) {
assert!(!is_error(resp), "{ctx} failed: {resp}");
}