Add selected-plugin precedence and attribution to the MCP catalog (#27884)

## Why

**In short:** this PR resolves already-discovered MCP registrations. It
does not read selected plugins or discover their MCP servers.

The resolved MCP catalog currently builds config and auto-discovered
plugin registrations before runtime contributors are applied. A
thread-selected plugin needs a distinct precedence tier in that same
initial resolution pass: otherwise a disabled lower-precedence winner
can leave stale name-level state behind, and the winning MCP tools
cannot be attributed to the selected package reliably.

This PR adds that catalog boundary before executor discovery is
connected.

## What changed

- Added an explicit selected-plugin registration tier between
auto-discovered plugins and explicit config.
- Collected selected-plugin contributions before the initial catalog
build, while leaving compatibility and generic extension overlays in
their existing runtime phase.
- Retained the winning plugin ID and display name directly on
plugin-owned catalog registrations.
- Derived MCP tool provenance from the winning catalog entry instead of
joining against local-only plugin summaries.
- Retained the winning selected server's tool approval policy in the
running connection manager, so a selected registration cannot inherit
approval behavior from a losing local plugin.
- Kept remembered approval session-scoped for selected plugins until
there is an authority-aware persistence contract; Codex will not write
approval back to an unrelated local plugin.
- Preserved existing name-level disabled vetoes for discovered plugins
and config, while keeping a selected package's own disabled registration
scoped to that registration.
- Preserved deterministic selection order and existing config,
compatibility, and extension precedence.

The resulting order is:

```text
auto-discovered plugin
  < selected plugin
  < explicit config
  < compatibility registration
  < extension overlay
```

## Behavior and scope

This is a catalog and provenance change only. No production host
contributes selected-plugin MCP registrations yet, so existing local MCP
behavior remains unchanged.

The stacked follow-up, #27870, installs the executor plugin provider
that produces these registrations. App-server activation remains a
separate final step.

## Verification

Focused tests cover precedence, deterministic selected-plugin conflicts,
disabled-veto behavior across catalog phases, managed requirements
before selected-plugin resolution, winning-server approval policy, and
attribution when local and selected packages share an ID or server name.
CI owns execution of the test suite.
This commit is contained in:
jif authored and GitHub committed 2026-06-15 11:10:51 +02:00
1 parent dfd03ea01b
commit c3a479620f
16 files changed
+585 -89

No files matched your search

+31 -14
View File
@@ -12,6 +12,7 @@ pub use auth::should_retry_without_scopes;
pub(crate) mod auth;
use std::collections::HashMap;
use std::collections::HashSet;
use std::env;
use std::path::PathBuf;
use std::time::Duration;
@@ -141,7 +142,8 @@ pub struct McpConfig {
pub client_elicitation_capability: ElicitationCapability,
/// 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.
/// Plugin metadata used to attribute connector tools to plugin display names.
/// MCP registrations retain their own package attribution in the catalog.
pub plugin_capability_summaries: Vec<PluginCapabilitySummary>,
}
@@ -150,6 +152,7 @@ pub struct ToolPluginProvenance {
plugin_display_names_by_connector_id: HashMap<String, Vec<String>>,
plugin_display_names_by_mcp_server_name: HashMap<String, Vec<String>>,
plugin_ids_by_mcp_server_name: HashMap<String, String>,
selected_plugin_mcp_server_names: HashSet<String>,
}
impl ToolPluginProvenance {
@@ -173,9 +176,12 @@ impl ToolPluginProvenance {
.map(String::as_str)
}
pub(crate) fn is_selected_plugin_mcp_server(&self, server_name: &str) -> bool {
self.selected_plugin_mcp_server_names.contains(server_name)
}
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
@@ -184,18 +190,31 @@ impl ToolPluginProvenance {
.or_default()
.push(plugin.display_name.clone());
}
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())
.or_default()
.push(plugin.display_name.clone());
}
}
for (server_name, attribution) in config
.mcp_server_catalog
.plugin_attributions_by_server_name()
{
tool_plugin_provenance
.plugin_display_names_by_mcp_server_name
.insert(
server_name.clone(),
vec![attribution.display_name().to_string()],
);
tool_plugin_provenance
.plugin_ids_by_mcp_server_name
.insert(server_name, attribution.plugin_id().to_string());
}
tool_plugin_provenance
.selected_plugin_mcp_server_names
.extend(
config
.mcp_server_catalog
.selected_plugin_server_names()
.map(str::to_string),
);
for plugin_names in tool_plugin_provenance
.plugin_display_names_by_connector_id
.values_mut()
@@ -208,8 +227,6 @@ impl ToolPluginProvenance {
plugin_names.sort_unstable();
plugin_names.dedup();
}
tool_plugin_provenance.plugin_ids_by_mcp_server_name = plugin_ids_by_mcp_server_name;
tool_plugin_provenance
}
}
+45 -1
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::McpPluginAttribution;
use crate::McpServerRegistration;
use codex_config::Constrained;
use codex_config::types::AppToolApproval;
@@ -13,6 +14,7 @@ use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GranularApprovalConfig;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
@@ -127,7 +129,7 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() {
let mut catalog = ResolvedMcpCatalog::builder();
catalog.register(McpServerRegistration::from_plugin(
"alpha".to_string(),
"alpha@test".to_string(),
McpPluginAttribution::new("alpha@test".to_string(), "alpha-plugin".to_string()),
/*plugin_order*/ 0,
codex_apps_mcp_server_config("https://alpha.example", /*apps_mcp_product_sku*/ None),
));
@@ -174,6 +176,7 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() {
"alpha".to_string(),
"alpha@test".to_string(),
)]),
selected_plugin_mcp_server_names: HashSet::new(),
}
);
assert_eq!(
@@ -183,6 +186,47 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() {
assert_eq!(provenance.plugin_id_for_mcp_server_name("beta"), None);
}
#[test]
fn selected_mcp_attribution_does_not_join_an_unrelated_local_summary() {
let mut config = test_mcp_config(PathBuf::new());
let mut catalog = ResolvedMcpCatalog::builder();
catalog.register(McpServerRegistration::from_selected_plugin(
"github".to_string(),
McpPluginAttribution::new(
"shared-plugin-id".to_string(),
"Executor GitHub".to_string(),
),
/*selection_order*/ 0,
codex_apps_mcp_server_config("https://github.example", /*apps_mcp_product_sku*/ None),
));
config.mcp_server_catalog = catalog.build();
config.plugin_capability_summaries = vec![PluginCapabilitySummary {
config_name: "shared-plugin-id".to_string(),
display_name: "Local GitHub".to_string(),
mcp_server_names: vec!["github".to_string()],
..PluginCapabilitySummary::default()
}];
let provenance = tool_plugin_provenance(&config);
assert_eq!(
provenance,
ToolPluginProvenance {
plugin_display_names_by_connector_id: HashMap::new(),
plugin_display_names_by_mcp_server_name: HashMap::from([(
"github".to_string(),
vec!["Executor GitHub".to_string()],
)]),
plugin_ids_by_mcp_server_name: HashMap::from([(
"github".to_string(),
"shared-plugin-id".to_string(),
)]),
selected_plugin_mcp_server_names: HashSet::from(["github".to_string()]),
}
);
assert!(provenance.is_selected_plugin_mcp_server("github"));
}
#[test]
fn codex_apps_mcp_url_for_base_url_keeps_existing_paths() {
assert_eq!(