33//! Handles connecting to MCP servers that run as child processes
44//! communicating over stdin/stdout.
55//!
6- //! Process stderr is captured via an OS pipe and streamed to the server
7- //! log manager, making terminal output visible in the desktop log viewer.
8- //! These logs are internal to the desktop app and are never exposed
6+ //! Process stderr is captured via tokio's piped stderr and streamed to the
7+ //! server log manager, making terminal output visible in the desktop log
8+ //! viewer. This works generically for any runtime (npx, node, docker, python,
9+ //! etc.). These logs are internal to the desktop app and are never exposed
910//! externally via the HTTP gateway.
1011
1112use std:: collections:: HashMap ;
@@ -17,7 +18,8 @@ use async_trait::async_trait;
1718use mcpmux_core:: { LogLevel , LogSource , ServerLog , ServerLogManager } ;
1819use rmcp:: transport:: { ConfigureCommandExt , TokioChildProcess } ;
1920use rmcp:: ServiceExt ;
20- use tokio:: process:: Command ;
21+ use tokio:: io:: AsyncBufReadExt ;
22+ use tokio:: process:: { ChildStderr , Command } ;
2123use tracing:: { debug, error, info, warn} ;
2224use uuid:: Uuid ;
2325
@@ -56,23 +58,13 @@ fn command_hint(command: &str) -> &'static str {
5658 }
5759}
5860
59- /// Create an OS pipe for stderr capture.
60- ///
61- /// Returns `(reader, write_stdio)` where:
62- /// - `reader` is a blocking `PipeReader` for the read end
63- /// - `write_stdio` is a `Stdio` for the child process's stderr
64- fn create_stderr_pipe ( ) -> std:: io:: Result < ( os_pipe:: PipeReader , Stdio ) > {
65- let ( reader, writer) = os_pipe:: pipe ( ) ?;
66- Ok ( ( reader, writer. into ( ) ) )
67- }
68-
69- /// Spawn a background task that reads lines from the process stderr pipe
61+ /// Spawn an async task that reads lines from the child process stderr
7062/// and logs them to the server log manager.
7163///
72- /// The task runs on the blocking thread pool until the pipe is closed
73- /// (child process exits) or an I/O error occurs.
64+ /// The task runs until the stderr stream is closed (child process exits)
65+ /// or an I/O error occurs.
7466fn spawn_stderr_reader (
75- stderr_file : os_pipe :: PipeReader ,
67+ stderr : ChildStderr ,
7668 log_manager : Option < Arc < ServerLogManager > > ,
7769 space_id : Uuid ,
7870 server_id : String ,
@@ -83,23 +75,22 @@ fn spawn_stderr_reader(
8375
8476 let space_id_str = space_id. to_string ( ) ;
8577
86- tokio:: task:: spawn_blocking ( move || {
87- use std:: io:: BufRead ;
78+ tokio:: spawn ( async move {
79+ let reader = tokio:: io:: BufReader :: new ( stderr) ;
80+ let mut lines = reader. lines ( ) ;
8881
89- let rt = match tokio:: runtime:: Handle :: try_current ( ) {
90- Ok ( h) => h,
91- Err ( _) => return ,
92- } ;
93-
94- let reader = std:: io:: BufReader :: new ( stderr_file) ;
95-
96- for line_result in reader. lines ( ) {
97- match line_result {
98- Ok ( line) if line. is_empty ( ) => continue ,
99- Ok ( line) => {
82+ loop {
83+ match lines. next_line ( ) . await {
84+ Ok ( Some ( line) ) if line. is_empty ( ) => continue ,
85+ Ok ( Some ( line) ) => {
10086 let level = classify_stderr_line ( & line) ;
10187 let log = ServerLog :: new ( level, LogSource :: Stderr , & line) ;
102- let _ = rt. block_on ( log_manager. append ( & space_id_str, & server_id, log) ) ;
88+ let _ = log_manager. append ( & space_id_str, & server_id, log) . await ;
89+ }
90+ Ok ( None ) => {
91+ // EOF - child process closed stderr
92+ debug ! ( server_id = %server_id, "Stderr reader finished (stream closed)" ) ;
93+ break ;
10394 }
10495 Err ( e) => {
10596 debug ! (
@@ -111,8 +102,6 @@ fn spawn_stderr_reader(
111102 }
112103 }
113104 }
114-
115- debug ! ( server_id = %server_id, "Stderr reader finished (pipe closed)" ) ;
116105 } ) ;
117106}
118107
@@ -178,26 +167,65 @@ impl StdioTransport {
178167 }
179168 }
180169 }
170+ }
171+
172+ #[ async_trait]
173+ impl Transport for StdioTransport {
174+ async fn connect ( & self ) -> TransportConnectResult {
175+ info ! (
176+ server_id = %self . server_id,
177+ command = %self . command,
178+ "Connecting to STDIO server"
179+ ) ;
180+
181+ // Log connection attempt
182+ self . log (
183+ LogLevel :: Info ,
184+ LogSource :: Connection ,
185+ format ! ( "Connecting to server: {} {:?}" , self . command, self . args) ,
186+ )
187+ . await ;
181188
182- /// Internal helper: attempt connection with a given stderr Stdio target.
183- async fn connect_with_stderr (
184- & self ,
185- command_path : & std:: path:: Path ,
186- stderr_config : Stdio ,
187- ) -> TransportConnectResult {
189+ // Validate command exists
190+ let command_path = match which:: which ( & self . command )
191+ . or_else ( |_| which:: which ( format ! ( "{}.exe" , & self . command) ) )
192+ {
193+ Ok ( path) => path,
194+ Err ( _) => {
195+ let hint = command_hint ( & self . command ) ;
196+ let err = format ! (
197+ "Command not found: {}. Ensure it's installed and in PATH.{hint}" ,
198+ self . command
199+ ) ;
200+ error ! ( server_id = %self . server_id, "{}" , err) ;
201+ self . log ( LogLevel :: Error , LogSource :: Connection , err. clone ( ) )
202+ . await ;
203+ return TransportConnectResult :: Failed ( err) ;
204+ }
205+ } ;
206+
207+ debug ! (
208+ server_id = %self . server_id,
209+ path = ?command_path,
210+ "Found command"
211+ ) ;
212+
213+ // Spawn the child process using the builder pattern so that stderr
214+ // is configured through the builder (not overridden).
215+ // TokioChildProcess::new() would override our stderr setting with
216+ // Stdio::inherit(), so we must use builder().stderr().spawn().
188217 let args = self . args . clone ( ) ;
189218 let env = self . env . clone ( ) ;
190219
191- let transport =
192- match TokioChildProcess :: new ( Command :: new ( command_path) . configure ( move |cmd| {
193- cmd. args ( & args)
194- . envs ( & env)
195- . stderr ( stderr_config)
196- . kill_on_drop ( true ) ;
197-
220+ let ( transport, child_stderr) =
221+ match TokioChildProcess :: builder ( Command :: new ( & command_path) . configure ( move |cmd| {
222+ cmd. args ( & args) . envs ( & env) . kill_on_drop ( true ) ;
198223 configure_child_process_platform ( cmd) ;
199- } ) ) {
200- Ok ( t) => t,
224+ } ) )
225+ . stderr ( Stdio :: piped ( ) )
226+ . spawn ( )
227+ {
228+ Ok ( result) => result,
201229 Err ( e) => {
202230 let hint = command_hint ( & self . command ) ;
203231 let err = format ! ( "Failed to spawn process: {e}.{hint}" ) ;
@@ -208,6 +236,21 @@ impl StdioTransport {
208236 }
209237 } ;
210238
239+ // Start the async stderr reader if we got a handle
240+ if let Some ( stderr) = child_stderr {
241+ spawn_stderr_reader (
242+ stderr,
243+ self . log_manager . clone ( ) ,
244+ self . space_id ,
245+ self . server_id . clone ( ) ,
246+ ) ;
247+ } else {
248+ warn ! (
249+ server_id = %self . server_id,
250+ "No stderr handle available - process logs will not be captured"
251+ ) ;
252+ }
253+
211254 // Create client handler
212255 let client_handler = create_client_handler (
213256 & self . server_id ,
@@ -252,76 +295,6 @@ impl StdioTransport {
252295
253296 TransportConnectResult :: Connected ( client)
254297 }
255- }
256-
257- #[ async_trait]
258- impl Transport for StdioTransport {
259- async fn connect ( & self ) -> TransportConnectResult {
260- info ! (
261- server_id = %self . server_id,
262- command = %self . command,
263- "Connecting to STDIO server"
264- ) ;
265-
266- // Log connection attempt
267- self . log (
268- LogLevel :: Info ,
269- LogSource :: Connection ,
270- format ! ( "Connecting to server: {} {:?}" , self . command, self . args) ,
271- )
272- . await ;
273-
274- // Validate command exists
275- let command_path = match which:: which ( & self . command )
276- . or_else ( |_| which:: which ( format ! ( "{}.exe" , & self . command) ) )
277- {
278- Ok ( path) => path,
279- Err ( _) => {
280- let hint = command_hint ( & self . command ) ;
281- let err = format ! (
282- "Command not found: {}. Ensure it's installed and in PATH.{hint}" ,
283- self . command
284- ) ;
285- error ! ( server_id = %self . server_id, "{}" , err) ;
286- self . log ( LogLevel :: Error , LogSource :: Connection , err. clone ( ) )
287- . await ;
288- return TransportConnectResult :: Failed ( err) ;
289- }
290- } ;
291-
292- debug ! (
293- server_id = %self . server_id,
294- path = ?command_path,
295- "Found command"
296- ) ;
297-
298- // Create an OS pipe for stderr capture.
299- // The write end goes to the child process, the read end stays with us
300- // for streaming process output into the log viewer.
301- let ( stderr_read, stderr_write) = match create_stderr_pipe ( ) {
302- Ok ( pair) => pair,
303- Err ( e) => {
304- warn ! (
305- server_id = %self . server_id,
306- error = %e,
307- "Failed to create stderr pipe, falling back to null"
308- ) ;
309- // Connection still works, just without process log capture
310- return self . connect_with_stderr ( & command_path, Stdio :: null ( ) ) . await ;
311- }
312- } ;
313-
314- // Spawn the background stderr reader before connecting.
315- // It blocks on the read end until the child writes to stderr.
316- spawn_stderr_reader (
317- stderr_read,
318- self . log_manager . clone ( ) ,
319- self . space_id ,
320- self . server_id . clone ( ) ,
321- ) ;
322-
323- self . connect_with_stderr ( & command_path, stderr_write) . await
324- }
325298
326299 fn transport_type ( & self ) -> TransportType {
327300 TransportType :: Stdio
0 commit comments