Files
codex/codex-rs/core/src/mcp.rs
T
jif c3a479620f Add selected-plugin precedence and attribution to the MCP catalog (#27884)
## Why

**In short:** this PR resolves already-discovered MCP registrations. It
does not read selected plugins or discover their MCP servers.

The resolved MCP catalog currently builds config and auto-discovered
plugin registrations before runtime contributors are applied. A
thread-selected plugin needs a distinct precedence tier in that same
initial resolution pass: otherwise a disabled lower-precedence winner
can leave stale name-level state behind, and the winning MCP tools
cannot be attributed to the selected package reliably.

This PR adds that catalog boundary before executor discovery is
connected.

## What changed

- Added an explicit selected-plugin registration tier between
auto-discovered plugins and explicit config.
- Collected selected-plugin contributions before the initial catalog
build, while leaving compatibility and generic extension overlays in
their existing runtime phase.
- Retained the winning plugin ID and display name directly on
plugin-owned catalog registrations.
- Derived MCP tool provenance from the winning catalog entry instead of
joining against local-only plugin summaries.
- Retained the winning selected server's tool approval policy in the
running connection manager, so a selected registration cannot inherit
approval behavior from a losing local plugin.
- Kept remembered approval session-scoped for selected plugins until
there is an authority-aware persistence contract; Codex will not write
approval back to an unrelated local plugin.
- Preserved existing name-level disabled vetoes for discovered plugins
and config, while keeping a selected package's own disabled registration
scoped to that registration.
- Preserved deterministic selection order and existing config,
compatibility, and extension precedence.

The resulting order is:

```text
auto-discovered plugin
  < selected plugin
  < explicit config
  < compatibility registration
  < extension overlay
```

## Behavior and scope

This is a catalog and provenance change only. No production host
contributes selected-plugin MCP registrations yet, so existing local MCP
behavior remains unchanged.

The stacked follow-up, #27870, installs the executor plugin provider
that produces these registrations. App-server activation remains a
separate final step.

## Verification

Focused tests cover precedence, deterministic selected-plugin conflicts,
disabled-veto behavior across catalog phases, managed requirements
before selected-plugin resolution, winning-server approval policy, and
attribution when local and selected packages share an ID or server name.
CI owns execution of the test suite.
2026-06-15 11:10:51 +02:00

207 lines
7.3 KiB
Rust

use std::collections::HashMap;
use std::sync::Arc;
use crate::config::Config;
use codex_config::McpServerConfig;
use codex_core_plugins::PluginsManager;
use codex_extension_api::ExtensionDataInit;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::McpServerContribution;
use codex_extension_api::McpServerContributionContext;
use codex_login::CodexAuth;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::EffectiveMcpServer;
use codex_mcp::McpConfig;
use codex_mcp::McpPluginAttribution;
use codex_mcp::McpServerRegistration;
use codex_mcp::codex_apps_mcp_server_config;
use codex_mcp::configured_mcp_servers;
use codex_mcp::effective_mcp_servers;
const LEGACY_CODEX_APPS_REGISTRATION_ID: &str = "legacy_codex_apps";
enum OrderedMcpOverlay {
Set {
contributor_id: &'static str,
contribution_order: usize,
name: String,
config: Box<McpServerConfig>,
},
Remove {
contributor_id: &'static str,
contribution_order: usize,
name: String,
},
}
#[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,
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 compatibility built-ins and
/// runtime-only extension overlays.
pub async fn runtime_config(&self, config: &Config) -> McpConfig {
self.runtime_config_with_context(config, /*thread_init*/ None)
.await
}
pub(crate) async fn runtime_config_for_thread(
&self,
config: &Config,
thread_init: &ExtensionDataInit,
) -> McpConfig {
self.runtime_config_with_context(config, Some(thread_init))
.await
}
async fn runtime_config_with_context(
&self,
config: &Config,
thread_init: Option<&ExtensionDataInit>,
) -> McpConfig {
let context = match thread_init {
Some(thread_init) => McpServerContributionContext::for_thread(config, thread_init),
None => McpServerContributionContext::global(config),
};
let mut selected_plugin_registrations = Vec::new();
let mut overlays = Vec::new();
// A contributor can emit multiple ordered actions, so order each action globally rather
// than enumerating contributors.
let mut contribution_order = 0;
for contributor in self.extensions.mcp_server_contributors() {
for contribution in contributor.contribute(context).await {
match contribution {
McpServerContribution::Set { name, config } => {
overlays.push(OrderedMcpOverlay::Set {
contributor_id: contributor.id(),
contribution_order,
name,
config,
});
}
McpServerContribution::SelectedPlugin {
name,
plugin_id,
plugin_display_name,
selection_order,
config,
} => selected_plugin_registrations.push(
McpServerRegistration::from_selected_plugin(
name,
McpPluginAttribution::new(plugin_id, plugin_display_name),
selection_order,
*config,
),
),
McpServerContribution::Remove { name } => {
overlays.push(OrderedMcpOverlay::Remove {
contributor_id: contributor.id(),
contribution_order,
name,
});
}
}
contribution_order += 1;
}
}
let mut mcp_config = config
.to_mcp_config_with_plugin_registrations(
self.plugins_manager.as_ref(),
selected_plugin_registrations,
)
.await;
let mut catalog = mcp_config.mcp_server_catalog.to_builder();
if mcp_config.apps_enabled {
catalog.register(McpServerRegistration::from_compatibility(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
LEGACY_CODEX_APPS_REGISTRATION_ID,
codex_apps_mcp_server_config(
&mcp_config.chatgpt_base_url,
mcp_config.apps_mcp_product_sku.as_deref(),
),
));
} else {
catalog.remove_compatibility(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
LEGACY_CODEX_APPS_REGISTRATION_ID,
);
}
for overlay in overlays {
match overlay {
OrderedMcpOverlay::Set {
contributor_id,
contribution_order,
name,
config,
} => catalog.register(McpServerRegistration::from_extension(
name,
contributor_id,
contribution_order,
*config,
)),
OrderedMcpOverlay::Remove {
contributor_id,
contribution_order,
name,
} => catalog.remove_extension(name, contributor_id, contribution_order),
}
}
let catalog = catalog.build();
for conflict in catalog.conflicts() {
tracing::warn!(
server = conflict.name,
outcome = ?conflict.outcome,
contenders = ?conflict.contenders,
"conflicting MCP server actions; using resolved catalog outcome"
);
}
mcp_config.mcp_server_catalog = catalog;
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 = self.runtime_config(config).await;
effective_mcp_servers(&mcp_config, auth)
}
}