mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user