Skip to content

Commit e6d43be

Browse files
committed
feat: capture stdio server process stderr logs in log viewer
Capture stderr output from stdio MCP server child processes and pipe it into the ServerLogManager so process logs appear in the desktop log viewer alongside connection and tool-call logs. - Use os_pipe to create a pipe pair before spawning the child process - Pass the write end as the child's stderr via the Command configure closure - Spawn a background blocking task that reads stderr line-by-line and appends each line to ServerLogManager with LogSource::Stderr - Add log-level heuristics (classify_stderr_line) for error/warn/debug - Graceful fallback to Stdio::null() if pipe creation fails - Add Rust integration tests for os_pipe stderr capture and ServerLogManager integration - Add e2e desktop test spec for process log visibility in the UI - Logs remain internal to the desktop app (Tauri IPC only, not exposed on the HTTP gateway) https://claude.ai/code/session_01FqgQw173URiGuGzfULZira
1 parent 3ffa395 commit e6d43be

8 files changed

Lines changed: 595 additions & 75 deletions

File tree

Cargo.lock

Lines changed: 17 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ glob = "0.3"
5454
url = "2.5"
5555
urlencoding = "2.1"
5656
dotenvy = "0.15"
57+
os_pipe = "1"
5758

5859
# MCP Protocol
5960
# NOTE: Never use local path dependency - E:\one-mcp\rust-sdk is for source lookup only

crates/mcpmux-gateway/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ zeroize = "1.8"
5252
which = "7.0"
5353
open = "5.3"
5454
dirs = "5.0"
55+
os_pipe = { workspace = true }
5556

5657
# MCP SDK
5758
rmcp.workspace = true

crates/mcpmux-gateway/src/pool/transport/stdio.rs

Lines changed: 160 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
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
611
use std::collections::HashMap;
712
use std::process::Stdio;
@@ -13,7 +18,7 @@ use mcpmux_core::{LogLevel, LogSource, ServerLog, ServerLogManager};
1318
use rmcp::transport::{ConfigureCommandExt, TokioChildProcess};
1419
use rmcp::ServiceExt;
1520
use tokio::process::Command;
16-
use tracing::{debug, error, info};
21+
use tracing::{debug, error, info, warn};
1722
use uuid::Uuid;
1823

1924
use 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
55134
pub 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,77 @@ 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)
320+
.await
321+
}
219322

220323
fn transport_type(&self) -> TransportType {
221324
TransportType::Stdio

0 commit comments

Comments
 (0)