mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
16 files changed
+585
-89
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in new issue
Block a user