//! Launch MCP stdio servers and return the transport rmcp should use. //! //! This module owns the "where does the server process run?" decision: //! //! - [`LocalStdioServerLauncher`] starts the configured command as a child of //! the orchestrator process. //! - [`ExecutorStdioServerLauncher`] starts the configured command through the //! executor process API. //! //! Both paths return [`StdioServerTransport`], so `RmcpClient` can hand the //! resulting byte stream to rmcp without knowing where the process lives. The //! executor-specific byte adaptation lives in `executor_process_transport`. use std::collections::HashMap; use std::ffi::OsString; use std::future::Future; use std::io; use std::path::Path; use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; #[cfg(unix)] use std::thread::sleep; #[cfg(unix)] use std::thread::spawn; #[cfg(unix)] use std::time::Duration; use anyhow::Result; use anyhow::anyhow; use codex_config::types::McpServerEnvVar; use codex_exec_server::ExecBackend; use codex_exec_server::ExecEnvPolicy; use codex_exec_server::ExecParams; use codex_exec_server::ExecProcess; use codex_protocol::config_types::ShellEnvironmentPolicyInherit; use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathUri; #[cfg(unix)] use codex_utils_pty::process_group::kill_process_group; #[cfg(unix)] use codex_utils_pty::process_group::terminate_process_group; use futures::FutureExt; use futures::future::BoxFuture; use rmcp::service::RoleClient; use rmcp::service::RxJsonRpcMessage; use rmcp::service::TxJsonRpcMessage; use rmcp::transport::Transport; use rmcp::transport::child_process::TokioChildProcess; use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tokio::process::Command; use tracing::info; use tracing::warn; use crate::executor_process_transport::ExecutorProcessTransport; use crate::program_resolver; use crate::utils::create_env_for_mcp_server; use crate::utils::create_env_overlay_for_remote_mcp_server; use crate::utils::remote_mcp_env_var_names; // General purpose public code. /// Launches an MCP stdio server and returns the transport for rmcp. /// /// This trait is the boundary between MCP lifecycle code and process placement. /// `RmcpClient` owns MCP operations such as `initialize` and `tools/list`; the /// launcher owns starting the configured command and producing an rmcp /// [`Transport`] over the server's stdin/stdout bytes. pub trait StdioServerLauncher: private::Sealed + Send + Sync { /// Start the configured stdio server and return its rmcp-facing transport. fn launch( &self, command: StdioServerCommand, ) -> BoxFuture<'static, io::Result>; } /// Command-line process shape shared by stdio server launchers. #[derive(Clone)] pub struct StdioServerCommand { program: OsString, args: Vec, env: Option>, env_vars: Vec, cwd: Option, } /// Client-side rmcp transport for a launched MCP stdio server. /// /// The concrete process placement stays private to this module. `RmcpClient` /// only sees the standard rmcp transport abstraction and can pass this value /// directly to `rmcp::service::serve_client`. pub struct StdioServerTransport { inner: StdioServerTransportInner, process: StdioServerProcessHandle, } enum StdioServerTransportInner { Local(TokioChildProcess), Executor(ExecutorProcessTransport), } impl Transport for StdioServerTransport { type Error = io::Error; fn send( &mut self, item: TxJsonRpcMessage, ) -> impl Future> + Send + 'static { // Both variants already implement rmcp's transport contract. This // wrapper keeps process placement private while leaving rmcp's send // semantics unchanged. match &mut self.inner { StdioServerTransportInner::Local(transport) => transport.send(item).boxed(), StdioServerTransportInner::Executor(transport) => transport.send(item).boxed(), } } fn receive(&mut self) -> impl Future>> + Send { // rmcp reads from the same transport shape for both placements. The // executor variant turns pushed process-output events back into the // line-delimited JSON stream expected by rmcp. match &mut self.inner { StdioServerTransportInner::Local(transport) => transport.receive().boxed(), StdioServerTransportInner::Executor(transport) => transport.receive().boxed(), } } async fn close(&mut self) -> std::result::Result<(), Self::Error> { self.process.terminate().await?; match &mut self.inner { StdioServerTransportInner::Local(transport) => transport.close().await, StdioServerTransportInner::Executor(transport) => transport.close().await, } } } impl StdioServerTransport { pub(crate) fn process_handle(&self) -> StdioServerProcessHandle { self.process.clone() } } impl StdioServerCommand { /// Build the stdio process parameters before choosing where the process /// runs. pub(super) fn new( program: OsString, args: Vec, env: Option>, env_vars: Vec, cwd: Option, ) -> Self { Self { program, args, env, env_vars, cwd, } } } // Local public implementation. /// Starts MCP stdio servers as local child processes. /// /// This is the existing behavior for local MCP servers: the orchestrator /// process spawns the configured command and rmcp talks to the child's local /// stdin/stdout pipes directly. #[derive(Clone)] pub struct LocalStdioServerLauncher { fallback_cwd: PathBuf, } impl LocalStdioServerLauncher { /// Creates a local stdio launcher. /// /// `fallback_cwd` is used when the MCP server config omits `cwd`, so /// relative commands resolve from the caller's runtime working directory. pub fn new(fallback_cwd: PathBuf) -> Self { Self { fallback_cwd } } } impl StdioServerLauncher for LocalStdioServerLauncher { fn launch( &self, command: StdioServerCommand, ) -> BoxFuture<'static, io::Result> { let fallback_cwd = self.fallback_cwd.clone(); async move { Self::launch_server(command, fallback_cwd) }.boxed() } } // Local private implementation. #[cfg(unix)] const PROCESS_GROUP_TERM_GRACE_PERIOD: Duration = Duration::from_secs(2); #[cfg(unix)] struct LocalProcessTerminator { process_group_id: u32, } #[cfg(windows)] struct LocalProcessTerminator { pid: u32, } #[cfg(not(any(unix, windows)))] struct LocalProcessTerminator; #[derive(Clone)] pub(crate) struct StdioServerProcessHandle { inner: Arc, } struct StdioServerProcessHandleInner { program_name: String, kind: StdioServerProcessKind, terminated: AtomicBool, } enum StdioServerProcessKind { Local(Option), Executor(Arc), } mod private { pub trait Sealed {} } impl private::Sealed for LocalStdioServerLauncher {} impl LocalStdioServerLauncher { fn launch_server( command: StdioServerCommand, fallback_cwd: PathBuf, ) -> io::Result { let StdioServerCommand { program, args, env, env_vars, cwd, } = command; let program_name = program.to_string_lossy().into_owned(); let envs = create_env_for_mcp_server(env, &env_vars).map_err(io::Error::other)?; let cwd = cwd.map(PathBuf::from).unwrap_or(fallback_cwd); let resolved_program = program_resolver::resolve(program, &envs, &cwd).map_err(io::Error::other)?; let mut command = Command::new(resolved_program); command .kill_on_drop(true) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .current_dir(cwd) .env_clear() .envs(envs) .args(args); #[cfg(unix)] command.process_group(0); let (transport, stderr) = TokioChildProcess::builder(command) .stderr(Stdio::piped()) .spawn()?; let process = StdioServerProcessHandle::local( program_name.clone(), transport.id().map(LocalProcessTerminator::new), ); if let Some(stderr) = stderr { tokio::spawn(async move { let mut reader = BufReader::new(stderr).lines(); loop { match reader.next_line().await { Ok(Some(line)) => { info!("MCP server stderr ({program_name}): {line}"); } Ok(None) => break, Err(error) => { warn!("Failed to read MCP server stderr ({program_name}): {error}"); break; } } } }); } Ok(StdioServerTransport { inner: StdioServerTransportInner::Local(transport), process, }) } } impl LocalProcessTerminator { fn new(process_group_id: u32) -> Self { #[cfg(unix)] { Self { process_group_id } } #[cfg(windows)] { Self { pid: process_group_id, } } #[cfg(not(any(unix, windows)))] { let _ = process_group_id; Self } } #[cfg(unix)] fn terminate(&self) { let process_group_id = self.process_group_id; let should_escalate = match terminate_process_group(process_group_id) { Ok(exists) => exists, Err(error) => { warn!("Failed to terminate MCP process group {process_group_id}: {error}"); false } }; if should_escalate { spawn(move || { sleep(PROCESS_GROUP_TERM_GRACE_PERIOD); if let Err(error) = kill_process_group(process_group_id) { warn!("Failed to kill MCP process group {process_group_id}: {error}"); } }); } } #[cfg(windows)] fn terminate(&self) { let _ = std::process::Command::new("taskkill") .arg("/PID") .arg(self.pid.to_string()) .arg("/T") .arg("/F") .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); } #[cfg(not(any(unix, windows)))] fn terminate(&self) {} } impl StdioServerProcessHandle { fn local(program_name: String, terminator: Option) -> Self { Self { inner: Arc::new(StdioServerProcessHandleInner { program_name, kind: StdioServerProcessKind::Local(terminator), terminated: AtomicBool::new(false), }), } } pub(crate) fn executor(program_name: String, process: Arc) -> Self { Self { inner: Arc::new(StdioServerProcessHandleInner { program_name, kind: StdioServerProcessKind::Executor(process), terminated: AtomicBool::new(false), }), } } pub(crate) async fn terminate(&self) -> io::Result<()> { if self.inner.terminated.swap(true, Ordering::AcqRel) { return Ok(()); } match &self.inner.kind { StdioServerProcessKind::Local(Some(terminator)) => { terminator.terminate(); Ok(()) } StdioServerProcessKind::Local(None) => Ok(()), StdioServerProcessKind::Executor(process) => match process.terminate().await { Ok(()) => Ok(()), Err(error) => { self.inner.terminated.store(false, Ordering::Release); Err(io::Error::other(error)) } }, } } } impl Drop for StdioServerProcessHandleInner { fn drop(&mut self) { if self.terminated.swap(true, Ordering::AcqRel) { return; } match &self.kind { StdioServerProcessKind::Local(Some(terminator)) => { terminator.terminate(); } StdioServerProcessKind::Local(None) => {} StdioServerProcessKind::Executor(process) => { let process = Arc::clone(process); let program_name = self.program_name.clone(); let Ok(handle) = tokio::runtime::Handle::try_current() else { warn!( "Could not schedule remote MCP server process termination on drop ({}): no Tokio runtime is available", self.program_name ); return; }; std::mem::drop(handle.spawn(async move { if let Err(error) = process.terminate().await { warn!( "Failed to terminate remote MCP server process on drop ({program_name}): {error}" ); } })); } } } } // Remote public implementation. /// Starts MCP stdio servers through the executor process API. /// /// MCP framing still runs in the orchestrator. The executor only owns the /// child process and transports raw stdin/stdout/stderr bytes, so it does not /// need to know about MCP methods such as `initialize` or `tools/list`. #[derive(Clone)] pub struct ExecutorStdioServerLauncher { exec_backend: Arc, } impl ExecutorStdioServerLauncher { /// Creates a stdio server launcher backed by the executor process API. pub fn new(exec_backend: Arc) -> Self { Self { exec_backend } } } impl StdioServerLauncher for ExecutorStdioServerLauncher { fn launch( &self, command: StdioServerCommand, ) -> BoxFuture<'static, io::Result> { let exec_backend = Arc::clone(&self.exec_backend); async move { Self::launch_server(command, exec_backend).await }.boxed() } } // Remote private implementation. impl private::Sealed for ExecutorStdioServerLauncher {} impl ExecutorStdioServerLauncher { async fn launch_server( command: StdioServerCommand, exec_backend: Arc, ) -> io::Result { let StdioServerCommand { program, args, env, env_vars, cwd, } = command; let Some(cwd) = cwd else { return Err(io::Error::other( "executor stdio server requires an explicit cwd", )); }; let cwd: PathUri = LegacyAppPathString::from_path(Path::new(&cwd)) .try_into() .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; let program_name = program.to_string_lossy().into_owned(); let envs = create_env_overlay_for_remote_mcp_server(env, &env_vars); let remote_env_vars = remote_mcp_env_var_names(&env_vars); // The executor protocol carries argv/env as UTF-8 strings. Local stdio can // accept arbitrary OsString values because it calls the OS directly; remote // stdio must reject non-Unicode command, argument, or environment data // before sending an executor request. let argv = Self::process_api_argv(&program, &args).map_err(io::Error::other)?; let env = Self::process_api_env(envs).map_err(io::Error::other)?; let process_id = ExecutorProcessTransport::next_process_id(); // Start the MCP server process on the executor with raw pipes. `tty=false` // keeps stdout as a clean protocol stream, while `pipe_stdin=true` lets // rmcp write JSON-RPC requests after the process starts. let started = exec_backend .start(ExecParams { process_id, argv, cwd, env_policy: Some(Self::remote_env_policy(&remote_env_vars)), env, tty: false, pipe_stdin: true, arg0: None, sandbox: None, enforce_managed_network: false, managed_network: None, }) .await .map_err(io::Error::other)?; let process = StdioServerProcessHandle::executor(program_name.clone(), Arc::clone(&started.process)); Ok(StdioServerTransport { inner: StdioServerTransportInner::Executor(ExecutorProcessTransport::new( started.process, program_name, )), process, }) } fn process_api_argv(program: &OsString, args: &[OsString]) -> Result> { let mut argv = Vec::with_capacity(args.len() + 1); argv.push(Self::os_string_to_process_api_string( program.clone(), "command", )?); for arg in args { argv.push(Self::os_string_to_process_api_string( arg.clone(), "argument", )?); } Ok(argv) } fn process_api_env(env: HashMap) -> Result> { env.into_iter() .map(|(key, value)| { Ok(( Self::os_string_to_process_api_string(key, "environment variable name")?, Self::os_string_to_process_api_string(value, "environment variable value")?, )) }) .collect() } fn os_string_to_process_api_string(value: OsString, label: &str) -> Result { value .into_string() .map_err(|_| anyhow!("{label} must be valid Unicode for remote MCP stdio")) } fn remote_env_policy(remote_env_vars: &[String]) -> ExecEnvPolicy { let include_only = if remote_env_vars.is_empty() { Vec::new() } else { // `source = "remote"` means the value is read from the executor's // environment, not copied from Codex. Start from `All` only so the // named remote variable is available to the filter below; the // effective child env is still limited by `include_only`. crate::utils::DEFAULT_ENV_VARS .iter() .map(|name| (*name).to_string()) .chain(remote_env_vars.iter().cloned()) .collect() }; ExecEnvPolicy { inherit: if remote_env_vars.is_empty() { ShellEnvironmentPolicyInherit::Core } else { ShellEnvironmentPolicyInherit::All }, ignore_default_excludes: true, exclude: Vec::new(), r#set: HashMap::new(), include_only, } } } #[cfg(test)] mod tests { use super::*; use codex_protocol::config_types::EnvironmentVariablePattern; use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::shell_environment; #[test] fn remote_env_policy_uses_core_env_without_remote_source_vars() { let policy = ExecutorStdioServerLauncher::remote_env_policy(&[]); assert_eq!(policy.inherit, ShellEnvironmentPolicyInherit::Core); assert!(policy.include_only.is_empty()); } #[test] fn remote_env_policy_includes_remote_source_vars_without_full_env() { let policy = ExecutorStdioServerLauncher::remote_env_policy(&["REMOTE_TOKEN".to_string()]); assert_eq!(policy.inherit, ShellEnvironmentPolicyInherit::All); assert!( policy.include_only.contains(&"REMOTE_TOKEN".to_string()), "remote source var should be included in executor env policy" ); assert!( policy .include_only .contains(&crate::utils::DEFAULT_ENV_VARS[0].to_string()), "remote default env vars should remain available" ); } #[test] fn remote_env_policy_effectively_filters_unrequested_vars() { let exec_policy = ExecutorStdioServerLauncher::remote_env_policy(&["REMOTE_TOKEN".to_string()]); let policy = ShellEnvironmentPolicy { inherit: exec_policy.inherit, ignore_default_excludes: exec_policy.ignore_default_excludes, exclude: exec_policy .exclude .iter() .map(|pattern| EnvironmentVariablePattern::new_case_insensitive(pattern)) .collect(), r#set: exec_policy.r#set, include_only: exec_policy .include_only .iter() .map(|pattern| EnvironmentVariablePattern::new_case_insensitive(pattern)) .collect(), use_profile: false, }; let env = shell_environment::create_env_from_vars( [ ("PATH".to_string(), "/remote/bin".to_string()), ("REMOTE_TOKEN".to_string(), "remote-secret".to_string()), ( "UNREQUESTED_SECRET".to_string(), "must-not-pass".to_string(), ), ], &policy, /*thread_id*/ None, ); assert_eq!(env.get("PATH").map(String::as_str), Some("/remote/bin")); assert_eq!( env.get("REMOTE_TOKEN").map(String::as_str), Some("remote-secret") ); assert!(!env.contains_key("UNREQUESTED_SECRET")); } }