[5/6] Wire executor-backed MCP stdio (#18212)

## Summary
- Add the executor-backed RMCP stdio transport.
- Wire MCP stdio placement through the executor environment config.
- Cover local and executor-backed stdio paths with the existing MCP test
helpers.

## Stack
```text
o  #18027 [6/6] Fail exec client operations after disconnect
│
@  #18212 [5/6] Wire executor-backed MCP stdio
│
o  #18087 [4/6] Abstract MCP stdio server launching
│
o  #18020 [3/6] Add pushed exec process events
│
o  #18086 [2/6] Support piped stdin in exec process API
│
o  #18085 [1/6] Add MCP server environment config
│
o  main
```

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Ahmed Ibrahim
2026-04-18 21:47:43 -07:00
committed by GitHub
co-authored by Codex
parent e3f44ca3b3
commit 996aa23e4c
31 changed files with 1815 additions and 76 deletions
+1
View File
@@ -38,6 +38,7 @@ pub use mcp_connection_manager::CodexAppsToolsCacheKey;
pub use mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT;
pub use mcp_connection_manager::MCP_SANDBOX_STATE_META_CAPABILITY;
pub use mcp_connection_manager::McpConnectionManager;
pub use mcp_connection_manager::McpRuntimeEnvironment;
pub use mcp_connection_manager::SandboxState;
pub use mcp_connection_manager::ToolInfo;
pub use mcp_connection_manager::codex_apps_tools_cache_key;
+23 -3
View File
@@ -35,6 +35,7 @@ use codex_protocol::protocol::SandboxPolicy;
use serde_json::Value;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_connection_manager::McpRuntimeEnvironment;
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
pub type McpManager = McpConnectionManager;
@@ -321,14 +322,23 @@ pub async fn collect_mcp_snapshot(
config: &McpConfig,
auth: Option<&CodexAuth>,
submit_id: String,
runtime_environment: McpRuntimeEnvironment,
) -> McpListToolsResponseEvent {
collect_mcp_snapshot_with_detail(config, auth, submit_id, McpSnapshotDetail::Full).await
collect_mcp_snapshot_with_detail(
config,
auth,
submit_id,
runtime_environment,
McpSnapshotDetail::Full,
)
.await
}
pub async fn collect_mcp_snapshot_with_detail(
config: &McpConfig,
auth: Option<&CodexAuth>,
submit_id: String,
runtime_environment: McpRuntimeEnvironment,
detail: McpSnapshotDetail,
) -> McpListToolsResponseEvent {
let mcp_servers = effective_mcp_servers(config, auth);
@@ -356,6 +366,7 @@ pub async fn collect_mcp_snapshot_with_detail(
submit_id,
tx_event,
SandboxPolicy::new_read_only_policy(),
runtime_environment,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth),
tool_plugin_provenance,
@@ -386,15 +397,23 @@ pub async fn collect_mcp_server_status_snapshot(
config: &McpConfig,
auth: Option<&CodexAuth>,
submit_id: String,
runtime_environment: McpRuntimeEnvironment,
) -> McpServerStatusSnapshot {
collect_mcp_server_status_snapshot_with_detail(config, auth, submit_id, McpSnapshotDetail::Full)
.await
collect_mcp_server_status_snapshot_with_detail(
config,
auth,
submit_id,
runtime_environment,
McpSnapshotDetail::Full,
)
.await
}
pub async fn collect_mcp_server_status_snapshot_with_detail(
config: &McpConfig,
auth: Option<&CodexAuth>,
submit_id: String,
runtime_environment: McpRuntimeEnvironment,
detail: McpSnapshotDetail,
) -> McpServerStatusSnapshot {
let mcp_servers = effective_mcp_servers(config, auth);
@@ -422,6 +441,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
submit_id,
tx_event,
SandboxPolicy::new_read_only_policy(),
runtime_environment,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth),
tool_plugin_provenance,
@@ -36,6 +36,7 @@ use codex_async_utils::CancelErr;
use codex_async_utils::OrCancelExt;
use codex_config::Constrained;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_exec_server::Environment;
use codex_protocol::ToolName;
use codex_protocol::approvals::ElicitationRequest;
use codex_protocol::approvals::ElicitationRequestEvent;
@@ -50,6 +51,7 @@ use codex_protocol::protocol::McpStartupStatus;
use codex_protocol::protocol::McpStartupUpdateEvent;
use codex_protocol::protocol::SandboxPolicy;
use codex_rmcp_client::ElicitationResponse;
use codex_rmcp_client::ExecutorStdioServerLauncher;
use codex_rmcp_client::LocalStdioServerLauncher;
use codex_rmcp_client::RmcpClient;
use codex_rmcp_client::SendElicitation;
@@ -493,6 +495,7 @@ impl AsyncManagedClient {
elicitation_requests: ElicitationRequestManager,
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
tool_plugin_provenance: Arc<ToolPluginProvenance>,
runtime_environment: McpRuntimeEnvironment,
) -> Self {
let tool_filter = ToolFilter::from_config(&config);
let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot(
@@ -509,8 +512,15 @@ impl AsyncManagedClient {
return Err(error.into());
}
let client =
Arc::new(make_rmcp_client(&server_name, config.transport, store_mode).await?);
let client = Arc::new(
make_rmcp_client(
&server_name,
config.clone(),
store_mode,
runtime_environment,
)
.await?,
);
match start_server_task(
server_name,
client,
@@ -650,6 +660,37 @@ pub struct McpConnectionManager {
elicitation_requests: ElicitationRequestManager,
}
/// Runtime placement information used when starting MCP server transports.
///
/// `McpConfig` describes what servers exist. This value describes where those
/// servers should run for the current caller. Keep it explicit at manager
/// construction time so status/snapshot paths and real sessions make the same
/// local-vs-remote decision. `fallback_cwd` is not a per-server override; it is
/// used only when an executor-backed stdio server omits `cwd` and the executor
/// API still needs a concrete process working directory.
#[derive(Clone)]
pub struct McpRuntimeEnvironment {
environment: Arc<Environment>,
fallback_cwd: PathBuf,
}
impl McpRuntimeEnvironment {
pub fn new(environment: Arc<Environment>, fallback_cwd: PathBuf) -> Self {
Self {
environment,
fallback_cwd,
}
}
fn environment(&self) -> Arc<Environment> {
Arc::clone(&self.environment)
}
fn fallback_cwd(&self) -> PathBuf {
self.fallback_cwd.clone()
}
}
impl McpConnectionManager {
pub fn configured_servers(&self, config: &McpConfig) -> HashMap<String, McpServerConfig> {
configured_mcp_servers(config)
@@ -710,6 +751,7 @@ impl McpConnectionManager {
submit_id: String,
tx_event: Sender<Event>,
initial_sandbox_policy: SandboxPolicy,
runtime_environment: McpRuntimeEnvironment,
codex_home: PathBuf,
codex_apps_tools_cache_key: CodexAppsToolsCacheKey,
tool_plugin_provenance: ToolPluginProvenance,
@@ -754,6 +796,7 @@ impl McpConnectionManager {
elicitation_requests.clone(),
codex_apps_tools_cache_context,
Arc::clone(&tool_plugin_provenance),
runtime_environment.clone(),
);
clients.insert(server_name.clone(), async_managed_client.clone());
let tx_event = tx_event.clone();
@@ -1484,9 +1527,25 @@ struct StartServerTaskParams {
async fn make_rmcp_client(
server_name: &str,
transport: McpServerTransportConfig,
config: McpServerConfig,
store_mode: OAuthCredentialsStoreMode,
runtime_environment: McpRuntimeEnvironment,
) -> Result<RmcpClient, StartupOutcomeError> {
let McpServerConfig {
transport,
experimental_environment,
..
} = config;
let remote_environment = match experimental_environment.as_deref() {
None | Some("local") => false,
Some("remote") => true,
Some(environment) => {
return Err(StartupOutcomeError::from(anyhow!(
"unsupported experimental_environment `{environment}` for MCP server `{server_name}`"
)));
}
};
match transport {
McpServerTransportConfig::Stdio {
command,
@@ -1502,7 +1561,24 @@ async fn make_rmcp_client(
.map(|(key, value)| (key.into(), value.into()))
.collect::<HashMap<_, _>>()
});
let launcher = Arc::new(LocalStdioServerLauncher) as Arc<dyn StdioServerLauncher>;
let launcher = if remote_environment {
let exec_environment = runtime_environment.environment();
if !exec_environment.is_remote() {
return Err(StartupOutcomeError::from(anyhow!(
"remote MCP server `{server_name}` requires a remote executor environment"
)));
}
Arc::new(ExecutorStdioServerLauncher::new(
exec_environment.get_exec_backend(),
runtime_environment.fallback_cwd(),
))
} else {
Arc::new(LocalStdioServerLauncher) as Arc<dyn StdioServerLauncher>
};
// `RmcpClient` always sees a launched MCP stdio server. The
// launcher hides whether that means a local child process or an
// executor process whose stdin/stdout bytes cross the process API.
RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd, launcher)
.await
.map_err(|err| StartupOutcomeError::from(anyhow!(err)))
@@ -1513,6 +1589,23 @@ async fn make_rmcp_client(
env_http_headers,
bearer_token_env_var,
} => {
if remote_environment {
if !runtime_environment.environment().is_remote() {
return Err(StartupOutcomeError::from(anyhow!(
"remote MCP server `{server_name}` requires a remote executor environment"
)));
}
return Err(StartupOutcomeError::from(anyhow!(
// Remote HTTP needs the future low-level executor
// `network/request` API so reqwest runs on the executor side.
// Do not fall back to local HTTP here; the config explicitly
// asked for remote placement.
"remote streamable HTTP MCP server `{server_name}` is not implemented yet"
)));
}
// Local streamable HTTP remains the existing reqwest path from
// the orchestrator process.
let resolved_bearer_token =
match resolve_bearer_token(server_name, bearer_token_env_var.as_deref()) {
Ok(token) => token,