Files
optimclaw/src/tools/mcp/stdio_transport.rs
T
f53c1bb10b fix(mcp): address 14 audit findings across MCP module (#1094)
* fix(mcp): address 14 audit findings across MCP module

- Replace panicking assert! in new_with_config with Result return (Critical)
- Fix initialize() race condition using tokio::sync::OnceCell (High)
- Fix localhost check bypass via proper URL parsing (High)
- Extract shared stream_transport_send() to deduplicate stdio/unix send logic
- Use atomic write (tmp+rename) for config file persistence
- Filter SSE responses by request_id to prevent wrong-response dispatch
- Share a single reqwest::Client for OAuth via fallible OnceLock
- Log notification send errors instead of silently discarding
- Fix unwrap_or(0) that could steal id=0 responses
- Store InitializeResult in OnceCell so callers can access server capabilities
- Add redirect logging in OAuth discovery
- Reuse is_localhost_url() in auth.rs
- Add McpToolWrapper unit tests and regression tests
- URL-encode PKCE challenge for consistency

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: retrigger CI with skip-regression-check label

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-13 17:37:51 +00:00

229 lines
7.6 KiB
Rust

//! Stdio transport for MCP servers.
//!
//! Spawns a child process and communicates via stdin/stdout using
//! newline-delimited JSON-RPC.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use tokio::io::BufReader;
use tokio::process::{Child, Command};
use tokio::sync::{Mutex, oneshot};
use tokio::task::JoinHandle;
use crate::tools::mcp::protocol::{McpRequest, McpResponse};
use crate::tools::mcp::transport::{McpTransport, spawn_jsonrpc_reader, stream_transport_send};
use crate::tools::tool::ToolError;
/// MCP transport that communicates with a child process over stdin/stdout.
///
/// The child process is spawned with piped stdin/stdout/stderr. Requests are
/// written as newline-delimited JSON to stdin, and responses are read from
/// stdout by a background reader task. Stderr is drained to tracing logs.
pub struct StdioMcpTransport {
server_name: String,
stdin: Arc<Mutex<tokio::process::ChildStdin>>,
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<McpResponse>>>>,
reader_handle: Mutex<Option<JoinHandle<()>>>,
stderr_handle: Mutex<Option<JoinHandle<()>>>,
child: Arc<Mutex<Child>>,
}
impl StdioMcpTransport {
/// Spawn a child process and create a stdio transport.
///
/// # Arguments
///
/// * `name` - Human-readable server name for logging.
/// * `command` - The command to execute.
/// * `args` - Command-line arguments.
/// * `env` - Additional environment variables to set.
pub async fn spawn(
name: impl Into<String>,
command: &str,
args: impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>>,
env: impl IntoIterator<Item = (impl AsRef<std::ffi::OsStr>, impl AsRef<std::ffi::OsStr>)>,
) -> Result<Self, ToolError> {
let server_name = name.into();
let mut cmd = Command::new(command);
cmd.args(args)
.envs(env)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
ToolError::ExternalService(format!(
"[{}] Failed to spawn MCP server '{}': {}",
server_name, command, e
))
})?;
let stdin = child.stdin.take().ok_or_else(|| {
ToolError::ExternalService(format!(
"[{}] Failed to capture stdin of MCP server",
server_name
))
})?;
let stdout = child.stdout.take().ok_or_else(|| {
ToolError::ExternalService(format!(
"[{}] Failed to capture stdout of MCP server",
server_name
))
})?;
let stderr = child.stderr.take().ok_or_else(|| {
ToolError::ExternalService(format!(
"[{}] Failed to capture stderr of MCP server",
server_name
))
})?;
let pending: Arc<Mutex<HashMap<u64, oneshot::Sender<McpResponse>>>> =
Arc::new(Mutex::new(HashMap::new()));
let reader = BufReader::new(stdout);
let reader_handle = spawn_jsonrpc_reader(reader, pending.clone(), server_name.clone());
let stderr_name = server_name.clone();
let stderr_handle = tokio::spawn(async move {
use tokio::io::{AsyncBufReadExt, BufReader as TokioBufReader};
let reader = TokioBufReader::new(stderr);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::debug!("[{}] stderr: {}", stderr_name, line);
}
});
Ok(Self {
server_name,
stdin: Arc::new(Mutex::new(stdin)),
pending,
reader_handle: Mutex::new(Some(reader_handle)),
stderr_handle: Mutex::new(Some(stderr_handle)),
child: Arc::new(Mutex::new(child)),
})
}
}
#[async_trait]
impl McpTransport for StdioMcpTransport {
async fn send(
&self,
request: &McpRequest,
_headers: &HashMap<String, String>,
) -> Result<McpResponse, ToolError> {
stream_transport_send(
&self.stdin,
&self.pending,
request,
&self.server_name,
Duration::from_secs(30),
)
.await
}
async fn shutdown(&self) -> Result<(), ToolError> {
// Kill the child process.
{
let mut child = self.child.lock().await;
let _ = child.kill().await;
}
// Abort the reader tasks.
if let Some(handle) = self.reader_handle.lock().await.take() {
handle.abort();
}
if let Some(handle) = self.stderr_handle.lock().await.take() {
handle.abort();
}
// Drain pending requests so waiters wake immediately instead of
// hanging until their 30s timeout.
{
let mut pending = self.pending.lock().await;
pending.clear(); // Dropping senders wakes receivers with Err
}
tracing::debug!("[{}] Stdio transport shut down", self.server_name);
Ok(())
}
fn supports_http_features(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_spawn_nonexistent_command_fails() {
let env: HashMap<String, String> = HashMap::new();
let result = StdioMcpTransport::spawn(
"test",
"this-command-does-not-exist-ironclaw-test",
std::iter::empty::<&str>(),
&env,
)
.await;
let err = result.err().expect("should be an error").to_string();
assert!(
err.contains("Failed to spawn"),
"Error should mention spawn failure: {}",
err
);
}
#[tokio::test]
async fn test_spawn_and_shutdown() {
let env: HashMap<String, String> = HashMap::new();
let transport =
StdioMcpTransport::spawn("test-cat", "cat", std::iter::empty::<&str>(), &env)
.await
.expect("cat should be available");
// Verify shutdown completes without error.
transport.shutdown().await.expect("shutdown should succeed");
}
#[tokio::test]
async fn test_send_timeout_on_non_jsonrpc_server() {
// Spawn `cat` which echoes input back. Since the echoed input is the
// request (not a response with matching id), it will be ignored by the
// reader and we should hit the timeout. We use a short-lived test so
// we override the 30s timeout expectation by just checking the error type.
let env: HashMap<String, String> = HashMap::new();
let transport =
StdioMcpTransport::spawn("test-echo", "cat", std::iter::empty::<&str>(), &env)
.await
.expect("cat should be available");
let request = McpRequest::list_tools(999);
let headers = HashMap::new();
// The request will be echoed back by `cat`, but it won't parse as a
// valid McpResponse with matching id, so the reader will log a debug
// message and the send will eventually timeout. We don't want to wait
// 30 seconds in tests, so we just verify the transport was created and
// shut it down.
transport.shutdown().await.expect("shutdown should succeed");
// Verify that pending map is empty after shutdown.
let pending = transport.pending.lock().await;
assert!(pending.is_empty());
drop(pending);
// Verify send after shutdown fails (stdin is closed).
let result = transport.send(&request, &headers).await;
assert!(result.is_err());
}
}