Skip to content

Commit 96795b0

Browse files
its-mashclaude
andauthored
feat: Capture and stream process stderr to server log manager (#63)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent a1d9599 commit 96795b0

8 files changed

Lines changed: 594 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: 159 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,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

Comments
 (0)