Route hosted Apps MCP through extensions (#27191)

## Stack

- Base: #27184
- This PR is the second vertical and should be reviewed against
`jif/external-plugins-1`, not `main`.

## Why

CCA is moving toward a split runtime where the orchestrator may have no
filesystem or executor, but it still needs to activate remotely hosted
plugin components. HTTP MCP servers are the simplest complete example:
they need configuration and host authentication, but they do not need an
executor process.

The Apps MCP endpoint is currently synthesized by a special-purpose
loader inside the MCP runtime. That works locally, but it leaves hosted
MCP activation outside the extension model being established in #27184.
It also makes the Apps path a poor foundation for plugins whose skills,
MCP servers, connectors, and hooks may come from different sources or
execute in different places.

This PR moves that one behavior behind an extension-owned contribution
while preserving the existing local fallback. It deliberately does not
introduce a generic plugin activation framework.

## What changed

### MCP extension contribution

`codex-extension-api` gains an ordered `McpServerContributor` contract.
A contributor returns typed `Set` or `Remove` overlays for MCP server
configuration; later contributors win for the names they own.

The contract stays at the existing MCP configuration boundary.
Extensions do not create a second connection manager or transport
abstraction.

### Hosted Apps MCP extension

A new `codex-mcp-extension` contributes the reserved `codex_apps` server
from the existing Apps feature, ChatGPT base URL, path override, and
product SKU configuration.

When `apps_mcp_path_override` is enabled for `https://chatgpt.com`, the
resulting streamable HTTP endpoint is
`https://chatgpt.com/backend-api/ps/mcp`. The existing ChatGPT-auth gate
remains authoritative, so this server can run in an orchestrator-only
process without being exposed for API-key sessions.

### One resolved runtime view

`McpManager` now distinguishes three views:

- **configured:** config- and plugin-backed servers before extension
overlays;
- **runtime:** configured servers plus host-installed extension
contributions;
- **effective:** runtime servers after auth gating and compatibility
built-ins.

App-server installs the hosted MCP extension and uses the runtime view
for thread startup, refresh, status, threadless resource reads,
connector discovery, and MCP OAuth lookup. This keeps
`mcpServer/oauth/login` consistent with the servers exposed by the other
MCP APIs. The hosted Apps server itself continues to use existing
ChatGPT host authentication rather than MCP OAuth.

## Compatibility

Hosts that do not install the MCP extension retain the existing Apps MCP
synthesis path. This preserves current local-only, CLI, and
standalone-host behavior while app-server exercises the extension path.

Disabling Apps removes the reserved `codex_apps` entry, and losing
ChatGPT auth removes it from the effective runtime view. Executor
availability is not consulted for this HTTP transport.

## Follow-ups

The next vertical will resolve a manifest-declared stdio MCP server from
an executor-selected plugin root and execute it in the environment that
owns that root. Later verticals can add backend-owned skills, connector
metadata, hooks, durable selection semantics, and incremental local
convergence without changing the component-specific runtime boundaries
introduced here.

## Verification

Focused coverage was added for:

- contributing the hosted Apps MCP at `/backend-api/ps/mcp` without an
executor;
- requiring ChatGPT auth in the effective runtime view;
- removing a reserved configured Apps server when the Apps feature is
disabled.

`cargo check -p codex-app-server -p codex-mcp-extension -p
codex-extension-api -p codex-mcp` passed. Tests and Clippy were not run
locally under the current development instruction; CI provides the full
validation pass.
This commit is contained in:
jif
2026-06-09 22:44:16 +02:00
committed by GitHub
Unverified
parent 5a0f913426
commit 4ec3b8eeea
28 changed files with 424 additions and 65 deletions
+1
View File
@@ -50,6 +50,7 @@ where
}
codex_guardian::install(&mut builder, guardian_agent_spawner);
codex_memories_extension::install(&mut builder, codex_otel::global());
codex_mcp_extension::install(&mut builder);
codex_web_search_extension::install(&mut builder, auth_manager.clone());
codex_image_generation_extension::install(&mut builder, auth_manager);
codex_skills_extension::install_with_providers(
+1 -4
View File
@@ -67,10 +67,7 @@ async fn build_refresh_config(
let config = config_manager
.load_latest_config_for_thread(thread_config.as_ref())
.await?;
let mcp_servers = thread_manager
.mcp_manager()
.configured_servers(&config)
.await;
let mcp_servers = thread_manager.mcp_manager().runtime_servers(&config).await;
Ok(McpServerRefreshConfig {
mcp_servers: serde_json::to_value(mcp_servers).map_err(io::Error::other)?,
mcp_oauth_credentials_store_mode: serde_json::to_value(
@@ -280,6 +280,7 @@ use codex_config::types::McpServerTransportConfig;
use codex_core::CodexThread;
use codex_core::CodexThreadSettingsOverrides;
use codex_core::ForkSnapshot;
use codex_core::McpManager;
use codex_core::NewThread;
#[cfg(test)]
use codex_core::SessionMeta;
@@ -88,11 +88,19 @@ impl AppsRequestProcessor {
let request = request_id.clone();
let outgoing = Arc::clone(&self.outgoing);
let environment_manager = self.thread_manager.environment_manager();
let mcp_manager = self.thread_manager.mcp_manager();
let shutdown_token = self.shutdown_token.child_token();
tokio::spawn(async move {
tokio::select! {
_ = shutdown_token.cancelled() => {}
_ = Self::apps_list_task(outgoing, request, params, config, environment_manager) => {}
_ = Self::apps_list_task(
outgoing,
request,
params,
config,
environment_manager,
mcp_manager,
) => {}
}
});
Ok(None)
@@ -108,11 +116,15 @@ impl AppsRequestProcessor {
params: AppsListParams,
config: Config,
environment_manager: Arc<EnvironmentManager>,
mcp_manager: Arc<McpManager>,
) {
let retry_params = params.clone();
let retry_config = config.clone();
let retry_environment_manager = Arc::clone(&environment_manager);
let result = Self::apps_list_response(&outgoing, params, config, environment_manager).await;
let retry_mcp_manager = Arc::clone(&mcp_manager);
let result =
Self::apps_list_response(&outgoing, params, config, environment_manager, mcp_manager)
.await;
let should_retry = result
.as_ref()
.is_ok_and(|(_, codex_apps_ready)| !codex_apps_ready);
@@ -128,6 +140,7 @@ impl AppsRequestProcessor {
retry_params,
retry_config,
retry_environment_manager,
retry_mcp_manager,
)
.await
{
@@ -141,6 +154,7 @@ impl AppsRequestProcessor {
params: AppsListParams,
config: Config,
environment_manager: Arc<EnvironmentManager>,
mcp_manager: Arc<McpManager>,
) -> Result<(AppsListResponse, bool), JSONRPCErrorError> {
let AppsListParams {
cursor,
@@ -167,14 +181,14 @@ impl AppsRequestProcessor {
let accessible_config = config.clone();
let accessible_tx = tx.clone();
tokio::spawn(async move {
let result =
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
&accessible_config,
force_refetch,
Arc::clone(&environment_manager),
)
.await
.map_err(|err| format!("failed to load accessible apps: {err}"));
let result = connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager(
&accessible_config,
force_refetch,
Arc::clone(&environment_manager),
mcp_manager,
)
.await
.map_err(|err| format!("failed to load accessible apps: {err}"));
let _ = accessible_tx.send(AppListLoadResult::Accessible(result));
});
@@ -120,12 +120,16 @@ impl McpRequestProcessor {
timeout_secs,
} = params;
let configured_servers = self
let auth = self.auth_manager.auth().await;
let effective_servers = self
.thread_manager
.mcp_manager()
.configured_servers(&config)
.effective_servers(&config, auth.as_ref())
.await;
let Some(server) = configured_servers.get(&name) else {
let Some(server) = effective_servers
.get(&name)
.and_then(codex_mcp::EffectiveMcpServer::configured_config)
else {
return Err(invalid_request(format!(
"No MCP server named '{name}' found."
)));
@@ -210,8 +214,10 @@ impl McpRequestProcessor {
}
None => self.load_latest_config(/*fallback_cwd*/ None).await?,
};
let mcp_config = config
.to_mcp_config(self.thread_manager.plugins_manager().as_ref())
let mcp_config = self
.thread_manager
.mcp_manager()
.runtime_config(&config)
.await;
let auth = self.auth_manager.auth().await;
let environment_manager = self.thread_manager.environment_manager();
@@ -361,8 +367,10 @@ impl McpRequestProcessor {
}
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let mcp_config = config
.to_mcp_config(self.thread_manager.plugins_manager().as_ref())
let mcp_config = self
.thread_manager
.mcp_manager()
.runtime_config(&config)
.await;
let auth = self.auth_manager.auth().await;
let environment_manager = self.thread_manager.environment_manager();
@@ -1036,6 +1036,7 @@ impl PluginRequestProcessor {
&config,
&outcome.plugin.apps,
Arc::clone(&environment_manager),
self.thread_manager.mcp_manager(),
)
.await;
let visible_skills = outcome
@@ -1118,6 +1119,7 @@ impl PluginRequestProcessor {
&config,
&plugin_apps,
Arc::clone(&environment_manager),
self.thread_manager.mcp_manager(),
)
.await;
remote_plugin_detail_to_info(remote_detail, app_summaries)
@@ -1611,10 +1613,11 @@ impl PluginRequestProcessor {
let environment_manager = self.thread_manager.environment_manager();
let (all_connectors_result, accessible_connectors_result) = tokio::join!(
connectors::list_all_connectors_with_options(config, /*force_refetch*/ false),
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager(
config,
/*force_refetch*/ true,
Arc::clone(&environment_manager)
Arc::clone(&environment_manager),
self.thread_manager.mcp_manager(),
),
);
@@ -1881,6 +1884,7 @@ async fn load_plugin_app_summaries(
config: &Config,
plugin_apps: &[codex_plugin::AppConnectorId],
environment_manager: Arc<EnvironmentManager>,
mcp_manager: Arc<McpManager>,
) -> Vec<AppSummary> {
if plugin_apps.is_empty() {
return Vec::new();
@@ -1900,10 +1904,11 @@ async fn load_plugin_app_summaries(
let plugin_connectors = connectors::connectors_for_plugin_apps(connectors, plugin_apps);
let accessible_connectors =
match connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
match connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager(
config,
/*force_refetch*/ false,
environment_manager,
mcp_manager,
)
.await
{