22//!
33//! Handles connecting to MCP servers that run as child processes
44//! communicating over stdin/stdout.
5+ //!
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
9+ //! externally via the HTTP gateway.
510
611use std:: collections:: HashMap ;
712use std:: process:: Stdio ;
@@ -13,7 +18,7 @@ use mcpmux_core::{LogLevel, LogSource, ServerLog, ServerLogManager};
1318use rmcp:: transport:: { ConfigureCommandExt , TokioChildProcess } ;
1419use rmcp:: ServiceExt ;
1520use tokio:: process:: Command ;
16- use tracing:: { debug, error, info} ;
21+ use tracing:: { debug, error, info, warn } ;
1722use uuid:: Uuid ;
1823
1924use super :: TransportType ;
@@ -51,6 +56,80 @@ fn command_hint(command: &str) -> &'static str {
5156 }
5257}
5358
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
70+ /// and logs them to the server log manager.
71+ ///
72+ /// The task runs on the blocking thread pool until the pipe is closed
73+ /// (child process exits) or an I/O error occurs.
74+ fn spawn_stderr_reader (
75+ stderr_file : os_pipe:: PipeReader ,
76+ log_manager : Option < Arc < ServerLogManager > > ,
77+ space_id : Uuid ,
78+ server_id : String ,
79+ ) {
80+ let Some ( log_manager) = log_manager else {
81+ return ;
82+ } ;
83+
84+ let space_id_str = space_id. to_string ( ) ;
85+
86+ tokio:: task:: spawn_blocking ( move || {
87+ use std:: io:: BufRead ;
88+
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) => {
100+ let level = classify_stderr_line ( & line) ;
101+ let log = ServerLog :: new ( level, LogSource :: Stderr , & line) ;
102+ let _ = rt. block_on ( log_manager. append ( & space_id_str, & server_id, log) ) ;
103+ }
104+ Err ( e) => {
105+ debug ! (
106+ server_id = %server_id,
107+ error = %e,
108+ "Stderr reader stopped"
109+ ) ;
110+ break ;
111+ }
112+ }
113+ }
114+
115+ debug ! ( server_id = %server_id, "Stderr reader finished (pipe closed)" ) ;
116+ } ) ;
117+ }
118+
119+ /// Classify a stderr line into a log level based on content heuristics.
120+ fn classify_stderr_line ( line : & str ) -> LogLevel {
121+ let lower = line. to_lowercase ( ) ;
122+ if lower. contains ( "error" ) || lower. contains ( "panic" ) || lower. contains ( "fatal" ) {
123+ LogLevel :: Error
124+ } else if lower. contains ( "warn" ) {
125+ LogLevel :: Warn
126+ } else if lower. contains ( "debug" ) || lower. contains ( "trace" ) {
127+ LogLevel :: Debug
128+ } else {
129+ LogLevel :: Info
130+ }
131+ }
132+
54133/// STDIO transport for child process MCP servers
55134pub struct StdioTransport {
56135 command : String ,
@@ -87,7 +166,7 @@ impl StdioTransport {
87166 }
88167 }
89168
90- /// Log a message
169+ /// Log a message to the server log manager.
91170 async fn log ( & self , level : LogLevel , source : LogSource , message : String ) {
92171 if let Some ( log_manager) = & self . log_manager {
93172 let log = ServerLog :: new ( level, source, message) ;
@@ -99,71 +178,24 @@ impl StdioTransport {
99178 }
100179 }
101180 }
102- }
103-
104- #[ async_trait]
105- impl Transport for StdioTransport {
106- async fn connect ( & self ) -> TransportConnectResult {
107- info ! (
108- server_id = %self . server_id,
109- command = %self . command,
110- "Connecting to STDIO server"
111- ) ;
112181
113- // Log connection attempt
114- self . log (
115- LogLevel :: Info ,
116- LogSource :: Connection ,
117- format ! ( "Connecting to server: {} {:?}" , self . command, self . args) ,
118- )
119- . await ;
120-
121- // Validate command exists
122- let command_path = match which:: which ( & self . command )
123- . or_else ( |_| which:: which ( format ! ( "{}.exe" , & self . command) ) )
124- {
125- Ok ( path) => path,
126- Err ( _) => {
127- let hint = command_hint ( & self . command ) ;
128- let err = format ! (
129- "Command not found: {}. Ensure it's installed and in PATH.{hint}" ,
130- self . command
131- ) ;
132- error ! ( server_id = %self . server_id, "{}" , err) ;
133- self . log ( LogLevel :: Error , LogSource :: Connection , err. clone ( ) )
134- . await ;
135- return TransportConnectResult :: Failed ( err) ;
136- }
137- } ;
138-
139- debug ! (
140- server_id = %self . server_id,
141- path = ?command_path,
142- "Found command"
143- ) ;
144-
145- // Clone for closure and stderr capture
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 {
146188 let args = self . args . clone ( ) ;
147189 let env = self . env . clone ( ) ;
148- let _log_manager = self . log_manager . clone ( ) ;
149- let _space_id = self . space_id ;
150- let _server_id = self . server_id . clone ( ) ;
151190
152- // Create transport using child process with stderr capture
153- // Use resolved command_path instead of self.command to ensure we use the full path
154191 let transport =
155- match TokioChildProcess :: new ( Command :: new ( & command_path) . configure ( move |cmd| {
192+ match TokioChildProcess :: new ( Command :: new ( command_path) . configure ( move |cmd| {
156193 cmd. args ( & args)
157194 . envs ( & env)
158- . stderr ( Stdio :: piped ( ) ) // Capture stderr for logging
195+ . stderr ( stderr_config )
159196 . kill_on_drop ( true ) ;
160197
161198 configure_child_process_platform ( cmd) ;
162-
163- // Note: We can't easily access stderr after TokioChildProcess wraps it
164- // This is a limitation of the current rmcp API
165- // For now, we log connection events only
166- // TODO: Consider forking rmcp or using a custom transport wrapper
167199 } ) ) {
168200 Ok ( t) => t,
169201 Err ( e) => {
@@ -216,6 +248,76 @@ impl Transport for StdioTransport {
216248
217249 TransportConnectResult :: Connected ( client)
218250 }
251+ }
252+
253+ #[ async_trait]
254+ impl Transport for StdioTransport {
255+ async fn connect ( & self ) -> TransportConnectResult {
256+ info ! (
257+ server_id = %self . server_id,
258+ command = %self . command,
259+ "Connecting to STDIO server"
260+ ) ;
261+
262+ // Log connection attempt
263+ self . log (
264+ LogLevel :: Info ,
265+ LogSource :: Connection ,
266+ format ! ( "Connecting to server: {} {:?}" , self . command, self . args) ,
267+ )
268+ . await ;
269+
270+ // Validate command exists
271+ let command_path = match which:: which ( & self . command )
272+ . or_else ( |_| which:: which ( format ! ( "{}.exe" , & self . command) ) )
273+ {
274+ Ok ( path) => path,
275+ Err ( _) => {
276+ let hint = command_hint ( & self . command ) ;
277+ let err = format ! (
278+ "Command not found: {}. Ensure it's installed and in PATH.{hint}" ,
279+ self . command
280+ ) ;
281+ error ! ( server_id = %self . server_id, "{}" , err) ;
282+ self . log ( LogLevel :: Error , LogSource :: Connection , err. clone ( ) )
283+ . await ;
284+ return TransportConnectResult :: Failed ( err) ;
285+ }
286+ } ;
287+
288+ debug ! (
289+ server_id = %self . server_id,
290+ path = ?command_path,
291+ "Found command"
292+ ) ;
293+
294+ // Create an OS pipe for stderr capture.
295+ // The write end goes to the child process, the read end stays with us
296+ // for streaming process output into the log viewer.
297+ let ( stderr_read, stderr_write) = match create_stderr_pipe ( ) {
298+ Ok ( pair) => pair,
299+ Err ( e) => {
300+ warn ! (
301+ server_id = %self . server_id,
302+ error = %e,
303+ "Failed to create stderr pipe, falling back to null"
304+ ) ;
305+ // Connection still works, just without process log capture
306+ return self . connect_with_stderr ( & command_path, Stdio :: null ( ) ) . await ;
307+ }
308+ } ;
309+
310+ // Spawn the background stderr reader before connecting.
311+ // It blocks on the read end until the child writes to stderr.
312+ spawn_stderr_reader (
313+ stderr_read,
314+ self . log_manager . clone ( ) ,
315+ self . space_id ,
316+ self . server_id . clone ( ) ,
317+ ) ;
318+
319+ self . connect_with_stderr ( & command_path, stderr_write) . await
320+ }
219321
220322 fn transport_type ( & self ) -> TransportType {
221323 TransportType :: Stdio
0 commit comments