1use std::cmp::Reverse;
2use std::collections::BTreeMap;
3use std::collections::BTreeSet;
4use std::collections::HashMap;
5
6use codex_config::McpServerConfig;
7
8#[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#[derive(Clone, Debug, PartialEq, Eq)]
34pub enum McpServerSource {
35 Plugin(McpPluginAttribution),
37 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 !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#[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 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#[derive(Clone, Debug, PartialEq, Eq)]
170pub enum McpServerConflictAction {
171 Register(McpServerSource),
172 Remove(McpServerSource),
173}
174
175#[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) => ®istration.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#[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 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 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 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#[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#[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 pub fn has_same_servers(&self, other: &Self) -> bool {
376 self.servers == other.servers
377 }
378
379 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 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 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;