Skip to main content

codex_mcp/
catalog.rs

1use std::cmp::Reverse;
2use std::collections::BTreeMap;
3use std::collections::BTreeSet;
4use std::collections::HashMap;
5
6use codex_config::McpServerConfig;
7
8/// Plugin identity retained with an MCP registration for tool attribution.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct McpPluginAttribution {
11    plugin_id: String,
12    display_name: String,
13}
14
15impl McpPluginAttribution {
16    pub fn new(plugin_id: String, display_name: String) -> Self {
17        Self {
18            plugin_id,
19            display_name,
20        }
21    }
22
23    pub fn plugin_id(&self) -> &str {
24        &self.plugin_id
25    }
26
27    pub fn display_name(&self) -> &str {
28        &self.display_name
29    }
30}
31
32/// The component that declared an MCP server registration.
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub enum McpServerSource {
35    /// A plugin discovered through the process-wide legacy plugin manager.
36    Plugin(McpPluginAttribution),
37    /// A plugin explicitly selected for this thread through a capability root.
38    SelectedPlugin(McpPluginAttribution),
39    Config,
40    Compatibility {
41        id: String,
42    },
43    Extension {
44        id: String,
45    },
46}
47
48impl McpServerSource {
49    fn disabled_registration_is_name_veto(&self) -> bool {
50        // A selected package's policy applies to its registration, not to a higher runtime source
51        // that happens to use the same logical server name.
52        !matches!(self, Self::SelectedPlugin(_))
53    }
54}
55
56#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
57enum RegistrationPrecedence {
58    Plugin(Reverse<usize>),
59    SelectedPlugin(Reverse<usize>),
60    Config,
61    Compatibility,
62    Extension(usize),
63}
64
65impl RegistrationPrecedence {
66    fn tier(self) -> u8 {
67        match self {
68            Self::Plugin(_) => 0,
69            Self::SelectedPlugin(_) => 1,
70            Self::Config => 2,
71            Self::Compatibility => 3,
72            Self::Extension(_) => 4,
73        }
74    }
75}
76
77/// One named MCP server declaration before source resolution.
78#[derive(Clone, Debug, PartialEq)]
79pub struct McpServerRegistration {
80    name: String,
81    source: McpServerSource,
82    config: McpServerConfig,
83    precedence: RegistrationPrecedence,
84}
85
86impl McpServerRegistration {
87    pub fn from_config(name: String, config: McpServerConfig) -> Self {
88        Self::new(
89            name,
90            McpServerSource::Config,
91            config,
92            RegistrationPrecedence::Config,
93        )
94    }
95
96    pub fn from_plugin(
97        name: String,
98        attribution: McpPluginAttribution,
99        plugin_order: usize,
100        config: McpServerConfig,
101    ) -> Self {
102        Self::new(
103            name,
104            McpServerSource::Plugin(attribution),
105            config,
106            RegistrationPrecedence::Plugin(Reverse(plugin_order)),
107        )
108    }
109
110    /// Registers a thread-selected plugin above discovered plugins and below config.
111    pub fn from_selected_plugin(
112        name: String,
113        attribution: McpPluginAttribution,
114        selection_order: usize,
115        config: McpServerConfig,
116    ) -> Self {
117        Self::new(
118            name,
119            McpServerSource::SelectedPlugin(attribution),
120            config,
121            RegistrationPrecedence::SelectedPlugin(Reverse(selection_order)),
122        )
123    }
124
125    pub fn from_compatibility(
126        name: String,
127        id: impl Into<String>,
128        config: McpServerConfig,
129    ) -> Self {
130        Self::new(
131            name,
132            McpServerSource::Compatibility { id: id.into() },
133            config,
134            RegistrationPrecedence::Compatibility,
135        )
136    }
137
138    pub fn from_extension(
139        name: String,
140        id: impl Into<String>,
141        contribution_order: usize,
142        config: McpServerConfig,
143    ) -> Self {
144        Self::new(
145            name,
146            McpServerSource::Extension { id: id.into() },
147            config,
148            RegistrationPrecedence::Extension(contribution_order),
149        )
150    }
151
152    fn new(
153        name: String,
154        source: McpServerSource,
155        config: McpServerConfig,
156        precedence: RegistrationPrecedence,
157    ) -> Self {
158        Self {
159            name,
160            source,
161            config,
162            precedence,
163        }
164    }
165}
166
167/// One side of an MCP server conflict, including whether it registers or
168/// removes the server.
169#[derive(Clone, Debug, PartialEq, Eq)]
170pub enum McpServerConflictAction {
171    Register(McpServerSource),
172    Remove(McpServerSource),
173}
174
175/// A same-tier name collision and the final outcome after all precedence is applied.
176#[derive(Clone, Debug, PartialEq, Eq)]
177pub struct McpServerConflict {
178    pub name: String,
179    pub outcome: McpServerConflictAction,
180    pub contenders: Vec<McpServerConflictAction>,
181}
182
183#[derive(Clone, Debug)]
184enum CatalogAction {
185    Register(Box<McpServerRegistration>),
186    Remove {
187        name: String,
188        source: McpServerSource,
189        precedence: RegistrationPrecedence,
190    },
191}
192
193impl CatalogAction {
194    fn name(&self) -> &str {
195        match self {
196            Self::Register(registration) => &registration.name,
197            Self::Remove { name, .. } => name,
198        }
199    }
200
201    fn precedence(&self) -> RegistrationPrecedence {
202        match self {
203            Self::Register(registration) => registration.precedence,
204            Self::Remove { precedence, .. } => *precedence,
205        }
206    }
207
208    fn conflict_action(&self) -> McpServerConflictAction {
209        match self {
210            Self::Register(registration) => {
211                McpServerConflictAction::Register(registration.source.clone())
212            }
213            Self::Remove { source, .. } => McpServerConflictAction::Remove(source.clone()),
214        }
215    }
216}
217
218/// Mutable inputs used to produce an immutable resolved catalog.
219#[derive(Clone, Debug, Default)]
220pub struct McpCatalogBuilder {
221    actions: Vec<CatalogAction>,
222    disabled_server_names: BTreeSet<String>,
223}
224
225impl McpCatalogBuilder {
226    pub fn register(&mut self, registration: McpServerRegistration) {
227        self.actions
228            .push(CatalogAction::Register(Box::new(registration)));
229    }
230
231    /// Applies the legacy name-scoped disabled veto after source resolution.
232    pub fn disable(&mut self, name: String) {
233        self.disabled_server_names.insert(name);
234    }
235
236    pub fn remove_compatibility(&mut self, name: String, id: impl Into<String>) {
237        self.actions.push(CatalogAction::Remove {
238            name,
239            source: McpServerSource::Compatibility { id: id.into() },
240            precedence: RegistrationPrecedence::Compatibility,
241        });
242    }
243
244    pub fn remove_extension(
245        &mut self,
246        name: String,
247        id: impl Into<String>,
248        contribution_order: usize,
249    ) {
250        self.actions.push(CatalogAction::Remove {
251            name,
252            source: McpServerSource::Extension { id: id.into() },
253            precedence: RegistrationPrecedence::Extension(contribution_order),
254        });
255    }
256
257    pub fn build(mut self) -> ResolvedMcpCatalog {
258        // Stable sorting makes action order the tie-breaker when precedence is equal.
259        self.actions.sort_by_key(CatalogAction::precedence);
260
261        let mut winners = BTreeMap::<String, CatalogAction>::new();
262        let mut actions_by_name_and_tier = BTreeMap::<(String, u8), Vec<&CatalogAction>>::new();
263        for action in &self.actions {
264            winners.insert(action.name().to_string(), action.clone());
265            actions_by_name_and_tier
266                .entry((action.name().to_string(), action.precedence().tier()))
267                .or_default()
268                .push(action);
269        }
270
271        let mut conflicts = Vec::new();
272        for ((name, _), actions) in actions_by_name_and_tier {
273            if actions.len() < 2 {
274                continue;
275            }
276            let Some(outcome) = winners.get(&name).map(CatalogAction::conflict_action) else {
277                continue;
278            };
279            conflicts.push(McpServerConflict {
280                name,
281                outcome,
282                contenders: actions
283                    .into_iter()
284                    .map(CatalogAction::conflict_action)
285                    .collect(),
286            });
287        }
288
289        let mut disabled_server_names = self.disabled_server_names;
290        let servers = winners
291            .into_iter()
292            .filter_map(|(name, action)| match action {
293                CatalogAction::Register(registration) => {
294                    let mut registration = *registration;
295                    let persist_disabled_name =
296                        registration.source.disabled_registration_is_name_veto();
297                    if !registration.config.enabled || disabled_server_names.contains(&name) {
298                        registration.config.enabled = false;
299                        if persist_disabled_name {
300                            // Preserve legacy disabled winners across later runtime overlays.
301                            disabled_server_names.insert(name.clone());
302                        }
303                    }
304                    Some((
305                        name,
306                        ResolvedMcpServer {
307                            source: registration.source,
308                            config: registration.config,
309                        },
310                    ))
311                }
312                CatalogAction::Remove { .. } => None,
313            })
314            .collect();
315
316        ResolvedMcpCatalog {
317            actions: self.actions,
318            disabled_server_names,
319            servers,
320            conflicts,
321        }
322    }
323}
324
325/// A single winning MCP registration.
326#[derive(Clone, Debug, PartialEq)]
327pub struct ResolvedMcpServer {
328    source: McpServerSource,
329    config: McpServerConfig,
330}
331
332impl ResolvedMcpServer {
333    pub fn source(&self) -> &McpServerSource {
334        &self.source
335    }
336
337    pub fn config(&self) -> &McpServerConfig {
338        &self.config
339    }
340}
341
342/// Immutable result of MCP registration resolution.
343#[derive(Clone, Debug, Default)]
344pub struct ResolvedMcpCatalog {
345    actions: Vec<CatalogAction>,
346    disabled_server_names: BTreeSet<String>,
347    servers: BTreeMap<String, ResolvedMcpServer>,
348    conflicts: Vec<McpServerConflict>,
349}
350
351impl ResolvedMcpCatalog {
352    pub fn builder() -> McpCatalogBuilder {
353        McpCatalogBuilder::default()
354    }
355
356    pub fn to_builder(&self) -> McpCatalogBuilder {
357        McpCatalogBuilder {
358            actions: self.actions.clone(),
359            disabled_server_names: self.disabled_server_names.clone(),
360        }
361    }
362
363    pub fn server(&self, name: &str) -> Option<&ResolvedMcpServer> {
364        self.servers.get(name)
365    }
366
367    pub fn configured_servers(&self) -> HashMap<String, McpServerConfig> {
368        self.servers
369            .iter()
370            .map(|(name, server)| (name.clone(), server.config.clone()))
371            .collect()
372    }
373
374    /// Returns whether both catalogs resolve to the same winning servers and sources.
375    pub fn has_same_servers(&self, other: &Self) -> bool {
376        self.servers == other.servers
377    }
378
379    /// Replaces the resolved server set while preserving known server sources.
380    ///
381    /// Names not present in the existing catalog are treated as config-owned.
382    pub fn with_materialized_servers(&self, servers: HashMap<String, McpServerConfig>) -> Self {
383        let mut builder = Self::builder();
384        for (name, config) in servers {
385            let source = self
386                .server(&name)
387                .map(|server| server.source.clone())
388                .unwrap_or(McpServerSource::Config);
389            let precedence = match &source {
390                McpServerSource::Plugin(_) => RegistrationPrecedence::Plugin(Reverse(0)),
391                McpServerSource::SelectedPlugin(_) => {
392                    RegistrationPrecedence::SelectedPlugin(Reverse(0))
393                }
394                McpServerSource::Config => RegistrationPrecedence::Config,
395                McpServerSource::Compatibility { .. } => RegistrationPrecedence::Compatibility,
396                McpServerSource::Extension { .. } => RegistrationPrecedence::Extension(0),
397            };
398            builder.register(McpServerRegistration::new(name, source, config, precedence));
399        }
400        builder.build()
401    }
402
403    /// Returns package attribution for each winning plugin-owned server.
404    pub fn plugin_attributions_by_server_name(&self) -> HashMap<String, McpPluginAttribution> {
405        self.servers
406            .iter()
407            .filter_map(|(name, server)| match server.source() {
408                McpServerSource::Plugin(attribution)
409                | McpServerSource::SelectedPlugin(attribution) => {
410                    Some((name.clone(), attribution.clone()))
411                }
412                McpServerSource::Config
413                | McpServerSource::Compatibility { .. }
414                | McpServerSource::Extension { .. } => None,
415            })
416            .collect()
417    }
418
419    /// Returns the names of winning servers supplied by thread-selected plugins.
420    pub(crate) fn selected_plugin_server_names(&self) -> impl Iterator<Item = &str> {
421        self.servers.iter().filter_map(|(name, server)| {
422            matches!(server.source(), McpServerSource::SelectedPlugin(_)).then_some(name.as_str())
423        })
424    }
425
426    pub fn conflicts(&self) -> &[McpServerConflict] {
427        &self.conflicts
428    }
429}
430
431#[cfg(test)]
432#[path = "catalog_tests.rs"]
433mod tests;