use std::process::{ExitStatus, Stdio};
use std::time::Duration;
use async_trait::async_trait;
use tokio::io::AsyncWriteExt;
use tokio::process::{Child, Command};
use super::transport::ClientTransport;
use crate::error::{Error, Result};
use crate::framing::{FrameReader, InputFrame, clean_input_line};
const DEFAULT_CLOSE_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(unix)]
const GROUP_POLL_INTERVAL: Duration = Duration::from_millis(20);
pub struct StdioClientTransport {
child: Option<Child>,
pid: Option<u32>,
exit_status: Option<ExitStatus>,
close_timeout: Duration,
#[cfg_attr(not(unix), allow(dead_code))]
process_group_shutdown: bool,
stdin: Option<tokio::process::ChildStdin>,
stdout: FrameReader<tokio::process::ChildStdout>,
}
impl StdioClientTransport {
pub async fn spawn(program: &str, args: &[&str]) -> Result<Self> {
let mut cmd = Command::new(program);
cmd.args(args);
Self::spawn_command(&mut cmd).await
}
pub async fn spawn_command(cmd: &mut Command) -> Result<Self> {
cmd.stdin(Stdio::piped()).stdout(Stdio::piped());
cmd.kill_on_drop(true);
let mut child = cmd
.spawn()
.map_err(|e| Error::Transport(format!("Failed to spawn process: {}", e)))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| Error::Transport("Failed to get child stdin".to_string()))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| Error::Transport("Failed to get child stdout".to_string()))?;
tracing::info!("Spawned MCP server process");
Ok(Self::from_parts(child, stdin, stdout))
}
fn from_parts(
child: Child,
stdin: tokio::process::ChildStdin,
stdout: tokio::process::ChildStdout,
) -> Self {
Self {
pid: child.id(),
child: Some(child),
exit_status: None,
close_timeout: DEFAULT_CLOSE_TIMEOUT,
process_group_shutdown: false,
stdin: Some(stdin),
stdout: FrameReader::with_max_len(
stdout,
crate::framing::DEFAULT_MAX_RESPONSE_FRAME_LEN,
),
}
}
pub fn max_frame_len(mut self, bytes: usize) -> Self {
self.stdout.set_max_len(bytes);
self
}
pub fn take_stderr(&mut self) -> Option<tokio::process::ChildStderr> {
self.child.as_mut()?.stderr.take()
}
pub fn from_child(mut child: Child) -> Result<Self> {
let stdin = child
.stdin
.take()
.ok_or_else(|| Error::Transport("Failed to get child stdin".to_string()))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| Error::Transport("Failed to get child stdout".to_string()))?;
Ok(Self::from_parts(child, stdin, stdout))
}
pub fn close_timeout(mut self, timeout: Duration) -> Self {
self.close_timeout = timeout;
self
}
pub fn process_group_shutdown(mut self, enabled: bool) -> Self {
self.process_group_shutdown = enabled;
self
}
pub fn child_id(&self) -> Option<u32> {
self.pid
}
pub fn exit_status(&self) -> Option<ExitStatus> {
self.exit_status
}
}
#[cfg(unix)]
mod pgroup {
use libc::{c_int, pid_t};
pub(super) fn own_group_of(pid: u32) -> Option<pid_t> {
let pid = pid_t::try_from(pid).ok()?;
(unsafe { libc::getpgid(pid) } == pid).then_some(pid)
}
pub(super) fn signal(pgid: pid_t, signal: c_int) -> bool {
unsafe { libc::killpg(pgid, signal) == 0 }
}
}
#[cfg(unix)]
async fn stop_process_group(
child: &mut Child,
pgid: libc::pid_t,
grace: Duration,
status: &mut Option<ExitStatus>,
) {
use tokio::time::{Instant, sleep, timeout};
if !pgroup::signal(pgid, libc::SIGTERM) {
return;
}
let deadline = Instant::now() + grace;
loop {
if status.is_none() {
if let Ok(Some(exit)) = child.try_wait() {
*status = Some(exit);
}
}
if status.is_some() && !pgroup::signal(pgid, 0) {
return;
}
if Instant::now() >= deadline {
break;
}
sleep(GROUP_POLL_INTERVAL).await;
}
tracing::warn!("Process group did not exit after SIGTERM, sending SIGKILL");
pgroup::signal(pgid, libc::SIGKILL);
if status.is_none() {
match timeout(grace, child.wait()).await {
Ok(Ok(exit)) => *status = Some(exit),
Ok(Err(e)) => tracing::error!(error = %e, "Error waiting for child"),
Err(_) => tracing::error!("Child did not exit after SIGKILL"),
}
}
}
#[async_trait]
impl ClientTransport for StdioClientTransport {
async fn send(&mut self, message: &str) -> Result<()> {
let stdin = self
.stdin
.as_mut()
.ok_or_else(|| Error::Transport("Transport closed".to_string()))?;
stdin
.write_all(message.as_bytes())
.await
.map_err(|e| Error::Transport(format!("Failed to write: {}", e)))?;
stdin
.write_all(b"\n")
.await
.map_err(|e| Error::Transport(format!("Failed to write newline: {}", e)))?;
stdin
.flush()
.await
.map_err(|e| Error::Transport(format!("Failed to flush: {}", e)))?;
Ok(())
}
async fn recv(&mut self) -> Result<Option<String>> {
loop {
let Some(frame) = self
.stdout
.next_frame()
.await
.map_err(|e| Error::Transport(format!("Failed to read: {}", e)))?
else {
return Ok(None);
};
match frame {
InputFrame::Line(line) => {
return Ok(Some(clean_input_line(&line).to_string()));
}
InputFrame::Undecodable => {
tracing::warn!(
"invalid UTF-8 in a frame from the server, discarding it; \
a response carried in it will not arrive and its request \
will wait for its timeout"
);
}
}
}
}
fn is_connected(&self) -> bool {
self.child.is_some() && self.stdin.is_some()
}
async fn close(&mut self) -> Result<()> {
#[cfg(unix)]
let group = if self.process_group_shutdown {
let group = self.pid.and_then(pgroup::own_group_of);
if group.is_none() {
tracing::warn!(
"process group shutdown is enabled but the child does not lead its own \
process group (spawn it with `process_group(0)`); stopping the child only"
);
}
group
} else {
None
};
#[cfg(not(unix))]
let group: Option<()> = None;
self.stdin.take();
if let Some(mut child) = self.child.take() {
let result = tokio::time::timeout(self.close_timeout, child.wait()).await;
match result {
Ok(Ok(status)) => {
tracing::info!(status = ?status, "Child process exited");
self.exit_status = Some(status);
}
Ok(Err(e)) => {
tracing::error!(error = %e, "Error waiting for child");
}
Err(_) if group.is_some() => {
tracing::warn!("Timeout waiting for child, stopping its process group");
}
Err(_) => {
tracing::warn!("Timeout waiting for child, killing");
let _ = child.kill().await;
self.exit_status = child.try_wait().ok().flatten();
}
}
#[cfg(unix)]
if let Some(pgid) = group {
stop_process_group(&mut child, pgid, self.close_timeout, &mut self.exit_status)
.await;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncBufReadExt, BufReader};
#[tokio::test]
async fn test_spawn_nonexistent_program() {
let result = StdioClientTransport::spawn("nonexistent-program-xyz", &[]).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_send_and_recv_via_cat() {
let mut transport = StdioClientTransport::spawn("cat", &[]).await.unwrap();
assert!(transport.is_connected());
let msg = r#"{"jsonrpc":"2.0","id":1,"method":"test"}"#;
transport.send(msg).await.unwrap();
let received = transport.recv().await.unwrap();
assert_eq!(received.as_deref(), Some(msg));
}
#[tokio::test]
async fn test_close_signals_eof() {
let mut transport = StdioClientTransport::spawn("cat", &[]).await.unwrap();
assert!(transport.is_connected());
transport.close().await.unwrap();
assert!(!transport.is_connected());
}
#[cfg(unix)]
#[tokio::test]
async fn dropping_the_transport_kills_the_child() {
use std::time::{Duration, Instant};
let ticks = std::env::temp_dir().join(format!(
"tower-mcp-kill-on-drop-{}-{}.ticks",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_file(&ticks);
let mut cmd = Command::new("sh");
cmd.args([
"-c",
&format!(
"trap '' TERM; while :; do printf x >> '{}'; sleep 0.1; done",
ticks.display()
),
]);
let transport = StdioClientTransport::spawn_command(&mut cmd).await.unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
let size = |p: &std::path::Path| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
while size(&ticks) == 0 {
assert!(
Instant::now() < deadline,
"child never started writing; the test cannot tell life from death"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
drop(transport);
tokio::time::sleep(Duration::from_millis(600)).await;
let settled = size(&ticks);
tokio::time::sleep(Duration::from_millis(600)).await;
let later = size(&ticks);
let _ = std::fs::remove_file(&ticks);
assert_eq!(
settled, later,
"child kept running after its transport was dropped ({settled} -> {later} bytes)"
);
}
#[cfg(unix)]
fn scratch_file(tag: &str) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!(
"tower-mcp-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_file(&path);
path
}
#[cfg(unix)]
async fn read_pid(path: &std::path::Path) -> i32 {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
if let Ok(text) = std::fs::read_to_string(path)
&& let Ok(pid) = text.trim().parse()
{
return pid;
}
assert!(
std::time::Instant::now() < deadline,
"child never wrote the background pid"
);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
#[cfg(unix)]
async fn is_gone(pid: i32) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while std::time::Instant::now() < deadline {
if unsafe { libc::kill(pid, 0) } != 0 {
return true;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
false
}
#[cfg(unix)]
fn reap_leftover(pid: i32) {
unsafe { libc::kill(pid, libc::SIGKILL) };
}
#[cfg(unix)]
#[tokio::test]
async fn process_group_shutdown_stops_the_childs_descendants() {
use std::time::{Duration, Instant};
let pidfile = scratch_file("pgroup-pid");
let mut cmd = Command::new("sh");
cmd.args([
"-c",
&format!("sleep 60 & echo $! > '{}'; sleep 60", pidfile.display()),
])
.process_group(0);
let mut transport = StdioClientTransport::spawn_command(&mut cmd)
.await
.unwrap()
.close_timeout(Duration::from_millis(300))
.process_group_shutdown(true);
let background = read_pid(&pidfile).await;
let started = Instant::now();
tokio::time::timeout(Duration::from_secs(10), transport.close())
.await
.expect("close must not hang")
.unwrap();
let elapsed = started.elapsed();
let gone = is_gone(background).await;
reap_leftover(background);
let _ = std::fs::remove_file(&pidfile);
assert!(gone, "the background process outlived close");
assert!(
elapsed < Duration::from_secs(3),
"SIGTERM should end the group without waiting out SIGKILL, took {elapsed:?}"
);
assert!(!transport.exit_status().unwrap().success());
}
#[cfg(unix)]
#[tokio::test]
async fn process_group_shutdown_stops_descendants_of_a_child_that_exited() {
use std::time::Duration;
let pidfile = scratch_file("pgroup-clean-pid");
let mut cmd = Command::new("sh");
cmd.args([
"-c",
&format!(
"sleep 60 & echo $! > '{}'; read line; exit 0",
pidfile.display()
),
])
.process_group(0);
let mut transport = StdioClientTransport::spawn_command(&mut cmd)
.await
.unwrap()
.process_group_shutdown(true);
let background = read_pid(&pidfile).await;
tokio::time::timeout(Duration::from_secs(10), transport.close())
.await
.expect("close must not hang")
.unwrap();
let gone = is_gone(background).await;
reap_leftover(background);
let _ = std::fs::remove_file(&pidfile);
assert!(gone, "the background process outlived a clean exit");
assert_eq!(transport.exit_status().unwrap().code(), Some(0));
}
#[cfg(unix)]
#[tokio::test]
async fn process_group_shutdown_ignores_a_child_in_the_callers_group() {
use std::time::Duration;
let mut cmd = Command::new("sh");
cmd.args(["-c", "while :; do sleep 0.1; done"]);
let mut transport = StdioClientTransport::spawn_command(&mut cmd)
.await
.unwrap()
.close_timeout(Duration::from_millis(200))
.process_group_shutdown(true);
tokio::time::timeout(Duration::from_secs(10), transport.close())
.await
.expect("close must not hang")
.unwrap();
assert!(!transport.exit_status().unwrap().success());
}
#[cfg(unix)]
#[tokio::test]
async fn close_timeout_bounds_the_wait_for_the_child() {
use std::os::unix::process::ExitStatusExt;
use std::time::{Duration, Instant};
let mut cmd = Command::new("sh");
cmd.args(["-c", "while :; do sleep 0.1; done"]);
let mut transport = StdioClientTransport::spawn_command(&mut cmd)
.await
.unwrap()
.close_timeout(Duration::from_millis(200));
let started = Instant::now();
transport.close().await.unwrap();
let elapsed = started.elapsed();
assert!(
elapsed >= Duration::from_millis(200) && elapsed < Duration::from_secs(1),
"close should wait the configured 200ms, then kill; took {elapsed:?}"
);
assert_eq!(
transport.exit_status().unwrap().signal(),
Some(libc::SIGKILL)
);
}
#[cfg(unix)]
#[tokio::test]
async fn exit_status_is_readable_after_close() {
let mut cmd = Command::new("sh");
cmd.args(["-c", "read line; exit 3"]);
let mut transport = StdioClientTransport::spawn_command(&mut cmd).await.unwrap();
assert!(transport.exit_status().is_none());
transport.close().await.unwrap();
assert_eq!(transport.exit_status().unwrap().code(), Some(3));
}
#[tokio::test]
async fn child_id_is_available_before_and_after_close() {
let mut transport = StdioClientTransport::spawn("cat", &[]).await.unwrap();
let pid = transport.child_id().expect("a spawned child has a pid");
transport.close().await.unwrap();
assert_eq!(transport.child_id(), Some(pid));
}
#[tokio::test]
async fn test_recv_returns_none_on_eof() {
let mut transport = StdioClientTransport::spawn("true", &[]).await.unwrap();
let result = transport.recv().await.unwrap();
assert_eq!(result, None);
}
async fn spawn_writer(script: &str) -> StdioClientTransport {
let mut cmd = Command::new("sh");
cmd.args(["-c", script]);
StdioClientTransport::spawn_command(&mut cmd).await.unwrap()
}
#[tokio::test]
async fn an_undecodable_frame_is_skipped_and_the_next_one_is_delivered() {
let response = r#"{"jsonrpc":"2.0","id":1,"result":{}}"#;
let mut transport =
spawn_writer(r#"printf '\377\376\n'; printf '{"jsonrpc":"2.0","id":1,"result":{}}\n'"#)
.await;
assert_eq!(
transport.recv().await.unwrap().as_deref(),
Some(response),
"the frame after a bad one must still be delivered"
);
}
#[tokio::test]
async fn repeated_undecodable_frames_do_not_end_the_transport() {
let response = r#"{"jsonrpc":"2.0","id":7,"result":{}}"#;
let mut transport = spawn_writer(
r#"printf '\377\n\376\n\377\376\n'; printf '{"jsonrpc":"2.0","id":7,"result":{}}\n'"#,
)
.await;
assert_eq!(transport.recv().await.unwrap().as_deref(), Some(response));
}
#[tokio::test]
async fn an_undecodable_frame_before_eof_reports_eof_rather_than_an_error() {
let mut transport = spawn_writer(r#"printf '\377\376\n'"#).await;
assert_eq!(transport.recv().await.unwrap(), None);
}
#[tokio::test]
async fn an_undecodable_final_frame_without_a_newline_reports_eof() {
let mut transport = spawn_writer(r#"printf '\377\376'"#).await;
assert_eq!(transport.recv().await.unwrap(), None);
}
#[tokio::test]
async fn a_partial_frame_survives_a_cancelled_receive() {
let frame = r#"{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}"#;
let mut transport = spawn_writer(
r#"printf '{"jsonrpc":"2.0","id":2,'; sleep 0.2; printf '"result":{"tools":[]}}\n'"#,
)
.await;
assert!(
tokio::time::timeout(std::time::Duration::from_millis(50), transport.recv())
.await
.is_err(),
"a partial frame must remain pending until its newline arrives"
);
assert_eq!(transport.recv().await.unwrap().as_deref(), Some(frame));
}
#[tokio::test]
async fn test_send_after_close_fails() {
let mut transport = StdioClientTransport::spawn("cat", &[]).await.unwrap();
transport.close().await.unwrap();
let result = transport.send("hello").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_spawn_command_with_env() {
let mut cmd = Command::new("sh");
cmd.args(["-c", "echo $TEST_VAR"]);
cmd.env("TEST_VAR", "hello_from_test");
let mut transport = StdioClientTransport::spawn_command(&mut cmd).await.unwrap();
let received = transport.recv().await.unwrap();
assert_eq!(received.as_deref(), Some("hello_from_test"));
}
#[tokio::test]
async fn test_spawn_command_preserves_piped_stderr() {
let mut cmd = Command::new("sh");
cmd.args(["-c", "echo diagnostic >&2"]);
cmd.stderr(Stdio::piped());
let mut transport = StdioClientTransport::spawn_command(&mut cmd).await.unwrap();
let stderr = transport
.take_stderr()
.expect("spawn_command must not replace piped stderr");
let mut stderr = BufReader::new(stderr);
let mut line = String::new();
stderr.read_line(&mut line).await.unwrap();
assert_eq!(line.trim(), "diagnostic");
assert!(transport.take_stderr().is_none());
}
#[tokio::test]
async fn test_recv_strips_a_leading_bom() {
let mut cmd = Command::new("sh");
cmd.args([
"-c",
r#"printf '\357\273\277{"jsonrpc":"2.0","id":1,"result":{}}\n'"#,
]);
let mut transport = StdioClientTransport::spawn_command(&mut cmd).await.unwrap();
let frame = transport
.recv()
.await
.unwrap()
.expect("the frame must arrive");
assert_eq!(frame, r#"{"jsonrpc":"2.0","id":1,"result":{}}"#);
serde_json::from_str::<serde_json::Value>(&frame)
.expect("and it must parse, which is the point");
}
#[tokio::test]
async fn test_multiple_send_recv_roundtrips() {
let mut transport = StdioClientTransport::spawn("cat", &[]).await.unwrap();
for i in 0..5 {
let msg = format!(r#"{{"id":{i},"msg":"test"}}"#);
transport.send(&msg).await.unwrap();
let received = transport.recv().await.unwrap();
assert_eq!(received.as_deref(), Some(msg.as_str()));
}
transport.close().await.unwrap();
}
#[tokio::test]
async fn max_frame_len_rejects_a_server_that_never_sends_a_delimiter() {
let mut cmd = Command::new("sh");
cmd.args(["-c", "while true; do printf 'aaaaaaaaaa'; done"]);
let mut transport = StdioClientTransport::spawn_command(&mut cmd)
.await
.unwrap()
.max_frame_len(1024);
let result = tokio::time::timeout(std::time::Duration::from_secs(10), transport.recv())
.await
.expect("an oversized frame must fail rather than hang waiting for a delimiter");
assert!(
result.is_err(),
"a frame past the configured limit must surface as an error"
);
transport.close().await.ok();
}
#[tokio::test]
async fn max_frame_len_does_not_affect_a_frame_under_the_limit() {
let mut transport = StdioClientTransport::spawn("cat", &[])
.await
.unwrap()
.max_frame_len(4096);
let msg = r#"{"jsonrpc":"2.0","id":1,"method":"test"}"#;
transport.send(msg).await.unwrap();
let received = transport.recv().await.unwrap();
assert_eq!(received.as_deref(), Some(msg));
}
}