Route MCP servers through explicit environments (#23583)

## Summary
- route each configured MCP server through an explicit per-server
`environment_id` instead of a manager-wide remote toggle
- default omitted `environment_id` to `local`, resolve named ids through
`EnvironmentManager`, and fail only the affected MCP server when an
explicit id is unknown
- keep local stdio on the existing local launcher path for now, while
named-environment stdio uses the selected environment backend and
requires an absolute `cwd`
- allow local HTTP MCP servers to keep using the ambient HTTP client
when no local `Environment` is configured; named-environment HTTP MCPs
use that environment's HTTP client

## Validation
- devbox Bazel build: `bazel build --bes_backend= --bes_results_url=
//codex-rs/cli:codex //codex-rs/rmcp-client:test_stdio_server
//codex-rs/rmcp-client:test_streamable_http_server`
- devbox app-server config matrix with real `config.toml` /
`environments.toml` files covering omitted local, explicit local,
omitted local under remote default, explicit remote stdio, local HTTP
without local env, explicit remote HTTP, local stdio without local env,
unknown explicit env, and remote stdio without `cwd`
This commit is contained in:
starr-openai
2026-05-21 17:19:54 +02:00
committed by GitHub
parent 97b390fbd4
commit 298e5cfce1
36 changed files with 463 additions and 431 deletions
+20 -20
View File
@@ -132,7 +132,7 @@ fn stdio_mcp(command: &str) -> McpServerConfig {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -157,7 +157,7 @@ fn http_mcp(url: &str) -> McpServerConfig {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -5144,9 +5144,9 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> {
args: vec!["hello".to_string()],
env: None,
env_vars: Vec::new(),
cwd: None,
cwd: Some(codex_home.path().to_path_buf()),
},
experimental_environment: Some("remote".to_string()),
environment_id: "remote".to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -5184,13 +5184,13 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> {
assert_eq!(args, &vec!["hello".to_string()]);
assert!(env.is_none());
assert!(env_vars.is_empty());
assert!(cwd.is_none());
assert_eq!(cwd, &Some(codex_home.path().to_path_buf()));
}
other => panic!("unexpected transport {other:?}"),
}
assert_eq!(docs.startup_timeout_sec, Some(Duration::from_secs(3)));
assert_eq!(docs.tool_timeout_sec, Some(Duration::from_secs(5)));
assert_eq!(docs.experimental_environment.as_deref(), Some("remote"));
assert_eq!(docs.environment_id, "remote");
assert!(docs.enabled);
let empty = BTreeMap::new();
@@ -5493,7 +5493,7 @@ async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -5570,7 +5570,7 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> {
env_vars: vec!["ALPHA".into(), "BETA".into()],
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -5632,7 +5632,7 @@ async fn replace_mcp_servers_serializes_sourced_env_vars() -> anyhow::Result<()>
],
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -5684,7 +5684,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> {
env_vars: Vec::new(),
cwd: Some(cwd_path.clone()),
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -5739,7 +5739,7 @@ async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -5810,7 +5810,7 @@ async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyh
"DOCS_AUTH".to_string(),
)])),
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -5893,7 +5893,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh
"DOCS_AUTH".to_string(),
)])),
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -5929,7 +5929,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -6000,7 +6000,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers()
"DOCS_AUTH".to_string(),
)])),
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -6026,7 +6026,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers()
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -6115,7 +6115,7 @@ async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: false,
required: false,
supports_parallel_tool_calls: false,
@@ -6166,7 +6166,7 @@ async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: true,
supports_parallel_tool_calls: false,
@@ -6217,7 +6217,7 @@ async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -6272,7 +6272,7 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
+2 -2
View File
@@ -300,8 +300,8 @@ mod document_helpers {
if !config.enabled {
entry["enabled"] = value(false);
}
if let Some(environment) = &config.experimental_environment {
entry["experimental_environment"] = value(environment.clone());
if !config.is_local_environment() {
entry["environment_id"] = value(config.environment_id.clone());
}
if config.required {
entry["required"] = value(true);
+7 -7
View File
@@ -945,7 +945,7 @@ fn blocking_replace_mcp_servers_round_trips() {
env_vars: vec!["FOO".into()],
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: true,
@@ -975,7 +975,7 @@ fn blocking_replace_mcp_servers_round_trips() {
),
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: false,
required: false,
supports_parallel_tool_calls: false,
@@ -1047,7 +1047,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -1112,7 +1112,7 @@ foo = { command = "cmd" }
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -1167,7 +1167,7 @@ foo = { command = "cmd" } # keep me
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: false,
required: false,
supports_parallel_tool_calls: false,
@@ -1221,7 +1221,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -1276,7 +1276,7 @@ foo = { command = "cmd" }
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: false,
required: false,
supports_parallel_tool_calls: false,
+4 -8
View File
@@ -35,7 +35,7 @@ use codex_login::CodexAuth;
use codex_login::default_client::originator;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::McpConnectionManager;
use codex_mcp::McpRuntimeEnvironment;
use codex_mcp::McpRuntimeContext;
use codex_mcp::ToolInfo;
use codex_mcp::ToolPluginProvenance;
use codex_mcp::codex_apps_tools_cache_key;
@@ -203,7 +203,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
list_accessible_connectors_from_mcp_tools_with_environment_manager(
config,
force_refetch,
&environment_manager,
Arc::new(environment_manager),
)
.await
}
@@ -211,7 +211,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
config: &Config,
force_refetch: bool,
environment_manager: &EnvironmentManager,
environment_manager: Arc<EnvironmentManager>,
) -> anyhow::Result<AccessibleConnectorsStatus> {
let auth_manager =
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
@@ -272,11 +272,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
PermissionProfile::default(),
// Connector discovery is threadless. Use an actually configured env if
// one exists, but do not reintroduce the old hidden-local fallback.
McpRuntimeEnvironment::new(
environment_manager.default_or_local_environment(),
environment_manager.try_local_environment(),
config.cwd.to_path_buf(),
),
McpRuntimeContext::new(environment_manager, config.cwd.to_path_buf()),
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
host_owned_codex_apps_enabled,
+2 -2
View File
@@ -361,7 +361,7 @@ fn mcp_dependency_to_server_config(
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -391,7 +391,7 @@ fn mcp_dependency_to_server_config(
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
+4 -13
View File
@@ -1255,11 +1255,6 @@ fn codex_apps_auth_failure_metadata() -> McpToolApprovalMetadata {
async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: &TurnContext) {
let auth = session.services.auth_manager.auth().await;
let environment = session
.services
.environment_manager
.default_or_local_environment()
.expect("test session should have an MCP runtime environment");
let (manager, _cancel_token) = codex_mcp::McpConnectionManager::new(
&HashMap::new(),
turn_context.config.mcp_oauth_credentials_store_mode,
@@ -1268,14 +1263,10 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context:
turn_context.sub_id.clone(),
session.get_tx_event(),
turn_context.permission_profile(),
codex_mcp::McpRuntimeEnvironment::new(
Some(environment),
session.services.environment_manager.try_local_environment(),
{
#[allow(deprecated)]
turn_context.cwd.to_path_buf()
},
),
codex_mcp::McpRuntimeContext::new(Arc::clone(&session.services.environment_manager), {
#[allow(deprecated)]
turn_context.cwd.to_path_buf()
}),
turn_context.config.codex_home.to_path_buf(),
codex_mcp::codex_apps_tools_cache_key(auth.as_ref()),
/*host_owned_codex_apps_enabled*/ true,
+6 -10
View File
@@ -325,17 +325,13 @@ impl Session {
host_owned_codex_apps_enabled(&mcp_config, auth.as_ref());
let auth_statuses =
compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await;
let mcp_runtime_environment = match turn_context.environments.primary() {
Some(turn_environment) => McpRuntimeEnvironment::new(
Some(Arc::clone(&turn_environment.environment)),
self.services.environment_manager.try_local_environment(),
let mcp_runtime_context = match turn_context.environments.primary() {
Some(turn_environment) => McpRuntimeContext::new(
Arc::clone(&self.services.environment_manager),
turn_environment.cwd.to_path_buf(),
),
None => McpRuntimeEnvironment::new(
self.services
.environment_manager
.default_or_local_environment(),
self.services.environment_manager.try_local_environment(),
None => McpRuntimeContext::new(
Arc::clone(&self.services.environment_manager),
#[allow(deprecated)]
turn_context.cwd.to_path_buf(),
),
@@ -353,7 +349,7 @@ impl Session {
turn_context.sub_id.clone(),
self.get_tx_event(),
turn_context.permission_profile(),
mcp_runtime_environment,
mcp_runtime_context,
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
host_owned_codex_apps_enabled,
+1 -1
View File
@@ -61,7 +61,7 @@ use codex_login::CodexAuth;
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
use codex_login::default_client::originator;
use codex_mcp::McpConnectionManager;
use codex_mcp::McpRuntimeEnvironment;
use codex_mcp::McpRuntimeContext;
use codex_mcp::codex_apps_tools_cache_key;
use codex_models_manager::manager::RefreshStrategy;
use codex_models_manager::manager::SharedModelsManager;
+6 -8
View File
@@ -1135,15 +1135,13 @@ impl Session {
})?
.primary()
.cloned();
let mcp_runtime_environment = match turn_environment {
Some(turn_environment) => McpRuntimeEnvironment::new(
Some(Arc::clone(&turn_environment.environment)),
sess.services.environment_manager.try_local_environment(),
let mcp_runtime_context = match turn_environment {
Some(turn_environment) => McpRuntimeContext::new(
Arc::clone(&sess.services.environment_manager),
turn_environment.cwd.to_path_buf(),
),
None => McpRuntimeEnvironment::new(
sess.services.environment_manager.default_or_local_environment(),
sess.services.environment_manager.try_local_environment(),
None => McpRuntimeContext::new(
Arc::clone(&sess.services.environment_manager),
session_configuration.cwd.to_path_buf(),
),
};
@@ -1155,7 +1153,7 @@ impl Session {
INITIAL_SUBMIT_ID.to_owned(),
tx_event.clone(),
session_configuration.permission_profile(),
mcp_runtime_environment,
mcp_runtime_context,
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth),
host_owned_codex_apps_enabled,