Skip to content

Commit 996aa23

Browse files
aibrahim-oaicodex
andauthored
[5/6] Wire executor-backed MCP stdio (openai#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 openai#18027 [6/6] Fail exec client operations after disconnect │ @ openai#18212 [5/6] Wire executor-backed MCP stdio │ o openai#18087 [4/6] Abstract MCP stdio server launching │ o openai#18020 [3/6] Add pushed exec process events │ o openai#18086 [2/6] Support piped stdin in exec process API │ o openai#18085 [1/6] Add MCP server environment config │ o main ``` --------- Co-authored-by: Codex <noreply@openai.com>
1 parent e3f44ca commit 996aa23

31 files changed

Lines changed: 1815 additions & 76 deletions

codex-rs/Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/app-server/src/codex_message_processor.rs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ use codex_login::default_client::set_default_client_residency_requirement;
279279
use codex_login::login_with_api_key;
280280
use codex_login::request_device_code;
281281
use codex_login::run_login_server;
282+
use codex_mcp::McpRuntimeEnvironment;
282283
use codex_mcp::McpServerStatusSnapshot;
283284
use codex_mcp::McpSnapshotDetail;
284285
use codex_mcp::collect_mcp_server_status_snapshot_with_detail;
@@ -5724,10 +5725,40 @@ impl CodexMessageProcessor {
57245725
.to_mcp_config(self.thread_manager.plugins_manager().as_ref())
57255726
.await;
57265727
let auth = self.auth_manager.auth().await;
5728+
let runtime_environment = match self.thread_manager.environment_manager().current().await {
5729+
Ok(Some(environment)) => {
5730+
// Status listing has no turn cwd. This fallback is used only
5731+
// by executor-backed stdio MCPs whose config omits `cwd`.
5732+
McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf())
5733+
}
5734+
Ok(None) => McpRuntimeEnvironment::new(
5735+
Arc::new(codex_exec_server::Environment::default()),
5736+
config.cwd.to_path_buf(),
5737+
),
5738+
Err(err) => {
5739+
// TODO(aibrahim): Investigate degrading MCP status listing when
5740+
// executor environment creation fails.
5741+
let error = JSONRPCErrorError {
5742+
code: INTERNAL_ERROR_CODE,
5743+
message: format!("failed to create environment: {err}"),
5744+
data: None,
5745+
};
5746+
self.outgoing.send_error(request, error).await;
5747+
return;
5748+
}
5749+
};
57275750

57285751
tokio::spawn(async move {
5729-
Self::list_mcp_server_status_task(outgoing, request, params, config, mcp_config, auth)
5730-
.await;
5752+
Self::list_mcp_server_status_task(
5753+
outgoing,
5754+
request,
5755+
params,
5756+
config,
5757+
mcp_config,
5758+
auth,
5759+
runtime_environment,
5760+
)
5761+
.await;
57315762
});
57325763
}
57335764

