Files
codex/codex-rs/codex-mcp/src/catalog.rs
T
jif 4a5a676499 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`
2026-06-11 21:54:52 +02:00

334 lines
9.4 KiB
Rust

use std::cmp::Reverse;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
use codex_config::McpServerConfig;
/// The component that declared an MCP server registration.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum McpServerSource {
Plugin { plugin_id: String },
Config,
Compatibility { id: String },
Extension { id: String },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum RegistrationPrecedence {
Plugin(Reverse<usize>),
Config,
Compatibility,
Extension(usize),
}
impl RegistrationPrecedence {
fn tier(self) -> u8 {
match self {
Self::Plugin(_) => 0,
Self::Config => 1,
Self::Compatibility => 2,
Self::Extension(_) => 3,
}
}
}
/// One named MCP server declaration before source resolution.
#[derive(Clone, Debug, PartialEq)]
pub struct McpServerRegistration {
name: String,
source: McpServerSource,
config: McpServerConfig,
precedence: RegistrationPrecedence,
}
impl McpServerRegistration {
pub fn from_config(name: String, config: McpServerConfig) -> Self {
Self::new(
name,
McpServerSource::Config,
config,
RegistrationPrecedence::Config,
)
}
pub fn from_plugin(
name: String,
plugin_id: String,
plugin_order: usize,
config: McpServerConfig,
) -> Self {
Self::new(
name,
McpServerSource::Plugin { plugin_id },
config,
RegistrationPrecedence::Plugin(Reverse(plugin_order)),
)
}
pub fn from_compatibility(
name: String,
id: impl Into<String>,
config: McpServerConfig,
) -> Self {
Self::new(
name,
McpServerSource::Compatibility { id: id.into() },
config,
RegistrationPrecedence::Compatibility,
)
}
pub fn from_extension(
name: String,
id: impl Into<String>,
contribution_order: usize,
config: McpServerConfig,
) -> Self {
Self::new(
name,
McpServerSource::Extension { id: id.into() },
config,
RegistrationPrecedence::Extension(contribution_order),
)
}
fn new(
name: String,
source: McpServerSource,
config: McpServerConfig,
precedence: RegistrationPrecedence,
) -> Self {
Self {
name,
source,
config,
precedence,
}
}
}
/// One side of an MCP server conflict, including whether it registers or
/// removes the server.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum McpServerConflictAction {
Register(McpServerSource),
Remove(McpServerSource),
}
/// A same-tier name collision and the final outcome after all precedence is applied.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct McpServerConflict {
pub name: String,
pub outcome: McpServerConflictAction,
pub contenders: Vec<McpServerConflictAction>,
}
#[derive(Clone, Debug)]
enum CatalogAction {
Register(Box<McpServerRegistration>),
Remove {
name: String,
source: McpServerSource,
precedence: RegistrationPrecedence,
},
}
impl CatalogAction {
fn name(&self) -> &str {
match self {
Self::Register(registration) => &registration.name,
Self::Remove { name, .. } => name,
}
}
fn precedence(&self) -> RegistrationPrecedence {
match self {
Self::Register(registration) => registration.precedence,
Self::Remove { precedence, .. } => *precedence,
}
}
fn conflict_action(&self) -> McpServerConflictAction {
match self {
Self::Register(registration) => {
McpServerConflictAction::Register(registration.source.clone())
}
Self::Remove { source, .. } => McpServerConflictAction::Remove(source.clone()),
}
}
}
/// Mutable inputs used to produce an immutable resolved catalog.
#[derive(Clone, Debug, Default)]
pub struct McpCatalogBuilder {
actions: Vec<CatalogAction>,
disabled_server_names: BTreeSet<String>,
}
impl McpCatalogBuilder {
pub fn register(&mut self, registration: McpServerRegistration) {
self.actions
.push(CatalogAction::Register(Box::new(registration)));
}
/// Applies the legacy name-scoped disabled veto after source resolution.
pub fn disable(&mut self, name: String) {
self.disabled_server_names.insert(name);
}
pub fn remove_compatibility(&mut self, name: String, id: impl Into<String>) {
self.actions.push(CatalogAction::Remove {
name,
source: McpServerSource::Compatibility { id: id.into() },
precedence: RegistrationPrecedence::Compatibility,
});
}
pub fn remove_extension(
&mut self,
name: String,
id: impl Into<String>,
contribution_order: usize,
) {
self.actions.push(CatalogAction::Remove {
name,
source: McpServerSource::Extension { id: id.into() },
precedence: RegistrationPrecedence::Extension(contribution_order),
});
}
pub fn build(mut self) -> ResolvedMcpCatalog {
// Stable sorting makes action order the tie-breaker when precedence is equal.
self.actions.sort_by_key(CatalogAction::precedence);
let mut winners = BTreeMap::<String, CatalogAction>::new();
let mut actions_by_name_and_tier = BTreeMap::<(String, u8), Vec<&CatalogAction>>::new();
for action in &self.actions {
winners.insert(action.name().to_string(), action.clone());
actions_by_name_and_tier
.entry((action.name().to_string(), action.precedence().tier()))
.or_default()
.push(action);
}
let mut conflicts = Vec::new();
for ((name, _), actions) in actions_by_name_and_tier {
if actions.len() < 2 {
continue;
}
let Some(outcome) = winners.get(&name).map(CatalogAction::conflict_action) else {
continue;
};
conflicts.push(McpServerConflict {
name,
outcome,
contenders: actions
.into_iter()
.map(CatalogAction::conflict_action)
.collect(),
});
}
let mut disabled_server_names = self.disabled_server_names;
let servers = winners
.into_iter()
.filter_map(|(name, action)| match action {
CatalogAction::Register(registration) => {
let mut registration = *registration;
// Effective disabled winners remain name-scoped vetoes for later overlays.
if !registration.config.enabled || disabled_server_names.contains(&name) {
registration.config.enabled = false;
disabled_server_names.insert(name.clone());
}
Some((
name,
ResolvedMcpServer {
source: registration.source,
config: registration.config,
},
))
}
CatalogAction::Remove { .. } => None,
})
.collect();
ResolvedMcpCatalog {
actions: self.actions,
disabled_server_names,
servers,
conflicts,
}
}
}
/// A single winning MCP registration.
#[derive(Clone, Debug, PartialEq)]
pub struct ResolvedMcpServer {
source: McpServerSource,
config: McpServerConfig,
}
impl ResolvedMcpServer {
pub fn source(&self) -> &McpServerSource {
&self.source
}
pub fn config(&self) -> &McpServerConfig {
&self.config
}
}
/// Immutable result of MCP registration resolution.
#[derive(Clone, Debug, Default)]
pub struct ResolvedMcpCatalog {
actions: Vec<CatalogAction>,
disabled_server_names: BTreeSet<String>,
servers: BTreeMap<String, ResolvedMcpServer>,
conflicts: Vec<McpServerConflict>,
}
impl ResolvedMcpCatalog {
pub fn builder() -> McpCatalogBuilder {
McpCatalogBuilder::default()
}
pub fn to_builder(&self) -> McpCatalogBuilder {
McpCatalogBuilder {
actions: self.actions.clone(),
disabled_server_names: self.disabled_server_names.clone(),
}
}
pub fn server(&self, name: &str) -> Option<&ResolvedMcpServer> {
self.servers.get(name)
}
pub fn configured_servers(&self) -> HashMap<String, McpServerConfig> {
self.servers
.iter()
.map(|(name, server)| (name.clone(), server.config.clone()))
.collect()
}
pub fn plugin_ids_by_server_name(&self) -> HashMap<String, String> {
self.servers
.iter()
.filter_map(|(name, server)| match server.source() {
McpServerSource::Plugin { plugin_id } => Some((name.clone(), plugin_id.clone())),
McpServerSource::Config
| McpServerSource::Compatibility { .. }
| McpServerSource::Extension { .. } => None,
})
.collect()
}
pub fn conflicts(&self) -> &[McpServerConflict] {
&self.conflicts
}
}
#[cfg(test)]
#[path = "catalog_tests.rs"]
mod tests;