mirror of
https://github.com/openai/codex.git
synced 2026-10-26 18:38:47 +00:00
## 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`
334 lines
9.4 KiB
Rust
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) => ®istration.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;
|