@@ -5738,6 +5769,7 @@ impl CodexMessageProcessor {
57385769
config: Config,
57395770
mcp_config: codex_mcp::McpConfig,
57405771
auth: Option<CodexAuth>,
5772+
runtime_environment: McpRuntimeEnvironment,
57415773
) {
57425774
let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) {
57435775
McpServerStatusDetail::Full => McpSnapshotDetail::Full,
@@ -5748,6 +5780,7 @@ impl CodexMessageProcessor {
57485780
&mcp_config,
57495781
auth.as_ref(),
57505782
request_id.request_id.to_string(),
5783+
runtime_environment,
57515784
detail,
57525785
)
57535786
.await;

codex-rs/cli/tests/mcp_list.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ async fn list_and_get_render_expected_output() -> Result<()> {
5555
.expect("docs server should exist after add");
5656
match &mut docs_entry.transport {
5757
McpServerTransportConfig::Stdio { env_vars, .. } => {
58-
*env_vars = vec!["APP_TOKEN".to_string(), "WORKSPACE_ID".to_string()];
58+
*env_vars = vec!["APP_TOKEN".into(), "WORKSPACE_ID".into()];
5959
}
6060
other => panic!("unexpected transport: {other:?}"),
6161
}

codex-rs/codex-mcp/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ anyhow = { workspace = true }
1616
async-channel = { workspace = true }
1717
codex-async-utils = { workspace = true }
1818
codex-config = { workspace = true }
19+
codex-exec-server = { workspace = true }
1920
codex-login = { workspace = true }
2021
codex-otel = { workspace = true }
2122
codex-plugin = { workspace = true }

codex-rs/codex-mcp/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pub use mcp_connection_manager::CodexAppsToolsCacheKey;
3838
pub use mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT;
3939
pub use mcp_connection_manager::MCP_SANDBOX_STATE_META_CAPABILITY;
4040
pub use mcp_connection_manager::McpConnectionManager;
41+
pub use mcp_connection_manager::McpRuntimeEnvironment;
4142
pub use mcp_connection_manager::SandboxState;
4243
pub use mcp_connection_manager::ToolInfo;
4344
pub use mcp_connection_manager::codex_apps_tools_cache_key;

codex-rs/codex-mcp/src/mcp/mod.rs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ use codex_protocol::protocol::SandboxPolicy;
3535
use serde_json::Value;
3636

3737
use crate::mcp_connection_manager::McpConnectionManager;
38+
use crate::mcp_connection_manager::McpRuntimeEnvironment;
3839
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
3940
pub type McpManager = McpConnectionManager;
4041

@@ -321,14 +322,23 @@ pub async fn collect_mcp_snapshot(
321322
config: &McpConfig,
322323
auth: Option<&CodexAuth>,
323324
submit_id: String,
325+
runtime_environment: McpRuntimeEnvironment,
324326
) -> McpListToolsResponseEvent {
325-
collect_mcp_snapshot_with_detail(config, auth, submit_id, McpSnapshotDetail::Full).await
327+
collect_mcp_snapshot_with_detail(
328+
config,
329+
auth,
330+
submit_id,
331+
runtime_environment,
332+
McpSnapshotDetail::Full,
333+
)
334+
.await
326335
}
327336

328337
pub async fn collect_mcp_snapshot_with_detail(
329338
config: &McpConfig,
330339
auth: Option<&CodexAuth>,
331340
submit_id: String,
341+
runtime_environment: McpRuntimeEnvironment,
332342
detail: McpSnapshotDetail,
333343
) -> McpListToolsResponseEvent {
334344
let mcp_servers = effective_mcp_servers(config, auth);
@@ -356,6 +366,7 @@ pub async fn collect_mcp_snapshot_with_detail(
356366
submit_id,
357367
tx_event,
358368
SandboxPolicy::new_read_only_policy(),
369+
runtime_environment,
359370
config.codex_home.clone(),
360371
codex_apps_tools_cache_key(auth),
361372
tool_plugin_provenance,
@@ -386,15 +397,23 @@ pub async fn collect_mcp_server_status_snapshot(
386397
config: &McpConfig,
387398
auth: Option<&CodexAuth>,
388399
submit_id: String,
400+
runtime_environment: McpRuntimeEnvironment,
389401
) -> McpServerStatusSnapshot {
390-
collect_mcp_server_status_snapshot_with_detail(config, auth, submit_id, McpSnapshotDetail::Full)
391-
.await
402+
collect_mcp_server_status_snapshot_with_detail(
403+
config,
404+
auth,
405+
submit_id,
406+
runtime_environment,
407+
McpSnapshotDetail::Full,
408+
)
409+
.await
392410
}
393411

394412
pub async fn collect_mcp_server_status_snapshot_with_detail(
395413
config: &McpConfig,
396414
auth: Option<&CodexAuth>,
397415
submit_id: String,
416+
runtime_environment: McpRuntimeEnvironment,
398417
detail: McpSnapshotDetail,
399418
) -> McpServerStatusSnapshot {
400419
let mcp_servers = effective_mcp_servers(config, auth);
@@ -422,6 +441,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
422441
submit_id,
423442
tx_event,
424443
SandboxPolicy::new_read_only_policy(),
444+
runtime_environment,
425445
config.codex_home.clone(),
426446
codex_apps_tools_cache_key(auth),
427447
tool_plugin_provenance,

codex-rs/codex-mcp/src/mcp_connection_manager.rs

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ use codex_async_utils::CancelErr;
3636
use codex_async_utils::OrCancelExt;
3737
use codex_config::Constrained;
3838
use codex_config::types::OAuthCredentialsStoreMode;
39+
use codex_exec_server::Environment;
3940
use codex_protocol::ToolName;
4041
use codex_protocol::approvals::ElicitationRequest;
4142
use codex_protocol::approvals::ElicitationRequestEvent;
@@ -50,6 +51,7 @@ use codex_protocol::protocol::McpStartupStatus;
5051
use codex_protocol::protocol::McpStartupUpdateEvent;
5152
use codex_protocol::protocol::SandboxPolicy;
5253
use codex_rmcp_client::ElicitationResponse;
54+
use codex_rmcp_client::ExecutorStdioServerLauncher;
5355
use codex_rmcp_client::LocalStdioServerLauncher;
5456
use codex_rmcp_client::RmcpClient;
5557
use codex_rmcp_client::SendElicitation;
@@ -493,6 +495,7 @@ impl AsyncManagedClient {
493495
elicitation_requests: ElicitationRequestManager,
494496
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
495497
tool_plugin_provenance: Arc<ToolPluginProvenance>,
498+
runtime_environment: McpRuntimeEnvironment,
496499
) -> Self {
497500
let tool_filter = ToolFilter::from_config(&config);
498501
let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot(
@@ -509,8 +512,15 @@ impl AsyncManagedClient {
509512
return Err(error.into());
510513
}
511514

512-
let client =
513-
Arc::new(make_rmcp_client(&server_name, config.transport, store_mode).await?);
515+
let client = Arc::new(
516+
make_rmcp_client(
517+
&server_name,
518+
config.clone(),
519+
store_mode,
520+
runtime_environment,
521+
)
522+
.await?,
523+
);
514524
match start_server_task(
515525
server_name,
516526
client,
@@ -650,6 +660,37 @@ pub struct McpConnectionManager {
650660
elicitation_requests: ElicitationRequestManager,
651661
}
652662

663+
/// Runtime placement information used when starting MCP server transports.
664+
///
665+
/// `McpConfig` describes what servers exist. This value describes where those
666+
/// servers should run for the current caller. Keep it explicit at manager
667+
/// construction time so status/snapshot paths and real sessions make the same
668+
/// local-vs-remote decision. `fallback_cwd` is not a per-server override; it is
669+
/// used only when an executor-backed stdio server omits `cwd` and the executor
670+
/// API still needs a concrete process working directory.
671+
#[derive(Clone)]
672+
pub struct McpRuntimeEnvironment {
673+
environment: Arc<Environment>,
674+
fallback_cwd: PathBuf,
675+
}
676+
677+
impl McpRuntimeEnvironment {
678+
pub fn new(environment: Arc<Environment>, fallback_cwd: PathBuf) -> Self {
679+
Self {
680+
environment,
681+
fallback_cwd,
682+
}
683+
}
684+
685+
fn environment(&self) -> Arc<Environment> {
686+
Arc::clone(&self.environment)
687+
}
688+
689+
fn fallback_cwd(&self) -> PathBuf {
690+
self.fallback_cwd.clone()
691+
}
692+
}
693+
653694
impl McpConnectionManager {
654695
pub fn configured_servers(&self, config: &McpConfig) -> HashMap<String, McpServerConfig> {
655696
configured_mcp_servers(config)
@@ -710,6 +751,7 @@ impl McpConnectionManager {
710751
submit_id: String,
711752
tx_event: Sender<Event>,
712753
initial_sandbox_policy: SandboxPolicy,
754+
runtime_environment: McpRuntimeEnvironment,
713755
codex_home: PathBuf,
714756
codex_apps_tools_cache_key: CodexAppsToolsCacheKey,
715757
tool_plugin_provenance: ToolPluginProvenance,
@@ -754,6 +796,7 @@ impl McpConnectionManager {
754796
elicitation_requests.clone(),
755797
codex_apps_tools_cache_context,
756798
Arc::clone(&tool_plugin_provenance),
799+
runtime_environment.clone(),
757800
);
758801
clients.insert(server_name.clone(), async_managed_client.clone());
759802
let tx_event = tx_event.clone();
@@ -1484,9 +1527,25 @@ struct StartServerTaskParams {
14841527

14851528
async fn make_rmcp_client(
14861529
server_name: &str,
1487-
transport: McpServerTransportConfig,
1530+
config: McpServerConfig,
14881531
store_mode: OAuthCredentialsStoreMode,
1532+
runtime_environment: McpRuntimeEnvironment,
14891533
) -> Result<RmcpClient, StartupOutcomeError> {
1534+
let McpServerConfig {
1535+
transport,
1536+
experimental_environment,
1537+
..
1538+
} = config;
1539+
let remote_environment = match experimental_environment.as_deref() {
1540+
None | Some("local") => false,
1541+
Some("remote") => true,
1542+
Some(environment) => {
1543+
return Err(StartupOutcomeError::from(anyhow!(
1544+
"unsupported experimental_environment `{environment}` for MCP server `{server_name}`"
1545+
)));
1546+
}
1547+
};
1548+
14901549
match transport {
14911550
McpServerTransportConfig::Stdio {
14921551
command,
@@ -1502,7 +1561,24 @@ async fn make_rmcp_client(
15021561
.map(|(key, value)| (key.into(), value.into()))
15031562
.collect::<HashMap<_, _>>()
15041563
});
1505-
let launcher = Arc::new(LocalStdioServerLauncher) as Arc<dyn StdioServerLauncher>;
1564+
let launcher = if remote_environment {
1565+
let exec_environment = runtime_environment.environment();
1566+
if !exec_environment.is_remote() {
1567+
return Err(StartupOutcomeError::from(anyhow!(
1568+
"remote MCP server `{server_name}` requires a remote executor environment"
1569+
)));
1570+
}
1571+
Arc::new(ExecutorStdioServerLauncher::new(
1572+
exec_environment.get_exec_backend(),
1573+
runtime_environment.fallback_cwd(),
1574+
))
1575+
} else {
1576+
Arc::new(LocalStdioServerLauncher) as Arc<dyn StdioServerLauncher>
1577+
};
1578+
1579+
// `RmcpClient` always sees a launched MCP stdio server. The
1580+
// launcher hides whether that means a local child process or an
1581+
// executor process whose stdin/stdout bytes cross the process API.
15061582
RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd, launcher)
15071583
.await
15081584
.map_err(|err| StartupOutcomeError::from(anyhow!(err)))
@@ -1513,6 +1589,23 @@ async fn make_rmcp_client(
15131589
env_http_headers,
15141590
bearer_token_env_var,
15151591
} => {
1592+
if remote_environment {
1593+
if !runtime_environment.environment().is_remote() {
1594+
return Err(StartupOutcomeError::from(anyhow!(
1595+
"remote MCP server `{server_name}` requires a remote executor environment"
1596+
)));
1597+
}
1598+
return Err(StartupOutcomeError::from(anyhow!(
1599+
// Remote HTTP needs the future low-level executor
1600+
// `network/request` API so reqwest runs on the executor side.
1601+
// Do not fall back to local HTTP here; the config explicitly
1602+
// asked for remote placement.
1603+
"remote streamable HTTP MCP server `{server_name}` is not implemented yet"
1604+
)));
1605+
}
1606+
1607+
// Local streamable HTTP remains the existing reqwest path from
1608+
// the orchestrator process.
15161609
let resolved_bearer_token =
15171610
match resolve_bearer_token(server_name, bearer_token_env_var.as_deref()) {
15181611
Ok(token) => token,

codex-rs/config/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ pub use mcp_edit::load_global_mcp_servers;
7272
pub use mcp_types::AppToolApproval;
7373
pub use mcp_types::McpServerConfig;
7474
pub use mcp_types::McpServerDisabledReason;
75+
pub use mcp_types::McpServerEnvVar;
7576
pub use mcp_types::McpServerToolConfig;
7677
pub use mcp_types::McpServerTransportConfig;
7778
pub use mcp_types::RawMcpServerConfig;

0 commit comments

Comments
 (0)