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 21:54:52 +02:00
committed by GitHub
parent 236b50125d
commit 4a5a676499
14 changed files with 745 additions and 121 deletions
@@ -48,6 +48,9 @@ pub type ExtensionFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
/// Plugin-owned servers and their provenance continue to be resolved by the
/// plugin manager until that ownership moves into an extension explicitly.
pub trait McpServerContributor<C: Sync>: Send + Sync {
/// Stable identity used for registration provenance and conflict diagnostics.
fn id(&self) -> &'static str;
fn contribute<'a>(&'a self, config: &'a C) -> ExtensionFuture<'a, Vec<McpServerContribution>>;
}
+4
View File
@@ -9,6 +9,10 @@ use codex_mcp::hosted_plugin_runtime_mcp_server_config;
struct HostedPluginRuntimeExtension;
impl McpServerContributor<Config> for HostedPluginRuntimeExtension {
fn id(&self) -> &'static str {
"hosted_plugin_runtime"
}
fn contribute<'a>(
&'a self,
config: &'a Config,
+17 -7
View File
@@ -104,7 +104,7 @@ async fn legacy_fallback_overwrites_reserved_config_without_an_extension() -> Te
}
#[tokio::test]
async fn extension_can_remove_legacy_fallback_while_apps_are_enabled() -> TestResult {
async fn later_extension_can_remove_same_name_registration() -> TestResult {
let codex_home = tempfile::tempdir()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
@@ -114,6 +114,7 @@ async fn extension_can_remove_legacy_fallback_while_apps_are_enabled() -> TestRe
.await?;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let mut builder = ExtensionRegistryBuilder::new();
codex_mcp_extension::install(&mut builder);
builder.mcp_server_contributor(Arc::new(RemoveCodexApps));
let manager = McpManager::new_with_extensions(
Arc::new(PluginsManager::new(config.codex_home.to_path_buf())),
@@ -145,7 +146,7 @@ async fn hosted_apps_mcp_requires_chatgpt_auth() -> TestResult {
}
#[tokio::test]
async fn disabled_apps_remove_reserved_server_config() -> TestResult {
async fn disabled_apps_remove_reserved_server_config_for_all_hosts() -> TestResult {
let codex_home = tempfile::tempdir()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
@@ -159,11 +160,16 @@ async fn disabled_apps_remove_reserved_server_config() -> TestResult {
])
.build()
.await?;
let manager = installed_manager(&config);
let servers = manager.runtime_servers(&config).await;
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));
let managers = [
installed_manager(&config),
McpManager::new(Arc::new(PluginsManager::new(
config.codex_home.to_path_buf(),
))),
];
for manager in managers {
let servers = manager.runtime_servers(&config).await;
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));
}
Ok(())
}
@@ -179,6 +185,10 @@ fn installed_manager(config: &Config) -> McpManager {
struct RemoveCodexApps;
impl McpServerContributor<Config> for RemoveCodexApps {
fn id(&self) -> &'static str {
"remove_codex_apps"
}
fn contribute<'a>(
&'a self,
_config: &'a Config,