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