From e9919f4b4ef9aa4b15af92589c7f44581d1ad26a Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sat, 14 Nov 2026 01:43:06 +0000 Subject: [PATCH] Add process-local launch directories for shared MCP servers (#61444) ## What changed - Add hidden app-server options `--plugin-mcp-launch-cwd` and `--mcp-launch-cwd` for host-supplied local stdio server launch directories without writing them to shared configuration. - Apply these directories when both `mcp_shared_repl_cwd` and `mcp_connection_sharing` are enabled. For configured servers, require a matching executable and preserve any explicit working directory. - Attach thread-specific sandbox state to direct MCP tool calls for servers advertising support, stripping caller-supplied sandbox and launcher metadata first. ## Testing Add regression tests for launch-directory feature toggling and executable matching, and for direct MCP calls using each thread's sandbox working directory despite forged caller metadata. GitOrigin-RevId: d7f78ea92163ec0ee9311f68ef17e87a6694b13b --- codex-rs/app-server/src/config_manager.rs | 21 +++-- codex-rs/app-server/src/lib.rs | 7 +- codex-rs/app-server/src/main.rs | 6 ++ codex-rs/app-server/src/plugin_mcp_launch.rs | 63 ++++++++++++++ .../request_processors/config_processor.rs | 1 + .../app-server/tests/suite/v2/mcp_tool.rs | 71 +++++++++++++++- codex-rs/cli/src/main.rs | 5 ++ codex-rs/codex-mcp/src/binding.rs | 31 ++----- codex-rs/codex-mcp/src/connection_manager.rs | 44 ++++++++-- codex-rs/codex-mcp/src/sandbox_metadata.rs | 30 +++++++ codex-rs/core/config.schema.json | 6 ++ codex-rs/core/src/config/config_tests.rs | 84 +++++++++++++++++++ codex-rs/core/src/config/managed_features.rs | 1 + codex-rs/core/src/config/mod.rs | 43 ++++++++-- codex-rs/core/src/config/runtime_refresh.rs | 1 + codex-rs/features/src/lib.rs | 8 ++ codex-rs/thread-manager-sample/src/main.rs | 1 + 17 files changed, 378 insertions(+), 45 deletions(-) create mode 100644 codex-rs/app-server/src/plugin_mcp_launch.rs diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index 3495e6c31e..dc75d18c07 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -11,6 +11,7 @@ use codex_config::loader::load_managed_requirements_state; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; +use codex_core::config::McpLaunchCwds; use codex_exec_server::LOCAL_FS; use codex_features::feature_for_key; use codex_login::AuthManager; @@ -49,6 +50,7 @@ pub(crate) struct ConfigManager { strict_config: bool, cloud_config_bundle: Arc>, arg0_paths: Arg0DispatchPaths, + mcp_launch_cwds: Arc, thread_config_loader: Arc, network_policy: codex_http_client::NetworkPolicyController, local_network_policy: codex_http_client::NetworkPolicyController, @@ -108,6 +110,7 @@ impl ConfigManager { strict_config, cloud_config_bundle: Arc::new(RwLock::new(cloud_config_bundle)), arg0_paths, + mcp_launch_cwds: Arc::default(), thread_config_loader, network_policy, local_network_policy: Default::default(), @@ -117,6 +120,11 @@ impl ConfigManager { } } + pub(crate) fn with_mcp_launch_cwds(mut self, cwds: McpLaunchCwds) -> Self { + self.mcp_launch_cwds = Arc::new(cwds); + self + } + pub(crate) fn codex_home(&self) -> &Path { self.codex_home.as_path() } @@ -283,7 +291,7 @@ impl ConfigManager { self.check_application_policy_load(&policy_load)?; self.apply_network_policy(&mut config); self.apply_runtime_feature_enablement(&mut config); - self.apply_arg0_paths(&mut config); + self.apply_process_settings(&mut config); config.config_layer_stack = config .config_layer_stack .with_mcp_ema_cancellation(mcp_ema_cancellation); @@ -309,7 +317,7 @@ impl ConfigManager { .await?; self.apply_network_policy(&mut config); self.apply_runtime_feature_enablement(&mut config); - self.apply_arg0_paths(&mut config); + self.apply_process_settings(&mut config); config.config_layer_stack = config .config_layer_stack .with_mcp_ema_cancellation(mcp_ema_cancellation); @@ -338,7 +346,7 @@ impl ConfigManager { .await?; self.apply_network_policy(&mut config); self.apply_runtime_feature_enablement(&mut config); - self.apply_arg0_paths(&mut config); + self.apply_process_settings(&mut config); config.config_layer_stack = config .config_layer_stack .with_mcp_ema_cancellation(mcp_ema_cancellation); @@ -478,7 +486,7 @@ impl ConfigManager { .await?; self.apply_network_policy(&mut config); self.apply_runtime_feature_enablement(&mut config); - self.apply_arg0_paths(&mut config); + self.apply_process_settings(&mut config); config.config_layer_stack = config .config_layer_stack .with_mcp_ema_cancellation(mcp_ema_cancellation); @@ -700,7 +708,7 @@ impl ConfigManager { self.check_application_policy_load(&policy_load)?; self.apply_network_policy(&mut config); self.apply_runtime_feature_enablement(&mut config); - self.apply_arg0_paths(&mut config); + self.apply_process_settings(&mut config); config.config_layer_stack = config .config_layer_stack .with_mcp_ema_cancellation(mcp_ema_cancellation); @@ -764,9 +772,10 @@ impl ConfigManager { ); } - fn apply_arg0_paths(&self, config: &mut Config) { + fn apply_process_settings(&self, config: &mut Config) { config.codex_self_exe = self.arg0_paths.codex_self_exe.clone(); config.codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); + config.mcp_launch_cwds = Arc::clone(&self.mcp_launch_cwds); } #[cfg(test)] diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index fcf03fc607..d7cc906c88 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -108,6 +108,7 @@ mod code_mode_host; mod codex_home_metrics; mod command_exec; mod config_layer; +mod plugin_mcp_launch; mod startup_config; pub use startup_config::StartupConfigArgs; pub use startup_config::print_startup_config; @@ -160,6 +161,7 @@ pub use crate::code_mode_host::AppServerCodeModeHostArgs; pub use crate::code_mode_host::CodeModeHostTransport; pub use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; pub use crate::error_code::INVALID_PARAMS_ERROR_CODE; +pub use crate::plugin_mcp_launch::PluginMcpLaunchArgs; pub use crate::transport::AppServerTransport; pub use crate::transport::RemoteControlStartupMode; pub use crate::transport::app_server_control_socket_path; @@ -492,6 +494,7 @@ pub struct AppServerRuntimeOptions { pub remote_control_startup_mode: RemoteControlStartupMode, pub install_shutdown_signal_handler: bool, pub managed_daemon: bool, + pub plugin_mcp_launch: PluginMcpLaunchArgs, } impl Default for AppServerRuntimeOptions { @@ -503,6 +506,7 @@ impl Default for AppServerRuntimeOptions { remote_control_startup_mode: RemoteControlStartupMode::ResolvePersisted, install_shutdown_signal_handler: true, managed_daemon: false, + plugin_mcp_launch: PluginMcpLaunchArgs::default(), } } } @@ -570,7 +574,8 @@ pub async fn run_main_with_transport_options( Default::default(), arg0_paths.clone(), Arc::new(NoopThreadConfigLoader), - ); + ) + .with_mcp_launch_cwds(runtime_options.plugin_mcp_launch.into_cwds()?); let startup = config_manager .load_managed_startup_config(startup_cwd.clone()) .await?; diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 9e527dea70..fe2be58cf4 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -4,6 +4,7 @@ use clap::Parser; use codex_app_server::AppServerCodeModeHostArgs; use codex_app_server::AppServerRuntimeOptions; use codex_app_server::AppServerTransport; +use codex_app_server::PluginMcpLaunchArgs; use codex_app_server::PluginStartupTasks; use codex_app_server::run_main_with_transport_options; use codex_arg0::Arg0DispatchPaths; @@ -77,6 +78,9 @@ struct AppServerArgs { /// Save loaded threads during managed daemon shutdown. #[arg(long, hide = true)] managed_daemon: bool, + + #[command(flatten)] + plugin_mcp_launch: PluginMcpLaunchArgs, } fn main() -> anyhow::Result<()> { @@ -94,6 +98,7 @@ fn main() -> anyhow::Result<()> { disable_plugin_startup_tasks_for_tests, remote_control, managed_daemon, + plugin_mcp_launch, } = AppServerArgs::parse(); let loader_overrides = if disable_managed_config_from_debug_env() { LoaderOverrides::without_managed_config_for_tests() @@ -108,6 +113,7 @@ fn main() -> anyhow::Result<()> { cloud_skills_snapshot, code_mode_host_transport: code_mode_host.into(), managed_daemon, + plugin_mcp_launch, ..Default::default() }; #[cfg(debug_assertions)] diff --git a/codex-rs/app-server/src/plugin_mcp_launch.rs b/codex-rs/app-server/src/plugin_mcp_launch.rs new file mode 100644 index 0000000000..acca45b9ae --- /dev/null +++ b/codex-rs/app-server/src/plugin_mcp_launch.rs @@ -0,0 +1,63 @@ +//! Parses the host's process-local launch directories for stdio MCP servers. +//! Validates server names, plugin IDs, and absolute paths before passing the +//! directories to Core. These overrides are never written to shared config. + +use std::ffi::OsString; +use std::io; +use std::path::Path; + +use clap::Args; +use codex_core::config::McpLaunchCwds; +use codex_plugin::PluginId; +use codex_utils_absolute_path::AbsolutePathBuf; + +/// Launch options for stdio servers in this app-server process. +#[derive(Args, Debug, Clone, Default, PartialEq, Eq)] +pub struct PluginMcpLaunchArgs { + #[arg(long = "plugin-mcp-launch-cwd", hide = true, num_args = 3, value_names = ["PLUGIN_ID", "SERVER", "DIRECTORY"])] + entries: Vec, + #[arg(long = "mcp-launch-cwd", hide = true, num_args = 3, value_names = ["SERVER", "EXECUTABLE", "DIRECTORY"])] + configured_entries: Vec, +} + +impl PluginMcpLaunchArgs { + pub(crate) fn into_cwds(self) -> io::Result { + let mut cwds = McpLaunchCwds::default(); + for [plugin_id, server, directory] in self.entries.as_chunks::<3>().0 { + let plugin_id = plugin_id.to_str().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "plugin ID must be UTF-8") + })?; + PluginId::parse(plugin_id) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + let server = server_name(server)?; + let cwd = AbsolutePathBuf::from_absolute_path_checked(Path::new(directory))?; + cwds.plugins + .entry(plugin_id.to_owned()) + .or_default() + .insert(server.to_owned(), cwd); + } + for [server, executable, directory] in self.configured_entries.as_chunks::<3>().0 { + let server = server_name(server)?; + let executable = executable.to_str().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "executable must be UTF-8") + })?; + AbsolutePathBuf::from_absolute_path_checked(Path::new(executable))?; + let cwd = AbsolutePathBuf::from_absolute_path_checked(Path::new(directory))?; + cwds.configured + .insert(server.to_owned(), (executable.to_owned(), cwd)); + } + Ok(cwds) + } +} + +fn server_name(value: &OsString) -> io::Result<&str> { + value + .to_str() + .filter(|name| !name.is_empty()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "server name must be nonempty UTF-8", + ) + }) +} diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 1e7663e6f7..944e44fea9 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -76,6 +76,7 @@ const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ "mcp_2026_07_28", "mcp_connection_sharing", "mcp_lazy_stdio_startup", + "mcp_shared_repl_cwd", "memories", "mentions_v2", "remote_control", diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index 1aaed0e4af..bedccb2dea 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -42,6 +42,8 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_features::Feature; +use codex_mcp::MCP_SANDBOX_STATE_CAPABILITY; +use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY; use codex_protocol::mcp::OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID; use codex_protocol::protocol::SessionSource as CoreSessionSource; use codex_protocol::protocol::ThreadSource as CoreThreadSource; @@ -100,6 +102,63 @@ const URL_ELICITATION_TRIGGER_MESSAGE: &str = "auth"; const URL_ELICITATION_MESSAGE: &str = "Sign in to GitHub to continue."; const URL_ELICITATION_URL: &str = "https://github.example/login/device"; const LATE_ENVIRONMENT_ID: &str = "late-environment"; +const SANDBOX_STATE_TOOL_NAME: &str = "sandbox_state"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_server_tool_call_uses_each_threads_sandbox_cwd() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let (mcp_server_url, mcp_server_handle) = + start_mcp_server_with_tools(&[SANDBOX_STATE_TOOL_NAME], /*sensitive_action*/ None).await?; + let codex_home = TempDir::new()?; + mcp_tool_config(&responses_server.uri(), &mcp_server_url, AUTO_COMPACT_LIMIT) + .write(codex_home.path())?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + + let workspace_a = TempDir::new()?; + let workspace_b = TempDir::new()?; + for workspace in [workspace_a.path(), workspace_b.path()] { + let cwd = workspace.canonicalize()?; + let cwd_uri = PathUri::from_host_native_path(cwd.clone())?; + let mut environment = mcp.auto_env_params()?; + if environment.environment_id == codex_exec_server::LOCAL_ENVIRONMENT_ID { + environment.cwd = cwd_uri.clone().into(); + } + let ThreadStartResponse { thread, .. } = mcp + .request(|request_id| ClientRequest::ThreadStart { + request_id, + params: ThreadStartParams { + cwd: Some(cwd.display().to_string()), + environments: Some(vec![environment]), + ..Default::default() + }, + }) + .await?; + let response: McpServerToolCallResponse = mcp + .request(|request_id| ClientRequest::McpServerToolCall { + request_id, + params: McpServerToolCallParams { + environment_id: None, + thread_id: thread.id, + server: TEST_SERVER_NAME.to_string(), + tool: SANDBOX_STATE_TOOL_NAME.to_string(), + arguments: None, + meta: Some(json!({ + MCP_SANDBOX_STATE_META_CAPABILITY: {"sandboxCwd": "file:///forged"} + })), + }, + }) + .await?; + let metadata = response.structured_content.expect("sandbox metadata"); + let state = &metadata[MCP_SANDBOX_STATE_CAPABILITY]; + assert_eq!(state, &metadata[MCP_SANDBOX_STATE_META_CAPABILITY]); + assert_eq!(state["sandboxCwd"], json!(cwd_uri)); + } + mcp_server_handle.abort(); + Ok(()) +} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_server_tool_call_returns_tool_result() -> Result<()> { @@ -1342,7 +1401,14 @@ impl ServerHandler for ToolAppsMcpServer { } fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + let mut capabilities = ServerCapabilities::builder().enable_tools().build(); + if self.tool_names.contains(&SANDBOX_STATE_TOOL_NAME) { + capabilities + .experimental + .get_or_insert_with(Default::default) + .insert(MCP_SANDBOX_STATE_CAPABILITY.to_string(), JsonObject::new()); + } + ServerConfig::new(capabilities) } async fn list_tools( @@ -1385,6 +1451,9 @@ impl ServerHandler for ToolAppsMcpServer { context: RequestContext, ) -> Result { assert!(self.tool_names.contains(&request.name.as_ref())); + if request.name.as_ref() == SANDBOX_STATE_TOOL_NAME { + return Ok(CallToolResult::structured(json!(context.meta.0.0)).into()); + } if matches!(request.name.as_ref(), "js_reset" | "js_add_node_module_dir") { return Ok(CallToolResult::success(vec![ContentBlock::text("setup complete")]).into()); } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 5108253a5e..172eb1ac0d 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -619,6 +619,9 @@ struct AppServerCommand { #[arg(long, hide = true)] managed_daemon: bool, + #[command(flatten)] + plugin_mcp_launch: codex_app_server::PluginMcpLaunchArgs, + /// Controls whether analytics are enabled by default. /// /// Analytics are disabled by default for app-server. Users have to explicitly opt in @@ -1288,6 +1291,7 @@ async fn dispatch_cli( stdio, remote_control, managed_daemon, + plugin_mcp_launch, analytics_default_enabled, auth, } = app_server_cli; @@ -1333,6 +1337,7 @@ async fn dispatch_cli( cloud_skills_snapshot, code_mode_host_transport: code_mode_host.into(), managed_daemon, + plugin_mcp_launch, remote_control_startup_mode: match (remote_control, remote_control_disabled) { (true, _) => { diff --git a/codex-rs/codex-mcp/src/binding.rs b/codex-rs/codex-mcp/src/binding.rs index 7ce365d395..294a81d44f 100644 --- a/codex-rs/codex-mcp/src/binding.rs +++ b/codex-rs/codex-mcp/src/binding.rs @@ -331,31 +331,12 @@ impl PreparedMcpCall { /// loopback) or remote executor does not establish a shared filesystem. /// Capability probes are cached process-wide by absolute executable path. pub async fn sandbox_codex_executable(&self) -> Option { - let server = self - .config - .mcp_server_catalog - .server(self.server_key())? - .config(); - if self.server_environment_id() != codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID - || !server.is_local_environment() - || !matches!( - server.transport, - codex_config::McpServerTransportConfig::Stdio { .. } - ) - { - return None; - } - - // Require a literal absolute path before the constructor can expand `~`. - let executable = self - .config - .codex_self_exe - .as_deref() - .filter(|path| path.is_absolute() && path.to_str().is_some())?; - let executable = AbsolutePathBuf::from_absolute_path_checked(executable).ok()?; - crate::sandbox_executable::supports_sandbox_state(&executable) - .await - .then_some(executable) + crate::sandbox_metadata::sandbox_codex_executable( + &self.config, + self.server_key(), + self.server_environment_id(), + ) + .await } pub fn server_name(&self) -> &str { diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 4e47313497..fc1545f279 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -61,6 +61,8 @@ use crate::rmcp_client::prepare_regular_mcp_tools_for_model; use crate::runtime::McpPublicationGate; use crate::runtime::McpRuntimeInput; use crate::runtime::McpStartupPolicy; +use crate::sandbox_metadata; +use crate::sandbox_metadata::SandboxState; use crate::server::McpServerConnectionIdentity; use crate::server::McpServerMetadata; use crate::tools::ToolFilter; @@ -1540,9 +1542,39 @@ impl McpConnectionSet { } (server_timeout, requested_timeout) => server_timeout.or(requested_timeout), }; - // Direct callers cannot supply host-owned entitlement metadata, even for unlisted tools. - if let Some(serde_json::Value::Object(meta)) = meta.as_mut() { - meta.remove(ENTITLEMENT_CONTEXT_KEY); + // Direct callers cannot set host-owned authority, even for unlisted tools. + if let Some(serde_json::Value::Object(metadata)) = meta.as_mut() { + metadata.remove(ENTITLEMENT_CONTEXT_KEY); + metadata.remove(sandbox_metadata::MCP_SANDBOX_STATE_CAPABILITY); + metadata.remove(sandbox_metadata::MCP_SANDBOX_STATE_META_CAPABILITY); + metadata.remove(sandbox_metadata::MCP_SANDBOX_LAUNCHER_META_KEY); + } + let environment_id = &view.metadata.environment_id; + let permission_profile = self + .config + .as_ref() + .and_then(|config| config.permission_profile_for_server(server_key)); + if client.server_supports_sandbox_state_meta_capability + && let Some(config) = self.config.as_ref() + && let Some(permission_profile) = permission_profile + && let Some(sandbox_cwd) = config.environment_cwds.get(environment_id) + { + sandbox_metadata::augment_metadata( + &mut meta, + SandboxState { + permission_profile: permission_profile.clone(), + codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), + sandbox_cwd: sandbox_cwd.clone(), + use_legacy_landlock: config.use_legacy_landlock, + use_mxc: config + .environment_use_mxc + .get(environment_id) + .copied() + .unwrap_or(false), + }, + sandbox_metadata::sandbox_codex_executable(config, server_key, environment_id) + .await, + )?; } let result: rmcp::model::CallToolResult = client .call_tool( @@ -1550,10 +1582,8 @@ impl McpConnectionSet { arguments, self.network_policy_meta_for_call( &client, - self.config - .as_ref() - .and_then(|config| config.permission_profile_for_server(server_key)), - &view.metadata.environment_id, + permission_profile, + environment_id, meta, ), effective_timeout, diff --git a/codex-rs/codex-mcp/src/sandbox_metadata.rs b/codex-rs/codex-mcp/src/sandbox_metadata.rs index c5adfa3381..242fc13772 100644 --- a/codex-rs/codex-mcp/src/sandbox_metadata.rs +++ b/codex-rs/codex-mcp/src/sandbox_metadata.rs @@ -11,6 +11,9 @@ use serde_json::Map; use serde_json::Value; use std::path::PathBuf; +use crate::McpConfig; +use crate::McpServerKey; + /// Servers advertise this experimental capability to receive opaque sandbox state /// in tool-call `_meta`, along with a local launcher when one is available. pub const MCP_SANDBOX_STATE_CAPABILITY: &str = "codex/sandbox-state"; @@ -50,6 +53,33 @@ struct SandboxLauncher { codex_executable: AbsolutePathBuf, } +/// Return a verified CLI on the same host and filesystem as a local stdio server. +pub(crate) async fn sandbox_codex_executable( + config: &McpConfig, + server_key: &McpServerKey, + environment_id: &str, +) -> Option { + let server = config.mcp_server_catalog.server(server_key)?.config(); + if environment_id != codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID + || !server.is_local_environment() + || !matches!( + server.transport, + codex_config::McpServerTransportConfig::Stdio { .. } + ) + { + return None; + } + + let executable = config + .codex_self_exe + .as_deref() + .filter(|path| path.is_absolute() && path.to_str().is_some())?; + let executable = AbsolutePathBuf::from_absolute_path_checked(executable).ok()?; + crate::sandbox_executable::supports_sandbox_state(&executable) + .await + .then_some(executable) +} + pub(crate) fn augment_metadata( meta: &mut Option, state: SandboxState, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index d63fb5569d..7fcf348063 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1193,6 +1193,9 @@ "mcp_oauth_refresh_coordination": { "type": "boolean" }, + "mcp_shared_repl_cwd": { + "type": "boolean" + }, "mcp_tool_list_changed": { "type": "boolean" }, @@ -8362,6 +8365,9 @@ "mcp_oauth_refresh_coordination": { "type": "boolean" }, + "mcp_shared_repl_cwd": { + "type": "boolean" + }, "mcp_tool_list_changed": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index ba1fc0484f..e1b9fae84d 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -6100,6 +6100,90 @@ async fn rebuild_with_session_layers_refreshes_plugin_derived_mcp_config() -> an Ok(()) } +#[tokio::test] +async fn mcp_launch_cwds_follow_the_runtime_feature() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = codex_home.path().join("plugins/cache/test/sample/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + )?; + std::fs::write( + plugin_root.join(".mcp.json"), + r#"{"mcpServers":{"sample":{"command":"sample","enabled":false},"other":{"command":"other"}}}"#, + )?; + let user_config = toml::toml! { + [features] + plugins = true + mcp_connection_sharing = true + + [plugins."sample@test"] + enabled = true + + [mcp_servers.node_repl] + command = "test-repl" + + [mcp_servers.artifact_session] + command = "test-repl" + + [mcp_servers.other_repl] + command = "custom-repl" + }; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + toml::to_string(&user_config)?, + )?; + + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await?; + config.mcp_launch_cwds = Arc::new(McpLaunchCwds { + plugins: HashMap::from([( + "sample@test".to_string(), + HashMap::from([("sample".to_string(), config.codex_home.clone())]), + )]), + configured: ["node_repl", "artifact_session", "other_repl"] + .into_iter() + .map(|name| { + ( + name.to_string(), + ("test-repl".to_string(), config.codex_home.clone()), + ) + }) + .collect(), + }); + let plugins_manager = + plugins_manager_for_config(&config, auth_manager_from_optional_auth(/*auth*/ None)); + assert!(!config.features.enabled(Feature::McpSharedReplCwd)); + for enabled in [false, true, false] { + let mut incoming = config.clone(); + incoming + .features + .set_enabled(Feature::McpSharedReplCwd, enabled)?; + config = config.resolve_runtime_refresh(&incoming, RuntimeConfigRefresh::User)?; + let mcp_config = config.to_mcp_config(&plugins_manager).await; + let configured_servers = mcp_config.mcp_server_catalog.configured_servers(); + for (name, command) in [ + ("sample", "sample"), + ("node_repl", "test-repl"), + ("artifact_session", "test-repl"), + ] { + let mut expected = stdio_mcp(command); + expected.enabled = name != "sample"; + let McpServerTransportConfig::Stdio { cwd, .. } = &mut expected.transport else { + unreachable!(); + }; + *cwd = enabled.then(|| LegacyAppPathString::from_abs_path(&config.codex_home)); + assert_eq!(configured_servers[name], expected); + } + assert_eq!(configured_servers["other"], stdio_mcp("other")); + assert_eq!(configured_servers["other_repl"], stdio_mcp("custom-repl")); + } + Ok(()) +} + #[tokio::test] async fn to_mcp_config_omits_plugin_id_when_user_server_shadows_plugin_mcp() -> anyhow::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/managed_features.rs b/codex-rs/core/src/config/managed_features.rs index a40e6de0f5..d3f8b6d5b1 100644 --- a/codex-rs/core/src/config/managed_features.rs +++ b/codex-rs/core/src/config/managed_features.rs @@ -154,6 +154,7 @@ impl ManagedFeatures { for feature in [ Feature::EnableMcpApps, Feature::McpConnectionSharing, + Feature::McpSharedReplCwd, Feature::McpLazyStdioStartup, Feature::SecretAuthStorage, Feature::McpOAuthRefreshCoordination, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3f732353b7..2194413f4c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -47,6 +47,7 @@ use codex_config::types::AuthCredentialsStoreMode; use codex_config::types::AuthKeyringBackendKind; use codex_config::types::History; use codex_config::types::McpServerConfig; +use codex_config::types::McpServerTransportConfig; use codex_config::types::MemoriesConfig; use codex_config::types::ModelAvailabilityNuxConfig; use codex_config::types::Notice; @@ -143,6 +144,7 @@ use codex_sandboxing::SandboxType; pub use codex_thread_store::ExtraConfig; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; +use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use codex_utils_path_uri::Platform; @@ -346,6 +348,14 @@ pub(crate) async fn test_config() -> Config { .expect("load default test config") } +/// Process-local launch directories for stdio servers supplied by the host. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct McpLaunchCwds { + pub plugins: HashMap>, + /// Server name, expected executable, and launch directory. + pub configured: HashMap, +} + /// Application configuration loaded from disk and merged with overrides. #[derive(Debug, Clone, PartialEq)] pub struct Permissions { @@ -1047,6 +1057,9 @@ pub struct Config { /// output will be hyperlinked using the specified URI scheme. pub file_opener: UriBasedFileOpener, + /// Process-local launch directories for stdio servers supplied by the host. + pub mcp_launch_cwds: Arc, + /// Path to the current Codex executable. This cannot be set in the config /// file: it must be set in code via [`ConfigOverrides`]. pub codex_self_exe: Option, @@ -1947,6 +1960,8 @@ impl Config { { catalog.enable_ema(auth.idp.clone()); } + let use_launch_cwds = self.features.enabled(Feature::McpSharedReplCwd) + && self.features.enabled(Feature::McpConnectionSharing); for (plugin_order, plugin) in loaded_plugins .plugins() .iter() @@ -1967,7 +1982,17 @@ impl Config { ) } .with_host_root(PathUri::from_abs_path(&plugin.root)); - for (name, plugin_server) in plugin_mcp_servers { + let launch_cwds = self.mcp_launch_cwds.plugins.get(&plugin.config_name); + for (name, mut plugin_server) in plugin_mcp_servers { + if use_launch_cwds + && let Some(path) = launch_cwds.and_then(|cwds| cwds.get(&name)) + && plugin_server.is_local_environment() + && !plugin.is_agent_plugin() + && let McpServerTransportConfig::Stdio { cwd, .. } = + &mut plugin_server.transport + { + *cwd = Some(LegacyAppPathString::from_abs_path(path)); + } let registration = McpServerRegistration::from_plugin( name, attribution.clone(), @@ -1981,10 +2006,17 @@ impl Config { catalog.register(registration); } for (name, server) in self.mcp_servers.get() { - catalog.register(McpServerRegistration::from_config( - name.clone(), - server.clone(), - )); + let mut server = server.clone(); + if use_launch_cwds + && let Some((executable, path)) = self.mcp_launch_cwds.configured.get(name) + && server.is_local_environment() + && let McpServerTransportConfig::Stdio { command, cwd, .. } = &mut server.transport + && command == executable + && cwd.is_none() + { + *cwd = Some(LegacyAppPathString::from_abs_path(path)); + } + catalog.register(McpServerRegistration::from_config(name.clone(), server)); } McpConfig { @@ -4696,6 +4728,7 @@ impl Config { extra_config: None, bypass_hook_trust, file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), + mcp_launch_cwds: Arc::default(), codex_self_exe, codex_linux_sandbox_exe, main_execve_wrapper_exe: None, diff --git a/codex-rs/core/src/config/runtime_refresh.rs b/codex-rs/core/src/config/runtime_refresh.rs index d47b722bd9..c2f74a0a80 100644 --- a/codex-rs/core/src/config/runtime_refresh.rs +++ b/codex-rs/core/src/config/runtime_refresh.rs @@ -89,6 +89,7 @@ impl Config { // Enterprise opt-in always comes from trusted layers instead. for feature in [ Feature::McpConnectionSharing, + Feature::McpSharedReplCwd, Feature::McpLazyStdioStartup, Feature::SecretAuthStorage, Feature::McpOAuthRefreshCoordination, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 701aa5eb63..ea19255aee 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -290,6 +290,8 @@ pub enum Feature { McpToolListChanged, /// Discover and share eligible local stdio connections across chats. McpConnectionSharing, + /// Use the app server's fixed launch directories for shared REPL servers. + McpSharedReplCwd, /// Defer optional stdio server startup in regular chats with a warm tool catalog. McpLazyStdioStartup, /// Keep same-named HTTP servers discovered on different executors separate. @@ -1768,6 +1770,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::McpSharedReplCwd, + key: "mcp_shared_repl_cwd", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::McpLazyStdioStartup, key: "mcp_lazy_stdio_startup", diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 6be7e7493b..43882e3248 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -360,6 +360,7 @@ async fn new_config( allow_persistent_dynamic_tool_updates: false, extra_config: None, file_opener: UriBasedFileOpener::VsCode, + mcp_launch_cwds: Default::default(), codex_self_exe: arg0_paths.codex_self_exe, codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe, main_execve_wrapper_exe: None,