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
+19
View File
@@ -1982,6 +1982,7 @@ dependencies = [
"codex-image-generation-extension",
"codex-login",
"codex-mcp",
"codex-mcp-extension",
"codex-memories-extension",
"codex-memories-write",
"codex-model-provider",
@@ -2920,6 +2921,7 @@ name = "codex-extension-api"
version = "0.0.0"
dependencies = [
"async-trait",
"codex-config",
"codex-context-fragments",
"codex-protocol",
"codex-tools",
@@ -3247,6 +3249,23 @@ dependencies = [
"url",
]
[[package]]
name = "codex-mcp-extension"
version = "0.0.0"
dependencies = [
"async-trait",
"codex-config",
"codex-core",
"codex-core-plugins",
"codex-extension-api",
"codex-features",
"codex-login",
"codex-mcp",
"pretty_assertions",
"tempfile",
"tokio",
]
[[package]]
name = "codex-mcp-server"
version = "0.0.0"
+2
View File
@@ -49,6 +49,7 @@ members = [
"ext/guardian",
"ext/image-generation",
"ext/memories",
"ext/mcp",
"ext/skills",
"ext/web-search",
"external-agent-migration",
@@ -191,6 +192,7 @@ codex-web-search-extension = { path = "ext/web-search" }
codex-memories-read = { path = "memories/read" }
codex-memories-write = { path = "memories/write" }
codex-mcp = { path = "codex-mcp" }
codex-mcp-extension = { path = "ext/mcp" }
codex-mcp-server = { path = "mcp-server" }
codex-model-provider-info = { path = "model-provider-info" }
codex-models-manager = { path = "models-manager" }
+1
View File
@@ -61,6 +61,7 @@ codex-memories-extension = { workspace = true }
codex-web-search-extension = { workspace = true }
codex-memories-write = { workspace = true }
codex-mcp = { workspace = true }
codex-mcp-extension = { workspace = true }
codex-model-provider = { workspace = true }
codex-models-manager = { workspace = true }
codex-protocol = { workspace = true }
+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
{
+1
View File
@@ -14,6 +14,7 @@ use codex_connectors::merge::merge_plugin_connectors;
use codex_core::config::Config;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status;
pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools;
+1
View File
@@ -24,6 +24,7 @@ pub use auth_elicitation::build_auth_elicitation_plan;
pub use auth_elicitation::connector_auth_failure_from_tool_result;
pub use codex_apps::CodexAppsToolsCacheKey;
pub use codex_apps::codex_apps_tools_cache_key;
pub use mcp::codex_apps_mcp_server_config;
pub use mcp::configured_mcp_servers;
pub use mcp::effective_mcp_servers;
pub use mcp::effective_mcp_servers_from_configured;
+24 -12
View File
@@ -131,6 +131,11 @@ pub struct McpConfig {
/// ChatGPT auth is checked separately at runtime before the host-owned apps
/// MCP server is added.
pub apps_enabled: bool,
/// Whether to synthesize the legacy host-owned Apps MCP server.
///
/// Hosts that install an MCP extension for this server disable the legacy
/// loader and contribute the server through the normal runtime overlay.
pub legacy_apps_mcp_loader_enabled: bool,
/// Whether model-visible MCP tool namespaces should keep the legacy
/// `mcp__` prefix.
pub prefix_mcp_tool_names: bool,
@@ -218,10 +223,20 @@ pub fn with_codex_apps_mcp(
auth: Option<&CodexAuth>,
config: &McpConfig,
) -> HashMap<String, EffectiveMcpServer> {
if !config.legacy_apps_mcp_loader_enabled {
if !host_owned_codex_apps_enabled(config, auth) {
servers.remove(CODEX_APPS_MCP_SERVER_NAME);
}
return servers;
}
if host_owned_codex_apps_enabled(config, auth) {
servers.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
EffectiveMcpServer::configured(codex_apps_mcp_server_config(config)),
EffectiveMcpServer::configured(codex_apps_mcp_server_config(
&config.chatgpt_base_url,
config.apps_mcp_path_override.as_deref(),
config.apps_mcp_product_sku.as_deref(),
)),
);
} else {
servers.remove(CODEX_APPS_MCP_SERVER_NAME);
@@ -381,13 +396,6 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
snapshot
}
pub(crate) fn codex_apps_mcp_url(config: &McpConfig) -> String {
codex_apps_mcp_url_for_base_url(
&config.chatgpt_base_url,
config.apps_mcp_path_override.as_deref(),
)
}
/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`.
/// MCP server/tool names are user-controlled, so sanitize the fully-qualified
/// name we expose to the model by replacing any disallowed character with `_`.
@@ -443,10 +451,14 @@ fn codex_apps_mcp_url_for_base_url(base_url: &str, apps_mcp_path_override: Optio
format!("{base_url}/{path}")
}
fn codex_apps_mcp_server_config(config: &McpConfig) -> McpServerConfig {
let url = codex_apps_mcp_url(config);
let http_headers = config.apps_mcp_product_sku.as_ref().map(|product_sku| {
HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.clone())])
pub fn codex_apps_mcp_server_config(
chatgpt_base_url: &str,
apps_mcp_path_override: Option<&str>,
apps_mcp_product_sku: Option<&str>,
) -> McpServerConfig {
let url = codex_apps_mcp_url_for_base_url(chatgpt_base_url, apps_mcp_path_override);
let http_headers = apps_mcp_product_sku.map(|product_sku| {
HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.to_string())])
});
McpServerConfig {
+1 -10
View File
@@ -27,6 +27,7 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
codex_linux_sandbox_exe: None,
use_legacy_landlock: false,
apps_enabled: false,
legacy_apps_mcp_loader_enabled: true,
prefix_mcp_tool_names: true,
client_elicitation_capability: ElicitationCapability::default(),
configured_mcp_servers: HashMap::new(),
@@ -206,16 +207,6 @@ fn codex_apps_mcp_url_for_base_url_keeps_existing_paths() {
);
}
#[test]
fn codex_apps_mcp_url_uses_legacy_codex_apps_path() {
let config = test_mcp_config(PathBuf::from("/tmp"));
assert_eq!(
codex_apps_mcp_url(&config),
"https://chatgpt.com/backend-api/wham/apps"
);
}
#[test]
fn codex_apps_server_config_uses_legacy_codex_apps_path() {
let mut config = test_mcp_config(PathBuf::from("/tmp"));
+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(),
+1
View File
@@ -15,6 +15,7 @@ workspace = true
[dependencies]
async-trait = { workspace = true }
codex-config = { workspace = true }
codex-context-fragments = { workspace = true }
codex-protocol = { workspace = true }
codex-tools = { workspace = true }
@@ -10,12 +10,14 @@ use codex_tools::ToolExecutor;
use crate::ExtensionData;
mod mcp;
mod prompt;
mod thread_lifecycle;
mod tool_lifecycle;
mod turn_input;
mod turn_lifecycle;
pub use mcp::McpServerContribution;
pub use prompt::PromptFragment;
pub use prompt::PromptSlot;
pub use thread_lifecycle::ThreadIdleInput;
@@ -34,6 +36,18 @@ pub use turn_lifecycle::TurnErrorInput;
pub use turn_lifecycle::TurnStartInput;
pub use turn_lifecycle::TurnStopInput;
/// Extension contribution that resolves runtime MCP servers from host config.
///
/// Contributors run in registration order. Later contributions for the same
/// name replace earlier ones. Implementations must contribute only names they
/// own and must apply any source-specific policy before returning a server.
/// Plugin-owned servers and their provenance continue to be resolved by the
/// plugin manager until that ownership moves into an extension explicitly.
#[async_trait::async_trait]
pub trait McpServerContributor<C: Sync>: Send + Sync {
async fn contribute(&self, config: &C) -> Vec<McpServerContribution>;
}
/// Extension contribution that adds prompt fragments during prompt assembly.
pub trait ContextContributor: Send + Sync {
fn contribute<'a>(
@@ -0,0 +1,22 @@
use codex_config::McpServerConfig;
/// One extension-owned overlay for the runtime MCP server configuration.
#[derive(Clone, Debug)]
pub enum McpServerContribution {
/// Adds or replaces a named MCP server.
Set {
name: String,
config: Box<McpServerConfig>,
},
/// Removes a named MCP server.
Remove { name: String },
}
impl McpServerContribution {
/// Returns the stable server name owned by this contribution.
pub fn name(&self) -> &str {
match self {
Self::Set { name, .. } | Self::Remove { name } => name,
}
}
}
+2
View File
@@ -31,6 +31,8 @@ pub use codex_tools::parse_tool_input_schema_without_compaction;
pub use contributors::ApprovalReviewContributor;
pub use contributors::ConfigContributor;
pub use contributors::ContextContributor;
pub use contributors::McpServerContribution;
pub use contributors::McpServerContributor;
pub use contributors::PromptFragment;
pub use contributors::PromptSlot;
pub use contributors::ThreadIdleInput;
@@ -7,6 +7,7 @@ use crate::ConfigContributor;
use crate::ContextContributor;
use crate::ExtensionData;
use crate::ExtensionEventSink;
use crate::McpServerContributor;
use crate::NoopExtensionEventSink;
use crate::ThreadLifecycleContributor;
use crate::TokenUsageContributor;
@@ -24,6 +25,7 @@ pub struct ExtensionRegistryBuilder<C: Sync> {
config_contributors: Vec<Arc<dyn ConfigContributor<C>>>,
token_usage_contributors: Vec<Arc<dyn TokenUsageContributor>>,
context_contributors: Vec<Arc<dyn ContextContributor>>,
mcp_server_contributors: Vec<Arc<dyn McpServerContributor<C>>>,
turn_input_contributors: Vec<Arc<dyn TurnInputContributor>>,
tool_contributors: Vec<Arc<dyn ToolContributor>>,
tool_lifecycle_contributors: Vec<Arc<dyn ToolLifecycleContributor>>,
@@ -41,6 +43,7 @@ impl<C: Sync> Default for ExtensionRegistryBuilder<C> {
token_usage_contributors: Vec::new(),
approval_review_contributors: Vec::new(),
context_contributors: Vec::new(),
mcp_server_contributors: Vec::new(),
turn_input_contributors: Vec::new(),
tool_contributors: Vec::new(),
tool_lifecycle_contributors: Vec::new(),
@@ -101,6 +104,11 @@ impl<C: Sync> ExtensionRegistryBuilder<C> {
self.context_contributors.push(contributor);
}
/// Registers one runtime MCP server contributor.
pub fn mcp_server_contributor(&mut self, contributor: Arc<dyn McpServerContributor<C>>) {
self.mcp_server_contributors.push(contributor);
}
/// Registers one turn-input contributor.
pub fn turn_input_contributor(&mut self, contributor: Arc<dyn TurnInputContributor>) {
self.turn_input_contributors.push(contributor);
@@ -131,6 +139,7 @@ impl<C: Sync> ExtensionRegistryBuilder<C> {
token_usage_contributors: self.token_usage_contributors,
approval_review_contributors: self.approval_review_contributors,
context_contributors: self.context_contributors,
mcp_server_contributors: self.mcp_server_contributors,
turn_input_contributors: self.turn_input_contributors,
tool_contributors: self.tool_contributors,
tool_lifecycle_contributors: self.tool_lifecycle_contributors,
@@ -147,6 +156,7 @@ pub struct ExtensionRegistry<C: Sync> {
config_contributors: Vec<Arc<dyn ConfigContributor<C>>>,
token_usage_contributors: Vec<Arc<dyn TokenUsageContributor>>,
context_contributors: Vec<Arc<dyn ContextContributor>>,
mcp_server_contributors: Vec<Arc<dyn McpServerContributor<C>>>,
turn_input_contributors: Vec<Arc<dyn TurnInputContributor>>,
tool_contributors: Vec<Arc<dyn ToolContributor>>,
tool_lifecycle_contributors: Vec<Arc<dyn ToolLifecycleContributor>>,
@@ -205,6 +215,11 @@ impl<C: Sync> ExtensionRegistry<C> {
&self.context_contributors
}
/// Returns the registered runtime MCP server contributors.
pub fn mcp_server_contributors(&self) -> &[Arc<dyn McpServerContributor<C>>] {
&self.mcp_server_contributors
}
/// Returns the registered turn-input contributors.
pub fn turn_input_contributors(&self) -> &[Arc<dyn TurnInputContributor>] {
&self.turn_input_contributors
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "mcp",
crate_name = "codex_mcp_extension",
)
+29
View File
@@ -0,0 +1,29 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-mcp-extension"
version.workspace = true
[lib]
name = "codex_mcp_extension"
path = "src/lib.rs"
test = false
doctest = false
[lints]
workspace = true
[dependencies]
async-trait = { workspace = true }
codex-core = { workspace = true }
codex-extension-api = { workspace = true }
codex-features = { workspace = true }
codex-mcp = { workspace = true }
[dev-dependencies]
codex-config = { workspace = true }
codex-core-plugins = { workspace = true }
codex-login = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+31
View File
@@ -0,0 +1,31 @@
use codex_core::config::Config;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::McpServerContribution;
use codex_extension_api::McpServerContributor;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::codex_apps_mcp_server_config;
struct HostedAppsMcpExtension;
#[async_trait::async_trait]
impl McpServerContributor<Config> for HostedAppsMcpExtension {
async fn contribute(&self, config: &Config) -> Vec<McpServerContribution> {
let name = CODEX_APPS_MCP_SERVER_NAME.to_string();
if !config.features.enabled(codex_features::Feature::Apps) {
return vec![McpServerContribution::Remove { name }];
}
vec![McpServerContribution::Set {
name,
config: Box::new(codex_apps_mcp_server_config(
&config.chatgpt_base_url,
config.apps_mcp_path_override.as_deref(),
config.apps_mcp_product_sku.as_deref(),
)),
}]
}
}
pub fn install(builder: &mut ExtensionRegistryBuilder<Config>) {
builder.mcp_server_contributor(std::sync::Arc::new(HostedAppsMcpExtension));
}
+93
View File
@@ -0,0 +1,93 @@
use std::sync::Arc;
use codex_config::McpServerTransportConfig;
use codex_core::McpManager;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core_plugins::PluginsManager;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_login::CodexAuth;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn contributes_hosted_apps_mcp_without_an_executor() -> Result<(), Box<dyn std::error::Error>>
{
let codex_home = tempfile::tempdir()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![
("features.apps".to_string(), true.into()),
("features.apps_mcp_path_override".to_string(), true.into()),
("chatgpt_base_url".to_string(), "https://chatgpt.com".into()),
])
.build()
.await?;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let manager = installed_manager(&config);
let runtime_config = manager.runtime_config(&config).await;
assert!(!runtime_config.legacy_apps_mcp_loader_enabled);
let servers = manager.effective_servers(&config, Some(&auth)).await;
let server = servers
.get(CODEX_APPS_MCP_SERVER_NAME)
.and_then(|server| server.configured_config())
.ok_or("Apps MCP should be contributed as a configured server")?;
let McpServerTransportConfig::StreamableHttp { url, .. } = &server.transport else {
panic!("Apps MCP should use streamable HTTP");
};
assert_eq!(url, "https://chatgpt.com/backend-api/ps/mcp");
Ok(())
}
#[tokio::test]
async fn hosted_apps_mcp_requires_chatgpt_auth() -> Result<(), Box<dyn std::error::Error>> {
let codex_home = tempfile::tempdir()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![("features.apps".to_string(), true.into())])
.build()
.await?;
let auth = CodexAuth::from_api_key("test");
let manager = installed_manager(&config);
let servers = manager.effective_servers(&config, Some(&auth)).await;
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));
Ok(())
}
#[tokio::test]
async fn disabled_apps_remove_reserved_server_config() -> Result<(), Box<dyn std::error::Error>> {
let codex_home = tempfile::tempdir()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![
("features.apps".to_string(), false.into()),
(
"mcp_servers.codex_apps.url".to_string(),
"https://example.com/mcp".into(),
),
])
.build()
.await?;
let manager = installed_manager(&config);
let servers = manager.runtime_servers(&config).await;
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));
Ok(())
}
fn installed_manager(config: &Config) -> McpManager {
let mut builder = ExtensionRegistryBuilder::new();
codex_mcp_extension::install(&mut builder);
McpManager::new_with_extensions(
Arc::new(PluginsManager::new(config.codex_home.to_path_buf())),
Arc::new(builder.build()),
)
}