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
+333
View File
@@ -0,0 +1,333 @@
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;
+257
View File
@@ -0,0 +1,257 @@
use std::collections::HashMap;
use std::time::Duration;
use codex_config::AppToolApproval;
use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
use codex_config::McpServerConfig;
use codex_config::McpServerToolConfig;
use codex_config::McpServerTransportConfig;
use pretty_assertions::assert_eq;
use super::McpServerConflict;
use super::McpServerConflictAction;
use super::McpServerRegistration;
use super::McpServerSource;
use super::ResolvedMcpCatalog;
fn server(url: &str) -> McpServerConfig {
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: url.to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
environment_id: DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: true,
supports_parallel_tool_calls: true,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(7)),
tool_timeout_sec: Some(Duration::from_secs(11)),
default_tools_approval_mode: Some(AppToolApproval::Prompt),
enabled_tools: Some(vec!["read".to_string()]),
disabled_tools: Some(vec!["write".to_string()]),
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::from([(
"read".to_string(),
McpServerToolConfig {
approval_mode: Some(AppToolApproval::Approve),
},
)]),
}
}
fn plugin_source(plugin_id: &str) -> McpServerSource {
McpServerSource::Plugin {
plugin_id: plugin_id.to_string(),
}
}
fn compatibility_source(id: &str) -> McpServerSource {
McpServerSource::Compatibility { id: id.to_string() }
}
fn extension_source(id: &str) -> McpServerSource {
McpServerSource::Extension { id: id.to_string() }
}
fn register(source: McpServerSource) -> McpServerConflictAction {
McpServerConflictAction::Register(source)
}
fn remove(source: McpServerSource) -> McpServerConflictAction {
McpServerConflictAction::Remove(source)
}
#[test]
fn source_precedence_preserves_the_winning_registration() {
let extension = server("https://extension.example/mcp");
let mut plugin = server("https://plugin.example/mcp");
plugin.enabled = false;
let mut builder = ResolvedMcpCatalog::builder();
builder.register(McpServerRegistration::from_extension(
"docs".to_string(),
"hosted",
/*contribution_order*/ 0,
extension.clone(),
));
builder.register(McpServerRegistration::from_plugin(
"docs".to_string(),
"plugin@test".to_string(),
/*plugin_order*/ 0,
plugin,
));
builder.register(McpServerRegistration::from_plugin(
"docs".to_string(),
"other-plugin@test".to_string(),
/*plugin_order*/ 1,
server("https://other-plugin.example/mcp"),
));
builder.register(McpServerRegistration::from_compatibility(
"docs".to_string(),
"legacy",
server("https://compatibility.example/mcp"),
));
builder.register(McpServerRegistration::from_config(
"docs".to_string(),
server("https://config.example/mcp"),
));
let catalog = builder.build();
let resolved = catalog.server("docs").expect("resolved server");
assert_eq!(
resolved.source(),
&McpServerSource::Extension {
id: "hosted".to_string(),
}
);
assert_eq!(resolved.config(), &extension);
assert!(catalog.plugin_ids_by_server_name().is_empty());
assert_eq!(
catalog.conflicts(),
&[McpServerConflict {
name: "docs".to_string(),
outcome: register(extension_source("hosted")),
contenders: vec![
register(plugin_source("other-plugin@test")),
register(plugin_source("plugin@test")),
],
}]
);
}
#[test]
fn disabled_veto_only_disables_the_winning_registration() {
let extension = server("https://extension.example/mcp");
let mut expected = extension.clone();
expected.enabled = false;
let mut builder = ResolvedMcpCatalog::builder();
builder.register(McpServerRegistration::from_extension(
"docs".to_string(),
"hosted",
/*contribution_order*/ 0,
extension,
));
builder.disable("docs".to_string());
let actual = builder
.build()
.server("docs")
.expect("resolved server")
.config()
.clone();
assert_eq!(actual, expected);
}
#[test]
fn disabled_winner_remains_a_veto_when_the_catalog_is_extended() {
let mut disabled = server("https://config.example/mcp");
disabled.enabled = false;
let mut expected = server("https://extension.example/mcp");
expected.enabled = false;
let mut builder = ResolvedMcpCatalog::builder();
builder.register(McpServerRegistration::from_config(
"docs".to_string(),
disabled,
));
let mut builder = builder.build().to_builder();
builder.register(McpServerRegistration::from_extension(
"docs".to_string(),
"hosted",
/*contribution_order*/ 0,
server("https://extension.example/mcp"),
));
let resolved = builder.build();
assert_eq!(
resolved.server("docs"),
Some(&super::ResolvedMcpServer {
source: extension_source("hosted"),
config: expected,
})
);
}
#[test]
fn earlier_plugin_wins_with_an_explicit_conflict() {
let mut builder = ResolvedMcpCatalog::builder();
builder.register(McpServerRegistration::from_plugin(
"docs".to_string(),
"alpha@test".to_string(),
/*plugin_order*/ 0,
server("https://alpha.example/mcp"),
));
builder.register(McpServerRegistration::from_plugin(
"docs".to_string(),
"beta@test".to_string(),
/*plugin_order*/ 1,
server("https://beta.example/mcp"),
));
let catalog = builder.build();
assert_eq!(
catalog.plugin_ids_by_server_name(),
HashMap::from([("docs".to_string(), "alpha@test".to_string())])
);
assert_eq!(
catalog.conflicts(),
&[McpServerConflict {
name: "docs".to_string(),
outcome: register(plugin_source("alpha@test")),
contenders: vec![
register(plugin_source("beta@test")),
register(plugin_source("alpha@test")),
],
}]
);
}
#[test]
fn equal_precedence_uses_insertion_order_not_source_identity() {
let mut builder = ResolvedMcpCatalog::builder();
builder.register(McpServerRegistration::from_compatibility(
"docs".to_string(),
"z-first",
server("https://first.example/mcp"),
));
builder.register(McpServerRegistration::from_compatibility(
"docs".to_string(),
"a-second",
server("https://second.example/mcp"),
));
let catalog = builder.build();
assert_eq!(
catalog.server("docs"),
Some(&super::ResolvedMcpServer {
source: compatibility_source("a-second"),
config: server("https://second.example/mcp"),
})
);
let mut builder = catalog.to_builder();
builder.remove_compatibility("docs".to_string(), "remove-last");
let catalog = builder.build();
assert_eq!(catalog.server("docs"), None);
assert_eq!(
catalog.conflicts(),
&[McpServerConflict {
name: "docs".to_string(),
outcome: remove(compatibility_source("remove-last")),
contenders: vec![
register(compatibility_source("z-first")),
register(compatibility_source("a-second")),
remove(compatibility_source("remove-last")),
],
}]
);
}
+9
View File
@@ -11,6 +11,14 @@ pub use runtime::McpRuntimeContext;
pub use runtime::SandboxState;
pub use tools::ToolInfo;
pub use catalog::McpCatalogBuilder;
pub use catalog::McpServerConflict;
pub use catalog::McpServerConflictAction;
pub use catalog::McpServerRegistration;
pub use catalog::McpServerSource;
pub use catalog::ResolvedMcpCatalog;
pub use catalog::ResolvedMcpServer;
pub use mcp::CODEX_APPS_MCP_SERVER_NAME;
pub use mcp::McpConfig;
pub use mcp::ToolPluginProvenance;
@@ -57,6 +65,7 @@ pub use mcp::qualified_mcp_tool_name_prefix;
pub use tools::declared_openai_file_input_param_names;
pub(crate) mod auth_elicitation;
mod catalog;
pub(crate) mod codex_apps;
pub(crate) mod connection_manager;
pub(crate) mod elicitation;
+9 -11
View File
@@ -37,6 +37,7 @@ use rmcp::model::ReadResourceResult;
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use crate::ResolvedMcpCatalog;
use crate::codex_apps::codex_apps_tools_cache_key;
use crate::connection_manager::McpConnectionManager;
use crate::runtime::McpRuntimeContext;
@@ -135,13 +136,8 @@ pub struct McpConfig {
pub prefix_mcp_tool_names: bool,
/// Client-side elicitation capabilities advertised during MCP initialization.
pub client_elicitation_capability: ElicitationCapability,
/// Materialized MCP servers keyed by server name.
///
/// A host may add compatibility built-ins and extension overlays before
/// calling runtime entry points in this crate.
pub configured_mcp_servers: HashMap<String, McpServerConfig>,
/// Winning plugin owner for plugin-provided MCP servers, keyed by server name.
pub plugin_ids_by_mcp_server_name: HashMap<String, String>,
/// Resolved MCP registrations keyed by logical server name.
pub mcp_server_catalog: ResolvedMcpCatalog,
/// Plugin metadata used to attribute MCP tools/connectors to plugin display names.
pub plugin_capability_summaries: Vec<PluginCapabilitySummary>,
}
@@ -176,6 +172,7 @@ impl ToolPluginProvenance {
fn from_config(config: &McpConfig) -> Self {
let mut tool_plugin_provenance = Self::default();
let plugin_ids_by_mcp_server_name = config.mcp_server_catalog.plugin_ids_by_server_name();
for plugin in &config.plugin_capability_summaries {
for connector_id in &plugin.app_connector_ids {
tool_plugin_provenance
@@ -185,7 +182,9 @@ impl ToolPluginProvenance {
.push(plugin.display_name.clone());
}
for server_name in &plugin.mcp_server_names {
for server_name in plugin.mcp_server_names.iter().filter(|server_name| {
plugin_ids_by_mcp_server_name.get(*server_name) == Some(&plugin.config_name)
}) {
tool_plugin_provenance
.plugin_display_names_by_mcp_server_name
.entry(server_name.clone())
@@ -206,8 +205,7 @@ impl ToolPluginProvenance {
plugin_names.sort_unstable();
plugin_names.dedup();
}
tool_plugin_provenance.plugin_ids_by_mcp_server_name =
config.plugin_ids_by_mcp_server_name.clone();
tool_plugin_provenance.plugin_ids_by_mcp_server_name = plugin_ids_by_mcp_server_name;
tool_plugin_provenance
}
@@ -218,7 +216,7 @@ pub fn host_owned_codex_apps_enabled(config: &McpConfig, auth: Option<&CodexAuth
}
pub fn configured_mcp_servers(config: &McpConfig) -> HashMap<String, McpServerConfig> {
config.configured_mcp_servers.clone()
config.mcp_server_catalog.configured_servers()
}
pub fn effective_mcp_servers(
+24 -14
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::McpServerRegistration;
use codex_config::Constrained;
use codex_config::types::AppToolApproval;
use codex_login::CodexAuth;
@@ -28,8 +29,7 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
apps_enabled: false,
prefix_mcp_tool_names: true,
client_elicitation_capability: ElicitationCapability::default(),
configured_mcp_servers: HashMap::new(),
plugin_ids_by_mcp_server_name: HashMap::new(),
mcp_server_catalog: ResolvedMcpCatalog::default(),
plugin_capability_summaries: Vec::new(),
}
}
@@ -122,16 +122,24 @@ fn mcp_prompt_auto_approval_rejects_auto_mode_in_default_permission_mode() {
#[test]
fn tool_plugin_provenance_collects_app_and_mcp_sources() {
let mut config = test_mcp_config(PathBuf::new());
config.plugin_ids_by_mcp_server_name =
HashMap::from([("alpha".to_string(), "alpha@test".to_string())]);
let mut catalog = ResolvedMcpCatalog::builder();
catalog.register(McpServerRegistration::from_plugin(
"alpha".to_string(),
"alpha@test".to_string(),
/*plugin_order*/ 0,
codex_apps_mcp_server_config("https://alpha.example", /*apps_mcp_product_sku*/ None),
));
config.mcp_server_catalog = catalog.build();
config.plugin_capability_summaries = vec![
PluginCapabilitySummary {
config_name: "alpha@test".to_string(),
display_name: "alpha-plugin".to_string(),
app_connector_ids: vec![AppConnectorId("connector_example".to_string())],
mcp_server_names: vec!["alpha".to_string()],
..PluginCapabilitySummary::default()
},
PluginCapabilitySummary {
config_name: "beta@test".to_string(),
display_name: "beta-plugin".to_string(),
app_connector_ids: vec![
AppConnectorId("connector_example".to_string()),
@@ -156,10 +164,10 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() {
vec!["beta-plugin".to_string()],
),
]),
plugin_display_names_by_mcp_server_name: HashMap::from([
("alpha".to_string(), vec!["alpha-plugin".to_string()]),
("beta".to_string(), vec!["beta-plugin".to_string()]),
]),
plugin_display_names_by_mcp_server_name: HashMap::from([(
"alpha".to_string(),
vec!["alpha-plugin".to_string()],
)]),
plugin_ids_by_mcp_server_name: HashMap::from([(
"alpha".to_string(),
"alpha@test".to_string(),
@@ -235,7 +243,8 @@ async fn effective_mcp_servers_preserve_runtime_servers() {
config.apps_enabled = true;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
config.configured_mcp_servers.insert(
let mut catalog = ResolvedMcpCatalog::builder();
catalog.register(McpServerRegistration::from_config(
"sample".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
@@ -259,8 +268,8 @@ async fn effective_mcp_servers_preserve_runtime_servers() {
oauth_resource: None,
tools: HashMap::new(),
},
);
config.configured_mcp_servers.insert(
));
catalog.register(McpServerRegistration::from_config(
"docs".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
@@ -284,14 +293,15 @@ async fn effective_mcp_servers_preserve_runtime_servers() {
oauth_resource: None,
tools: HashMap::new(),
},
);
config.configured_mcp_servers.insert(
));
catalog.register(McpServerRegistration::from_config(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
codex_apps_mcp_server_config(
&config.chatgpt_base_url,
config.apps_mcp_product_sku.as_deref(),
),
);
));
config.mcp_server_catalog = catalog.build();
let effective = effective_mcp_servers(&config, Some(&auth));