Resolve MCP server registrations through a catalog (#27634)

## Why

MCP servers currently come from user config, local plugins,
compatibility Apps synthesis, and host extensions. Those sources were
composed by mutating a shared map, leaving registration identity,
precedence, removal, and provenance implicit in assembly order.

Before adding executor-owned MCPs, Codex needs one durable resolution
boundary above `McpConnectionManager`. This PR introduces that boundary
while preserving current server configuration, policy, and runtime
behavior. Executor-scoped registrations and explicit policy layers
remain follow-ups.

## What changed

- Add typed `McpServerRegistration` inputs and an immutable
`ResolvedMcpCatalog` in `codex-mcp`.
- Retain each registration's complete `McpServerConfig`, including its
environment binding, while recording its source and provenance.
- Preserve the existing structural precedence between plugin, config,
compatibility, and ordered extension sources.
- Resolve equal-precedence actions by contribution order; provenance IDs
are used only for diagnostics and cannot affect the winner.
- Preserve extension removals and the existing name-scoped `enabled =
false` veto.
- Report same-tier conflicts with every contender and the final catalog
outcome, including whether the winning action registers or removes the
server.
- Require MCP contributors to provide a stable diagnostic identity.
- Derive materialized server maps and plugin ownership from the resolved
catalog.

`McpConnectionManager`, transport startup, tool calls, and resource
routing continue to consume the same effective `McpServerConfig` values.

## Scope

This PR does not add new MCP capabilities or change user-visible
behavior. It does not add executor plugin discovery, thread-scoped
registrations, dynamic refresh generations, or new user/managed policy
semantics.

## Verification

- Added focused catalog coverage for source precedence, complete
configuration preservation, disabled vetoes, plugin ownership,
contribution-order tie breaking, removal outcomes, and conflict
diagnostics.
- Extended hosted Apps coverage for ordered extension removal and
Apps-disabled hosts with and without the hosted extension installed.
- `cargo check -p codex-mcp --tests -p codex-extension-api -p
codex-core`
This commit is contained in:
jif
2026-06-11 20:54:52 +01:00
committed by GitHub
Unverified
parent 236b50125d
commit 4a5a676499
14 changed files with 745 additions and 121 deletions
+9 -11
View File
@@ -37,6 +37,7 @@ use rmcp::model::ReadResourceResult;
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use crate::ResolvedMcpCatalog;
use crate::codex_apps::codex_apps_tools_cache_key;
use crate::connection_manager::McpConnectionManager;
use crate::runtime::McpRuntimeContext;
@@ -135,13 +136,8 @@ pub struct McpConfig {
pub prefix_mcp_tool_names: bool,
/// Client-side elicitation capabilities advertised during MCP initialization.
pub client_elicitation_capability: ElicitationCapability,
/// Materialized MCP servers keyed by server name.
///
/// A host may add compatibility built-ins and extension overlays before
/// calling runtime entry points in this crate.
pub configured_mcp_servers: HashMap<String, McpServerConfig>,
/// Winning plugin owner for plugin-provided MCP servers, keyed by server name.
pub plugin_ids_by_mcp_server_name: HashMap<String, String>,
/// Resolved MCP registrations keyed by logical server name.
pub mcp_server_catalog: ResolvedMcpCatalog,
/// Plugin metadata used to attribute MCP tools/connectors to plugin display names.
pub plugin_capability_summaries: Vec<PluginCapabilitySummary>,
}
@@ -176,6 +172,7 @@ impl ToolPluginProvenance {
fn from_config(config: &McpConfig) -> Self {
let mut tool_plugin_provenance = Self::default();
let plugin_ids_by_mcp_server_name = config.mcp_server_catalog.plugin_ids_by_server_name();
for plugin in &config.plugin_capability_summaries {
for connector_id in &plugin.app_connector_ids {
tool_plugin_provenance
@@ -185,7 +182,9 @@ impl ToolPluginProvenance {
.push(plugin.display_name.clone());
}
for server_name in &plugin.mcp_server_names {
for server_name in plugin.mcp_server_names.iter().filter(|server_name| {
plugin_ids_by_mcp_server_name.get(*server_name) == Some(&plugin.config_name)
}) {
tool_plugin_provenance
.plugin_display_names_by_mcp_server_name
.entry(server_name.clone())
@@ -206,8 +205,7 @@ impl ToolPluginProvenance {
plugin_names.sort_unstable();
plugin_names.dedup();
}
tool_plugin_provenance.plugin_ids_by_mcp_server_name =
config.plugin_ids_by_mcp_server_name.clone();
tool_plugin_provenance.plugin_ids_by_mcp_server_name = plugin_ids_by_mcp_server_name;
tool_plugin_provenance
}
@@ -218,7 +216,7 @@ pub fn host_owned_codex_apps_enabled(config: &McpConfig, auth: Option<&CodexAuth
}
pub fn configured_mcp_servers(config: &McpConfig) -> HashMap<String, McpServerConfig> {
config.configured_mcp_servers.clone()
config.mcp_server_catalog.configured_servers()
}
pub fn effective_mcp_servers(
+24 -14
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::McpServerRegistration;
use codex_config::Constrained;
use codex_config::types::AppToolApproval;
use codex_login::CodexAuth;
@@ -28,8 +29,7 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
apps_enabled: false,
prefix_mcp_tool_names: true,
client_elicitation_capability: ElicitationCapability::default(),
configured_mcp_servers: HashMap::new(),
plugin_ids_by_mcp_server_name: HashMap::new(),
mcp_server_catalog: ResolvedMcpCatalog::default(),
plugin_capability_summaries: Vec::new(),
}
}
@@ -122,16 +122,24 @@ fn mcp_prompt_auto_approval_rejects_auto_mode_in_default_permission_mode() {
#[test]
fn tool_plugin_provenance_collects_app_and_mcp_sources() {
let mut config = test_mcp_config(PathBuf::new());
config.plugin_ids_by_mcp_server_name =
HashMap::from([("alpha".to_string(), "alpha@test".to_string())]);
let mut catalog = ResolvedMcpCatalog::builder();
catalog.register(McpServerRegistration::from_plugin(
"alpha".to_string(),
"alpha@test".to_string(),
/*plugin_order*/ 0,
codex_apps_mcp_server_config("https://alpha.example", /*apps_mcp_product_sku*/ None),
));
config.mcp_server_catalog = catalog.build();
config.plugin_capability_summaries = vec![
PluginCapabilitySummary {
config_name: "alpha@test".to_string(),
display_name: "alpha-plugin".to_string(),
app_connector_ids: vec![AppConnectorId("connector_example".to_string())],
mcp_server_names: vec!["alpha".to_string()],
..PluginCapabilitySummary::default()
},
PluginCapabilitySummary {
config_name: "beta@test".to_string(),
display_name: "beta-plugin".to_string(),
app_connector_ids: vec![
AppConnectorId("connector_example".to_string()),
@@ -156,10 +164,10 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() {
vec!["beta-plugin".to_string()],
),
]),
plugin_display_names_by_mcp_server_name: HashMap::from([
("alpha".to_string(), vec!["alpha-plugin".to_string()]),
("beta".to_string(), vec!["beta-plugin".to_string()]),
]),
plugin_display_names_by_mcp_server_name: HashMap::from([(
"alpha".to_string(),
vec!["alpha-plugin".to_string()],
)]),
plugin_ids_by_mcp_server_name: HashMap::from([(
"alpha".to_string(),
"alpha@test".to_string(),
@@ -235,7 +243,8 @@ async fn effective_mcp_servers_preserve_runtime_servers() {
config.apps_enabled = true;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
config.configured_mcp_servers.insert(
let mut catalog = ResolvedMcpCatalog::builder();
catalog.register(McpServerRegistration::from_config(
"sample".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
@@ -259,8 +268,8 @@ async fn effective_mcp_servers_preserve_runtime_servers() {
oauth_resource: None,
tools: HashMap::new(),
},
);
config.configured_mcp_servers.insert(
));
catalog.register(McpServerRegistration::from_config(
"docs".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
@@ -284,14 +293,15 @@ async fn effective_mcp_servers_preserve_runtime_servers() {
oauth_resource: None,
tools: HashMap::new(),
},
);
config.configured_mcp_servers.insert(
));
catalog.register(McpServerRegistration::from_config(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
codex_apps_mcp_server_config(
&config.chatgpt_base_url,
config.apps_mcp_product_sku.as_deref(),
),
);
));
config.mcp_server_catalog = catalog.build();
let effective = effective_mcp_servers(&config, Some(&auth));