Skip to main content

tower_mcp/client/
stdio.rs

1//! Stdio client transport for subprocess MCP servers.
2//!
3//! Provides [`StdioClientTransport`] which spawns a child process and
4//! communicates using line-delimited JSON over stdin/stdout.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! use tower_mcp::client::{McpClient, StdioClientTransport};
10//!
11//! # async fn example() -> Result<(), tower_mcp::BoxError> {
12//! let transport = StdioClientTransport::spawn("my-mcp-server", &["--flag"]).await?;
13//! let client = McpClient::connect(transport).await?;
14//! # Ok(())
15//! # }
16//! ```
17
18use std::process::Stdio;
19
20use async_trait::async_trait;
21use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines};
22use tokio::process::{Child, Command};
23
24use super::transport::ClientTransport;
25use crate::error::{Error, Result};
26
27/// Client transport that communicates with a subprocess via stdio.
28///
29/// Spawns a child process and communicates using line-delimited JSON-RPC
30/// messages over stdin (write) and stdout (read). By default stderr is
31/// inherited so server debug output appears in the client's terminal. A
32/// caller using [`Self::spawn_command`] may redirect or pipe it instead.
33pub struct StdioClientTransport {
34    child: Option<Child>,
35    stdin: Option<tokio::process::ChildStdin>,
36    // `Lines::next_line` retains a partially read frame when its future is
37    // cancelled by the client's `select!` loop. A bare `read_line` future can
38    // discard those bytes while leaving the newline behind, turning a valid
39    // response into an empty frame when outgoing commands arrive concurrently.
40    stdout: Lines<BufReader<tokio::process::ChildStdout>>,
41}
42
43impl StdioClientTransport {
44    /// Spawn a new subprocess and connect to it.
45    ///
46    /// # Errors
47    ///
48    /// Returns an error if the process fails to spawn or if stdin/stdout
49    /// handles cannot be acquired.
50    pub async fn spawn(program: &str, args: &[&str]) -> Result<Self> {
51        let mut cmd = Command::new(program);
52        cmd.args(args);
53        Self::spawn_command(&mut cmd).await
54    }
55
56    /// Spawn from a pre-configured [`Command`].
57    ///
58    /// This allows setting environment variables, working directory, and
59    /// other process configuration before spawning.
60    ///
61    /// Stdin and stdout are automatically set to piped. Stderr keeps the
62    /// [`Command`] configuration; its default is inherited.
63    ///
64    /// # Example
65    ///
66    /// ```rust,no_run
67    /// use tokio::process::Command;
68    /// use tower_mcp::client::StdioClientTransport;
69    ///
70    /// # async fn example() -> Result<(), tower_mcp::BoxError> {
71    /// let mut cmd = Command::new("npx");
72    /// cmd.args(["-y", "@modelcontextprotocol/server-github"])
73    ///    .env("GITHUB_TOKEN", "ghp_...");
74    /// let transport = StdioClientTransport::spawn_command(&mut cmd).await?;
75    /// # Ok(())
76    /// # }
77    /// ```
78    pub async fn spawn_command(cmd: &mut Command) -> Result<Self> {
79        cmd.stdin(Stdio::piped()).stdout(Stdio::piped());
80
81        let mut child = cmd
82            .spawn()
83            .map_err(|e| Error::Transport(format!("Failed to spawn process: {}", e)))?;
84
85        let stdin = child
86            .stdin
87            .take()
88            .ok_or_else(|| Error::Transport("Failed to get child stdin".to_string()))?;
89        let stdout = child
90            .stdout
91            .take()
92            .ok_or_else(|| Error::Transport("Failed to get child stdout".to_string()))?;
93
94        tracing::info!("Spawned MCP server process");
95
96        Ok(Self {
97            child: Some(child),
98            stdin: Some(stdin),
99            stdout: BufReader::new(stdout).lines(),
100        })
101    }
102
103    /// Take the child's piped stderr handle, if the command configured one.
104    ///
105    /// This returns `None` when stderr is inherited, redirected elsewhere, or
106    /// has already been taken. It is useful for clients that need to integrate
107    /// server diagnostics with their own terminal or logging UI.
108    pub fn take_stderr(&mut self) -> Option<tokio::process::ChildStderr> {
109        self.child.as_mut()?.stderr.take()
110    }
111
112    /// Create from an existing child process.
113    ///
114    /// The child must have piped stdin and stdout.
115    pub fn from_child(mut child: Child) -> Result<Self> {
116        let stdin = child
117            .stdin
118            .take()
119            .ok_or_else(|| Error::Transport("Failed to get child stdin".to_string()))?;
120        let stdout = child
121            .stdout
122            .take()
123            .ok_or_else(|| Error::Transport("Failed to get child stdout".to_string()))?;
124
125        Ok(Self {
126            child: Some(child),
127            stdin: Some(stdin),
128            stdout: BufReader::new(stdout).lines(),
129        })
130    }
131}
132
133#[async_trait]
134impl ClientTransport for StdioClientTransport {
135    async fn send(&mut self, message: &str) -> Result<()> {
136        let stdin = self
137            .stdin
138            .as_mut()
139            .ok_or_else(|| Error::Transport("Transport closed".to_string()))?;
140
141        stdin
142            .write_all(message.as_bytes())
143            .await
144            .map_err(|e| Error::Transport(format!("Failed to write: {}", e)))?;
145        stdin
146            .write_all(b"\n")
147            .await
148            .map_err(|e| Error::Transport(format!("Failed to write newline: {}", e)))?;
149        stdin
150            .flush()
151            .await
152            .map_err(|e| Error::Transport(format!("Failed to flush: {}", e)))?;
153        Ok(())
154    }
155
156    async fn recv(&mut self) -> Result<Option<String>> {
157        let line = self
158            .stdout
159            .next_line()
160            .await
161            .map_err(|e| Error::Transport(format!("Failed to read: {}", e)))?;
162        Ok(line.map(|line| line.trim().to_string()))
163    }
164
165    fn is_connected(&self) -> bool {
166        self.child.is_some() && self.stdin.is_some()
167    }
168
169    async fn close(&mut self) -> Result<()> {
170        // Drop stdin to signal EOF to the child process
171        self.stdin.take();
172
173        if let Some(mut child) = self.child.take() {
174            let result =
175                tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()).await;
176
177            match result {
178                Ok(Ok(status)) => {
179                    tracing::info!(status = ?status, "Child process exited");
180                }
181                Ok(Err(e)) => {
182                    tracing::error!(error = %e, "Error waiting for child");
183                }
184                Err(_) => {
185                    tracing::warn!("Timeout waiting for child, killing");
186                    let _ = child.kill().await;
187                }
188            }
189        }
190
191        Ok(())
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[tokio::test]
200    async fn test_spawn_nonexistent_program() {
201        let result = StdioClientTransport::spawn("nonexistent-program-xyz", &[]).await;
202        assert!(result.is_err());
203    }
204
205    #[tokio::test]
206    async fn test_send_and_recv_via_cat() {
207        // `cat` echoes stdin to stdout line-by-line
208        let mut transport = StdioClientTransport::spawn("cat", &[]).await.unwrap();
209
210        assert!(transport.is_connected());
211
212        // Send a JSON message
213        let msg = r#"{"jsonrpc":"2.0","id":1,"method":"test"}"#;
214        transport.send(msg).await.unwrap();
215
216        // cat echoes it back
217        let received = transport.recv().await.unwrap();
218        assert_eq!(received.as_deref(), Some(msg));
219    }
220
221    #[tokio::test]
222    async fn test_close_signals_eof() {
223        let mut transport = StdioClientTransport::spawn("cat", &[]).await.unwrap();
224        assert!(transport.is_connected());
225
226        transport.close().await.unwrap();
227        assert!(!transport.is_connected());
228    }
229
230    #[tokio::test]
231    async fn test_recv_returns_none_on_eof() {
232        // `true` exits immediately with no output
233        let mut transport = StdioClientTransport::spawn("true", &[]).await.unwrap();
234
235        // Should get None (EOF) since `true` produces no output and exits
236        let result = transport.recv().await.unwrap();
237        assert_eq!(result, None);
238    }
239
240    #[tokio::test]
241    async fn line_reader_preserves_partial_frame_when_receive_is_cancelled() {
242        let (mut writer, reader) = tokio::io::duplex(256);
243        let mut lines = BufReader::new(reader).lines();
244        let frame = r#"{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}"#;
245
246        writer.write_all(frame.as_bytes()).await.unwrap();
247        assert!(
248            tokio::time::timeout(std::time::Duration::from_millis(10), lines.next_line())
249                .await
250                .is_err(),
251            "a partial frame must remain pending until its newline arrives"
252        );
253
254        writer.write_all(b"\n").await.unwrap();
255        assert_eq!(lines.next_line().await.unwrap().as_deref(), Some(frame));
256    }
257
258    #[tokio::test]
259    async fn test_send_after_close_fails() {
260        let mut transport = StdioClientTransport::spawn("cat", &[]).await.unwrap();
261        transport.close().await.unwrap();
262
263        let result = transport.send("hello").await;
264        assert!(result.is_err());
265    }
266
267    #[tokio::test]
268    async fn test_spawn_command_with_env() {
269        let mut cmd = Command::new("sh");
270        cmd.args(["-c", "echo $TEST_VAR"]);
271        cmd.env("TEST_VAR", "hello_from_test");
272
273        let mut transport = StdioClientTransport::spawn_command(&mut cmd).await.unwrap();
274
275        let received = transport.recv().await.unwrap();
276        assert_eq!(received.as_deref(), Some("hello_from_test"));
277    }
278
279    #[tokio::test]
280    async fn test_spawn_command_preserves_piped_stderr() {
281        let mut cmd = Command::new("sh");
282        cmd.args(["-c", "echo diagnostic >&2"]);
283        cmd.stderr(Stdio::piped());
284
285        let mut transport = StdioClientTransport::spawn_command(&mut cmd).await.unwrap();
286        let stderr = transport
287            .take_stderr()
288            .expect("spawn_command must not replace piped stderr");
289        let mut stderr = BufReader::new(stderr);
290        let mut line = String::new();
291        stderr.read_line(&mut line).await.unwrap();
292
293        assert_eq!(line.trim(), "diagnostic");
294        assert!(transport.take_stderr().is_none());
295    }
296
297    #[tokio::test]
298    async fn test_multiple_send_recv_roundtrips() {
299        let mut transport = StdioClientTransport::spawn("cat", &[]).await.unwrap();
300
301        for i in 0..5 {
302            let msg = format!(r#"{{"id":{i},"msg":"test"}}"#);
303            transport.send(&msg).await.unwrap();
304            let received = transport.recv().await.unwrap();
305            assert_eq!(received.as_deref(), Some(msg.as_str()));
306        }
307
308        transport.close().await.unwrap();
309    }
310}