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
This commit is contained in:
Charlie Marsh
2026-11-14 01:54:53 +00:00
committed by copyberry
parent 9825e4b2fe
commit e9919f4b4e
17 changed files with 378 additions and 45 deletions
+15 -6
View File
@@ -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<RwLock<CloudConfigBundleLoader>>,
arg0_paths: Arg0DispatchPaths,
mcp_launch_cwds: Arc<McpLaunchCwds>,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
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)]
+6 -1
View File
@@ -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?;
+6
View File
@@ -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)]
@@ -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<OsString>,
#[arg(long = "mcp-launch-cwd", hide = true, num_args = 3, value_names = ["SERVER", "EXECUTABLE", "DIRECTORY"])]
configured_entries: Vec<OsString>,
}
impl PluginMcpLaunchArgs {
pub(crate) fn into_cwds(self) -> io::Result<McpLaunchCwds> {
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",
)
})
}
@@ -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",
+70 -1
View File
@@ -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<RoleServer>,
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
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());
}
+5
View File
@@ -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, _) => {
+6 -25
View File
@@ -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<AbsolutePathBuf> {
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 {
+37 -7
View File
@@ -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,
@@ -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<AbsolutePathBuf> {
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<Value>,
state: SandboxState,
+6
View File
@@ -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"
},
+84
View File
@@ -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()?;
@@ -154,6 +154,7 @@ impl ManagedFeatures {
for feature in [
Feature::EnableMcpApps,
Feature::McpConnectionSharing,
Feature::McpSharedReplCwd,
Feature::McpLazyStdioStartup,
Feature::SecretAuthStorage,
Feature::McpOAuthRefreshCoordination,
+38 -5
View File
@@ -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<String, HashMap<String, AbsolutePathBuf>>,
/// Server name, expected executable, and launch directory.
pub configured: HashMap<String, (String, AbsolutePathBuf)>,
}
/// 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<McpLaunchCwds>,
/// 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<PathBuf>,
@@ -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,
@@ -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,
+8
View File
@@ -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",
@@ -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,