Skip to main content

yapi_core/mcp/
stdio.rs

1//! The stdio transport: a server process speaking newline-delimited JSON-RPC
2//! on stdin and stdout. Port of `transports/stdio.ts` in pi-mcp `v1.0.0`.
3
4use std::path::PathBuf;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::{Arc, Mutex};
7use std::time::Duration;
8
9use serde_json::Value;
10use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt};
11use tokio::process::ChildStdin;
12use tokio::sync::watch;
13use yapi_types::sync::lock;
14
15use super::jsonrpc::McpError;
16use super::transport::{Event, Events, MAX_MESSAGE_BYTES};
17
18const MAX_STDERR_BYTES: usize = 64 * 1024;
19/// How long a server may take to exit on its own after stdin closes.
20const STDIN_CLOSE_GRACE: Duration = Duration::from_millis(500);
21/// How long a server may take to exit after SIGTERM before SIGKILL.
22const CLOSE_TIMEOUT: Duration = Duration::from_millis(2000);
23
24/// How to start a stdio server.
25#[derive(Clone, Debug, Default, PartialEq)]
26pub struct StdioOptions {
27    /// The program.
28    pub command: String,
29    /// Its arguments.
30    pub args: Vec<String>,
31    /// Its working directory.
32    pub cwd: PathBuf,
33    /// Variables added to the inherited environment.
34    pub env: Vec<(String, String)>,
35}
36
37/// A running stdio server.
38pub struct StdioTransport {
39    options: StdioOptions,
40    stdin: tokio::sync::Mutex<Option<ChildStdin>>,
41    pid: Mutex<Option<u32>>,
42    exited: Mutex<Option<watch::Receiver<bool>>>,
43    stderr: Arc<Mutex<Vec<u8>>>,
44    closed: AtomicBool,
45}
46
47impl StdioTransport {
48    /// A transport that starts `options` when the client connects.
49    pub fn new(options: StdioOptions) -> StdioTransport {
50        StdioTransport {
51            options,
52            stdin: tokio::sync::Mutex::new(None),
53            pid: Mutex::new(None),
54            exited: Mutex::new(None),
55            stderr: Arc::new(Mutex::new(Vec::new())),
56            closed: AtomicBool::new(false),
57        }
58    }
59
60    /// The end of the server's stderr, at most 64 KiB.
61    pub fn stderr(&self) -> String {
62        String::from_utf8_lossy(&lock(&self.stderr)).into_owned()
63    }
64
65    /// Starts the server and sends its messages and exit to `events`.
66    pub async fn start(&self, events: Events) -> Result<(), McpError> {
67        if self.closed.load(Ordering::SeqCst) {
68            return Err(McpError::closed());
69        }
70        let mut command = tokio::process::Command::new(&self.options.command);
71        command
72            .args(&self.options.args)
73            .current_dir(&self.options.cwd)
74            .envs(crate::config::child_env())
75            .envs(self.options.env.iter().map(|(key, value)| (key, value)))
76            .stdin(std::process::Stdio::piped())
77            .stdout(std::process::Stdio::piped())
78            .stderr(std::process::Stdio::piped());
79        // Its own process group, so closing can stop the server's children too.
80        #[cfg(unix)]
81        command.process_group(0);
82        let mut child = command.spawn().map_err(|error| {
83            if error.kind() == std::io::ErrorKind::NotFound {
84                McpError::Other(format!("spawn {} ENOENT", self.options.command))
85            } else {
86                McpError::Other(error.to_string())
87            }
88        })?;
89        *lock(&self.pid) = child.id();
90        *self.stdin.lock().await = child.stdin.take();
91        let (exit_tx, exit_rx) = watch::channel(false);
92        *lock(&self.exited) = Some(exit_rx.clone());
93
94        if let Some(stderr) = child.stderr.take() {
95            let tail = Arc::clone(&self.stderr);
96            tokio::spawn(async move {
97                let mut stderr = stderr;
98                let mut buffer = [0u8; 4096];
99                while let Ok(count) = stderr.read(&mut buffer).await {
100                    if count == 0 {
101                        break;
102                    }
103                    let mut tail = lock(&tail);
104                    tail.extend_from_slice(&buffer[..count]);
105                    if tail.len() > MAX_STDERR_BYTES {
106                        let excess = tail.len() - MAX_STDERR_BYTES;
107                        tail.drain(..excess);
108                    }
109                }
110            });
111        }
112        if let Some(stdout) = child.stdout.take() {
113            tokio::spawn(read_messages(stdout, events, exit_rx));
114        }
115        tokio::spawn(async move {
116            let _ = child.wait().await;
117            let _ = exit_tx.send(true);
118        });
119        Ok(())
120    }
121
122    /// Writes one message as a line.
123    pub async fn send(&self, message: &Value) -> Result<(), McpError> {
124        if self.closed.load(Ordering::SeqCst) {
125            return Err(McpError::closed());
126        }
127        let mut stdin = self.stdin.lock().await;
128        let stdin = stdin.as_mut().ok_or_else(McpError::closed)?;
129        let line = yapi_types::json::stringify(message) + "\n";
130        // A server that already exited fails the write as Node's does.
131        let failed = |error: std::io::Error| {
132            if error.kind() == std::io::ErrorKind::BrokenPipe {
133                McpError::Other("write EPIPE".into())
134            } else {
135                McpError::closed()
136            }
137        };
138        stdin.write_all(line.as_bytes()).await.map_err(failed)?;
139        stdin.flush().await.map_err(failed)
140    }
141
142    /// Shuts the server down as the spec asks: stdin closes, then SIGTERM,
143    /// then SIGKILL. Children that outlive it are sent SIGTERM.
144    pub async fn close(&self) {
145        if self.closed.swap(true, Ordering::SeqCst) {
146            return;
147        }
148        let Some(mut exited) = lock(&self.exited).clone() else {
149            drop(self.stdin.lock().await.take());
150            return;
151        };
152        let pid = *lock(&self.pid);
153        // Closing stdin waits for a write in progress, which a server that
154        // stopped reading lets finish only once the signals below stop it.
155        let wait = async {
156            drop(self.stdin.lock().await.take());
157            let _ = exited.wait_for(|done| *done).await;
158        };
159        tokio::pin!(wait);
160        if tokio::time::timeout(STDIN_CLOSE_GRACE, &mut wait)
161            .await
162            .is_err()
163        {
164            #[cfg(unix)]
165            crate::tools::bash::signal_tree(pid, rustix::process::Signal::TERM);
166            if tokio::time::timeout(CLOSE_TIMEOUT, &mut wait)
167                .await
168                .is_err()
169            {
170                #[cfg(unix)]
171                crate::tools::bash::signal_tree(pid, rustix::process::Signal::KILL);
172                wait.await;
173            }
174        }
175        #[cfg(unix)]
176        crate::tools::bash::signal_tree(pid, rustix::process::Signal::TERM);
177        #[cfg(not(unix))]
178        let _ = pid;
179    }
180}
181
182/// Reads stdout line by line until it ends, then reports the close once the
183/// process has exited.
184async fn read_messages(
185    stdout: tokio::process::ChildStdout,
186    events: Events,
187    mut exited: watch::Receiver<bool>,
188) {
189    let mut reader = tokio::io::BufReader::new(stdout);
190    let mut line = Vec::new();
191    let mut incomplete = false;
192    // Within an oversized line, whose rest is read in bounded pieces and dropped.
193    let mut skipping = false;
194    loop {
195        line.clear();
196        let read = (&mut reader)
197            .take(MAX_MESSAGE_BYTES as u64 + 1)
198            .read_until(b'\n', &mut line)
199            .await;
200        match read {
201            Ok(0) | Err(_) => break,
202            Ok(_) => {}
203        }
204        if skipping {
205            skipping = !line.ends_with(b"\n");
206            continue;
207        }
208        if !line.ends_with(b"\n") {
209            if line.len() > MAX_MESSAGE_BYTES {
210                let _ = events.send(Event::Error(format!(
211                    "MCP stdio message exceeds {MAX_MESSAGE_BYTES} bytes"
212                )));
213                skipping = true;
214                continue;
215            }
216            incomplete = !String::from_utf8_lossy(&line).trim().is_empty();
217            break;
218        }
219        let text = String::from_utf8_lossy(&line);
220        let text = text.trim_end_matches('\n').trim_end_matches('\r');
221        if text.trim().is_empty() {
222            continue;
223        }
224        let event = match serde_json::from_str::<Value>(text) {
225            Ok(message) if super::jsonrpc::classify(&message).is_some() => Event::Message(message),
226            Ok(_) => Event::Error("Invalid JSON-RPC message".into()),
227            Err(error) => Event::Error(error.to_string()),
228        };
229        let _ = events.send(event);
230    }
231    let _ = exited.wait_for(|done| *done).await;
232    if incomplete {
233        let _ = events.send(Event::Error(
234            "MCP stdio server closed with an incomplete JSON-RPC message".into(),
235        ));
236    }
237    let _ = events.send(Event::Closed);
238}