[mcp] Fix plugin MCP approval policy. (#19537)

Plugin MCP servers are loaded from plugin manifests rather than
top-level `[mcp_servers]`, so their tool approval preferences need to be
stored and applied through the owning plugin config. Without this,
choosing "Always allow" for a plugin MCP tool could write a preference
that was not reliably used on later tool calls.

## Summary
- Add plugin-scoped MCP policy config under
`plugins.<plugin>.mcp_servers`, including server enablement, tool
allow/deny lists, server defaults, and per-tool approval modes.
- Overlay plugin MCP policy onto manifest-provided server configs when
plugins are loaded.
- Route persistent "Always allow" writes for plugin MCP tools back to
the owning `plugins.<plugin>.mcp_servers.<server>.tools.<tool>` config
entry.
- Reload user config after persisting an approval and make the plugin
load cache config-aware so stale plugin MCP policy is not reused after
`config.toml` changes.
- Regenerate the config schema and add coverage for plugin MCP policy
loading, approval lookup, persistence, and stale-cache prevention.

## Testing
- `cargo test -p codex-config`
- `cargo test -p codex-core-plugins`
- `cargo test -p codex-core --lib plugin_mcp`
This commit is contained in:
Matthew Zeng
2026-04-29 15:40:03 -07:00
committed by GitHub
parent 4241df4d79
commit e20391e567
15 changed files with 919 additions and 28 deletions
+25 -6
View File
@@ -7,6 +7,7 @@ use crate::config::edit::ConfigEditsBuilder;
use codex_analytics::AnalyticsEventsClient;
use codex_config::ConfigLayerStack;
use codex_config::types::PluginConfig;
use codex_config::version_for_toml;
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core_plugins::installed_marketplaces::installed_marketplace_roots_from_layer_stack;
use codex_core_plugins::loader::configured_curated_plugin_ids_from_codex_home;
@@ -359,7 +360,7 @@ pub struct PluginsManager {
featured_plugin_ids_cache: RwLock<Option<CachedFeaturedPluginIds>>,
configured_marketplace_upgrade_state: RwLock<ConfiguredMarketplaceUpgradeState>,
non_curated_cache_refresh_state: RwLock<NonCuratedCacheRefreshState>,
cached_enabled_outcome: RwLock<Option<PluginLoadOutcome>>,
cached_enabled_outcome: RwLock<Option<CachedPluginLoadOutcome>>,
// TODO(remote plugins): reset this cache when ChatGPT auth/account state changes so stale
// remote installed state cannot remain effective for a different account.
remote_installed_plugins_cache: RwLock<Option<Vec<RemoteInstalledPlugin>>>,
@@ -369,6 +370,12 @@ pub struct PluginsManager {
analytics_events_client: RwLock<Option<AnalyticsEventsClient>>,
}
#[derive(Clone)]
struct CachedPluginLoadOutcome {
config_version: String,
outcome: PluginLoadOutcome,
}
impl PluginsManager {
pub fn new(codex_home: PathBuf) -> Self {
Self::new_with_restriction_product(codex_home, Some(Product::Codex))
@@ -436,7 +443,9 @@ impl PluginsManager {
return PluginLoadOutcome::default();
}
if !force_reload && let Some(outcome) = self.cached_enabled_outcome() {
let config_version = version_for_toml(&config.config_layer_stack.effective_config());
if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&config_version) {
return outcome;
}
@@ -452,7 +461,10 @@ impl PluginsManager {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
*cache = Some(outcome.clone());
*cache = Some(CachedPluginLoadOutcome {
config_version,
outcome: outcome.clone(),
});
outcome
}
@@ -492,10 +504,17 @@ impl PluginsManager {
.effective_skill_roots()
}
fn cached_enabled_outcome(&self) -> Option<PluginLoadOutcome> {
fn cached_enabled_outcome(&self, config_version: &str) -> Option<PluginLoadOutcome> {
match self.cached_enabled_outcome.read() {
Ok(cache) => cache.clone(),
Err(err) => err.into_inner().clone(),
Ok(cache) => cache
.as_ref()
.filter(|cached| cached.config_version == config_version)
.map(|cached| cached.outcome.clone()),
Err(err) => err
.into_inner()
.as_ref()
.filter(|cached| cached.config_version == config_version)
.map(|cached| cached.outcome.clone()),
}
}
@@ -9,11 +9,13 @@ use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated
use crate::plugins::test_support::write_file;
use crate::plugins::test_support::write_openai_curated_marketplace;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::AppToolApproval;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStack;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::McpServerConfig;
use codex_config::McpServerToolConfig;
use codex_config::types::McpServerTransportConfig;
use codex_core_plugins::installed_marketplaces::marketplace_install_root;
use codex_core_plugins::loader::load_plugins_from_layer_stack;
@@ -247,6 +249,74 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
);
}
#[tokio::test]
async fn load_plugins_applies_plugin_mcp_server_policy() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "sample"
}"#,
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://sample.example/mcp",
"default_tools_approval_mode": "prompt",
"enabled_tools": ["read", "search"],
"tools": {
"search": { "approval_mode": "prompt" }
}
}
}
}"#,
);
let config_toml = r#"
[features]
plugins = true
[plugins."sample@test"]
enabled = true
[plugins."sample@test".mcp_servers.sample]
enabled = false
default_tools_approval_mode = "approve"
enabled_tools = ["search"]
disabled_tools = ["delete"]
[plugins."sample@test".mcp_servers.sample.tools.search]
approval_mode = "approve"
"#;
let outcome = load_plugins_from_config(config_toml, codex_home.path()).await;
let server = outcome.plugins()[0]
.mcp_servers
.get("sample")
.expect("sample server");
assert!(!server.enabled);
assert_eq!(
server.default_tools_approval_mode,
Some(AppToolApproval::Approve)
);
assert_eq!(server.enabled_tools, Some(vec!["search".to_string()]));
assert_eq!(server.disabled_tools, Some(vec!["delete".to_string()]));
assert_eq!(
server.tools.get("search"),
Some(&McpServerToolConfig {
approval_mode: Some(AppToolApproval::Approve),
})
);
}
#[tokio::test]
async fn remote_installed_cache_adds_plugin_skill_roots_without_marketplace_config() {
let codex_home = TempDir::new().unwrap();