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
@@ -1428,6 +1428,7 @@ impl Config {
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
use_legacy_landlock: self.features.use_legacy_landlock(),
apps_enabled: self.features.enabled(Feature::Apps),
legacy_apps_mcp_loader_enabled: true,
prefix_mcp_tool_names: self.prefix_mcp_tool_names(),
client_elicitation_capability: if self.features.enabled(Feature::AuthElicitation) {
ElicitationCapability {
+21 -6
View File
@@ -1,4 +1,3 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::LazyLock;
@@ -41,8 +40,8 @@ use codex_mcp::ToolInfo;
use codex_mcp::ToolPluginProvenance;
use codex_mcp::codex_apps_tools_cache_key;
use codex_mcp::compute_auth_statuses;
use codex_mcp::effective_mcp_servers;
use codex_mcp::host_owned_codex_apps_enabled;
use codex_mcp::with_codex_apps_mcp;
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
@@ -220,6 +219,23 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
config: &Config,
force_refetch: bool,
environment_manager: Arc<EnvironmentManager>,
) -> anyhow::Result<AccessibleConnectorsStatus> {
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf()));
let mcp_manager = Arc::new(McpManager::new(plugins_manager));
list_accessible_connectors_from_mcp_tools_with_mcp_manager(
config,
force_refetch,
environment_manager,
mcp_manager,
)
.await
}
pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
config: &Config,
force_refetch: bool,
environment_manager: Arc<EnvironmentManager>,
mcp_manager: Arc<McpManager>,
) -> anyhow::Result<AccessibleConnectorsStatus> {
let auth_manager =
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
@@ -234,8 +250,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
});
}
let cache_key = accessible_connectors_cache_key(config, auth.as_ref());
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf()));
let mcp_manager = McpManager::new(Arc::clone(&plugins_manager));
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config).await;
if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key)
{
@@ -250,8 +264,9 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
});
}
let mcp_config = config.to_mcp_config(plugins_manager.as_ref()).await;
let mcp_servers = with_codex_apps_mcp(HashMap::new(), auth.as_ref(), &mcp_config);
let mcp_config = mcp_manager.runtime_config(config).await;
let mut mcp_servers = effective_mcp_servers(&mcp_config, auth.as_ref());
mcp_servers.retain(|name, _| name == CODEX_APPS_MCP_SERVER_NAME);
let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(&mcp_config, auth.as_ref());
if mcp_servers.is_empty() {
return Ok(AccessibleConnectorsStatus {
+68 -2
View File
@@ -4,8 +4,12 @@ use std::sync::Arc;
use crate::config::Config;
use codex_config::McpServerConfig;
use codex_core_plugins::PluginsManager;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::McpServerContribution;
use codex_login::CodexAuth;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::EffectiveMcpServer;
use codex_mcp::McpConfig;
use codex_mcp::ToolPluginProvenance;
use codex_mcp::configured_mcp_servers;
use codex_mcp::effective_mcp_servers;
@@ -14,29 +18,91 @@ use codex_mcp::tool_plugin_provenance as collect_tool_plugin_provenance;
#[derive(Clone)]
pub struct McpManager {
plugins_manager: Arc<PluginsManager>,
extensions: Arc<ExtensionRegistry<Config>>,
}
impl McpManager {
pub fn new(plugins_manager: Arc<PluginsManager>) -> Self {
Self { plugins_manager }
Self {
plugins_manager,
extensions: codex_extension_api::empty_extension_registry(),
}
}
/// Creates a manager that resolves host-installed MCP contributions.
pub fn new_with_extensions(
plugins_manager: Arc<PluginsManager>,
extensions: Arc<ExtensionRegistry<Config>>,
) -> Self {
Self {
plugins_manager,
extensions,
}
}
/// Returns the MCP config after applying runtime-only extension overlays.
pub async fn runtime_config(&self, config: &Config) -> McpConfig {
let mut mcp_config = config.to_mcp_config(self.plugins_manager.as_ref()).await;
let contributions = self.contributions(config).await;
if contributions
.iter()
.any(|contribution| contribution.name() == CODEX_APPS_MCP_SERVER_NAME)
{
mcp_config.legacy_apps_mcp_loader_enabled = false;
}
Self::apply_to_configured_servers(&contributions, &mut mcp_config.configured_mcp_servers);
mcp_config
}
/// Returns config- and plugin-backed servers without runtime contributions.
pub async fn configured_servers(&self, config: &Config) -> HashMap<String, McpServerConfig> {
let mcp_config = config.to_mcp_config(self.plugins_manager.as_ref()).await;
configured_mcp_servers(&mcp_config)
}
/// Returns configured and host-contributed servers before auth gating.
pub async fn runtime_servers(&self, config: &Config) -> HashMap<String, McpServerConfig> {
let mcp_config = self.runtime_config(config).await;
configured_mcp_servers(&mcp_config)
}
/// Returns runtime servers after auth gating and compatibility built-ins.
pub async fn effective_servers(
&self,
config: &Config,
auth: Option<&CodexAuth>,
) -> HashMap<String, EffectiveMcpServer> {
let mcp_config = config.to_mcp_config(self.plugins_manager.as_ref()).await;
let mcp_config = self.runtime_config(config).await;
effective_mcp_servers(&mcp_config, auth)
}
/// Returns provenance for plugin-owned servers in the configured view.
pub async fn tool_plugin_provenance(&self, config: &Config) -> ToolPluginProvenance {
let mcp_config = config.to_mcp_config(self.plugins_manager.as_ref()).await;
collect_tool_plugin_provenance(&mcp_config)
}
async fn contributions(&self, config: &Config) -> Vec<McpServerContribution> {
let mut contributions = Vec::new();
for contributor in self.extensions.mcp_server_contributors() {
contributions.extend(contributor.contribute(config).await);
}
contributions
}
fn apply_to_configured_servers(
contributions: &[McpServerContribution],
servers: &mut HashMap<String, McpServerConfig>,
) {
for contribution in contributions {
match contribution {
McpServerContribution::Set { name, config } => {
servers.insert(name.clone(), config.as_ref().clone());
}
McpServerContribution::Remove { name } => {
servers.remove(name);
}
}
}
}
}
+14 -8
View File
@@ -56,7 +56,7 @@ pub(crate) async fn maybe_prompt_and_install_mcp_dependencies(
let installed = sess
.services
.mcp_manager
.configured_servers(config.as_ref())
.runtime_servers(config.as_ref())
.await;
let missing = collect_missing_mcp_dependencies(mentioned_skills, &installed);
if missing.is_empty() {
@@ -98,7 +98,7 @@ pub(crate) async fn maybe_install_mcp_dependencies(
}
let codex_home = config.codex_home.clone();
let installed = sess.services.mcp_manager.configured_servers(config).await;
let installed = sess.services.mcp_manager.runtime_servers(config).await;
let missing = collect_missing_mcp_dependencies(mentioned_skills, &installed);
if missing.is_empty() {
return;
@@ -190,16 +190,22 @@ pub(crate) async fn maybe_install_mcp_dependencies(
}
}
// Refresh from the config-backed merged MCP map (global + repo + managed)
// and overlay the updated global servers so we don't drop repo-scoped
// servers. Runtime additions such as built-ins are rebuilt by the refresh
// path from the current config.
let mut refresh_servers = sess.services.mcp_manager.configured_servers(config).await;
let mut refresh_config = config.clone();
let mut configured_servers = config.mcp_servers.get().clone();
for (name, server_config) in &servers {
refresh_servers
configured_servers
.entry(name.clone())
.or_insert_with(|| server_config.clone());
}
if let Err(err) = refresh_config.mcp_servers.set(configured_servers) {
warn!("failed to refresh MCP dependencies for mentioned skills: {err}");
return;
}
let refresh_servers = sess
.services
.mcp_manager
.runtime_servers(&refresh_config)
.await;
sess.refresh_mcp_servers_now(
turn_context,
refresh_servers,
+4 -2
View File
@@ -311,8 +311,10 @@ impl Session {
) {
let auth = self.services.auth_manager.auth().await;
let config = self.get_config().await;
let mcp_config = config
.to_mcp_config(self.services.plugins_manager.as_ref())
let mcp_config = self
.services
.mcp_manager
.runtime_config(config.as_ref())
.await;
let tool_plugin_provenance = self
.services
+4 -1
View File
@@ -272,7 +272,10 @@ impl ThreadManager {
codex_home.to_path_buf(),
restriction_product,
));
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let mcp_manager = Arc::new(McpManager::new_with_extensions(
Arc::clone(&plugins_manager),
Arc::clone(&extensions),
));
let skills_manager = Arc::new(SkillsManager::new_with_restriction_product(
codex_home,
config.bundled_skills_enabled(),