[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 authored and GitHub committed 2026-04-29 15:40:03 -07:00
1 parent 4241df4d79
commit e20391e567
15 files changed
+919 -28

No files matched your search

@@ -780,6 +780,7 @@ allowed_approval_policies = ["on-request"]
feature_requirements: None,
hooks: None,
mcp_servers: None,
plugins: None,
apps: None,
rules: None,
enforce_residency: None,
@@ -836,6 +837,7 @@ allowed_approval_policies = ["on-request"]
feature_requirements: None,
hooks: None,
mcp_servers: None,
plugins: None,
apps: None,
rules: None,
enforce_residency: None,
@@ -1043,6 +1045,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
feature_requirements: None,
hooks: None,
mcp_servers: None,
plugins: None,
apps: None,
rules: None,
enforce_residency: None,
+254
View File
@@ -2404,6 +2404,258 @@ fn filter_mcp_servers_by_allowlist_blocks_all_when_empty() {
);
}
#[test]
fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() {
const MATCHED_SERVER: &str = "matched-should-allow";
const MISMATCHED_SERVER: &str = "mismatched-should-disable";
const UNLISTED_SERVER: &str = "unlisted-should-disable";
const GOOD_CMD: &str = "good-cmd";
let mut servers = HashMap::from([
(MATCHED_SERVER.to_string(), stdio_mcp(GOOD_CMD)),
(MISMATCHED_SERVER.to_string(), stdio_mcp("bad-cmd")),
(
UNLISTED_SERVER.to_string(),
http_mcp("https://example.com/mcp"),
),
]);
let source = RequirementSource::CloudRequirements;
let requirements = Sourced::new(
BTreeMap::from([(
"sample@test".to_string(),
codex_config::PluginRequirementsToml {
mcp_servers: Some(BTreeMap::from([
(
MATCHED_SERVER.to_string(),
McpServerRequirement {
identity: McpServerIdentity::Command {
command: GOOD_CMD.to_string(),
},
},
),
(
MISMATCHED_SERVER.to_string(),
McpServerRequirement {
identity: McpServerIdentity::Command {
command: GOOD_CMD.to_string(),
},
},
),
])),
},
)]),
source.clone(),
);
filter_plugin_mcp_servers_by_requirements("sample@test", &mut servers, Some(&requirements));
let reason = Some(McpServerDisabledReason::Requirements { source });
assert_eq!(
servers
.iter()
.map(|(name, server)| (
name.clone(),
(server.enabled, server.disabled_reason.clone())
))
.collect::<HashMap<String, (bool, Option<McpServerDisabledReason>)>>(),
HashMap::from([
(MATCHED_SERVER.to_string(), (true, None)),
(MISMATCHED_SERVER.to_string(), (false, reason.clone())),
(UNLISTED_SERVER.to_string(), (false, reason)),
])
);
}
#[test]
fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() {
let mut servers = HashMap::from([("server-a".to_string(), stdio_mcp("cmd-a"))]);
let source = RequirementSource::CloudRequirements;
let requirements = Sourced::new(
BTreeMap::from([(
"other@test".to_string(),
codex_config::PluginRequirementsToml {
mcp_servers: Some(BTreeMap::from([(
"server-a".to_string(),
McpServerRequirement {
identity: McpServerIdentity::Command {
command: "cmd-a".to_string(),
},
},
)])),
},
)]),
source.clone(),
);
filter_plugin_mcp_servers_by_requirements("sample@test", &mut servers, Some(&requirements));
assert_eq!(
servers
.iter()
.map(|(name, server)| (
name.clone(),
(server.enabled, server.disabled_reason.clone())
))
.collect::<HashMap<String, (bool, Option<McpServerDisabledReason>)>>(),
HashMap::from([(
"server-a".to_string(),
(
false,
Some(McpServerDisabledReason::Requirements { source })
)
)])
);
}
#[tokio::test]
async fn to_mcp_config_applies_plugin_mcp_cloud_requirements() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)?;
std::fs::write(
plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://sample.example/mcp"
},
"unlisted": {
"type": "http",
"url": "https://unlisted.example/mcp"
}
}
}"#,
)?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[features]
plugins = true
[plugins."sample@test"]
enabled = true
"#,
)?;
let requirements = codex_config::ConfigRequirementsToml {
plugins: Some(BTreeMap::from([(
"sample@test".to_string(),
codex_config::PluginRequirementsToml {
mcp_servers: Some(BTreeMap::from([(
"sample".to_string(),
McpServerRequirement {
identity: McpServerIdentity::Url {
url: "https://sample.example/mcp".to_string(),
},
},
)])),
},
)])),
..Default::default()
};
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async move {
Ok(Some(requirements))
}))
.build()
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert_eq!(
mcp_config
.configured_mcp_servers
.get("sample")
.map(|server| (server.enabled, server.disabled_reason.clone())),
Some((true, None))
);
assert_eq!(
mcp_config
.configured_mcp_servers
.get("unlisted")
.map(|server| (server.enabled, server.disabled_reason.clone())),
Some((
false,
Some(McpServerDisabledReason::Requirements {
source: RequirementSource::CloudRequirements,
})
))
);
Ok(())
}
#[tokio::test]
async fn to_mcp_config_empty_mcp_requirements_disable_plugin_mcps() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)?;
std::fs::write(
plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://sample.example/mcp"
}
}
}"#,
)?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[features]
plugins = true
[plugins."sample@test"]
enabled = true
"#,
)?;
let requirements = codex_config::ConfigRequirementsToml {
mcp_servers: Some(BTreeMap::new()),
..Default::default()
};
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async move {
Ok(Some(requirements))
}))
.build()
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert_eq!(
mcp_config
.configured_mcp_servers
.get("sample")
.map(|server| (server.enabled, server.disabled_reason.clone())),
Some((
false,
Some(McpServerDisabledReason::Requirements {
source: RequirementSource::CloudRequirements,
})
))
);
Ok(())
}
#[tokio::test]
async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
@@ -6499,6 +6751,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
feature_requirements: None,
hooks: None,
mcp_servers: None,
plugins: None,
apps: None,
rules: None,
enforce_residency: None,
@@ -7210,6 +7463,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s
feature_requirements: None,
hooks: None,
mcp_servers: None,
plugins: None,
apps: None,
rules: None,
enforce_residency: None,
+51 -2
View File
@@ -18,6 +18,7 @@ use codex_config::FeatureRequirementsToml;
use codex_config::LoaderOverrides;
use codex_config::McpServerIdentity;
use codex_config::McpServerRequirement;
use codex_config::PluginRequirementsToml;
use codex_config::ResidencyRequirement;
use codex_config::SandboxModeRequirement;
use codex_config::Sourced;
@@ -975,8 +976,26 @@ impl Config {
) -> McpConfig {
let loaded_plugins = plugins_manager.plugins_for_config(self).await;
let mut configured_mcp_servers = self.mcp_servers.get().clone();
for (name, plugin_server) in loaded_plugins.effective_mcp_servers() {
configured_mcp_servers.entry(name).or_insert(plugin_server);
for plugin in loaded_plugins
.plugins()
.iter()
.filter(|plugin| plugin.is_active())
{
let mut plugin_mcp_servers = plugin.mcp_servers.clone();
filter_plugin_mcp_servers_by_requirements(
&plugin.config_name,
&mut plugin_mcp_servers,
self.config_layer_stack.requirements().plugins.as_ref(),
);
for (name, plugin_server) in plugin_mcp_servers {
configured_mcp_servers.entry(name).or_insert(plugin_server);
}
}
if let Some(mcp_requirements) = self.config_layer_stack.requirements().mcp_servers.as_ref()
&& mcp_requirements.value.is_empty()
{
// A present empty allowlist bans all MCPs, including plugin MCPs merged above.
filter_mcp_servers_by_requirements(&mut configured_mcp_servers, Some(mcp_requirements));
}
McpConfig {
@@ -1186,6 +1205,35 @@ fn filter_mcp_servers_by_requirements(
}
}
fn filter_plugin_mcp_servers_by_requirements(
plugin_config_name: &str,
mcp_servers: &mut HashMap<String, McpServerConfig>,
plugin_requirements: Option<&Sourced<BTreeMap<String, PluginRequirementsToml>>>,
) {
let Some(requirements) = plugin_requirements else {
return;
};
let source = requirements.source.clone();
let plugin_mcp_requirements = requirements
.value
.get(plugin_config_name)
.and_then(|plugin| plugin.mcp_servers.as_ref());
for (name, server) in mcp_servers.iter_mut() {
let allowed = plugin_mcp_requirements
.and_then(|mcp_requirements| mcp_requirements.get(name))
.is_some_and(|requirement| mcp_server_matches_requirement(requirement, server));
if allowed {
server.disabled_reason = None;
} else {
server.enabled = false;
server.disabled_reason = Some(McpServerDisabledReason::Requirements {
source: source.clone(),
});
}
}
}
fn constrain_mcp_servers(
mcp_servers: HashMap<String, McpServerConfig>,
mcp_requirements: Option<&Sourced<BTreeMap<String, McpServerRequirement>>>,
@@ -1876,6 +1924,7 @@ impl Config {
feature_requirements,
managed_hooks: _,
mcp_servers,
plugins: _,
exec_policy: _,
enforce_residency,
network: network_requirements,
+96 -15
View File
@@ -141,7 +141,8 @@ pub(crate) async fn handle_mcp_tool_call(
let approval_mode = if server == CODEX_APPS_MCP_SERVER_NAME {
app_tool_policy.approval
} else {
custom_mcp_tool_approval_mode(turn_context.as_ref(), &server, &tool_name)
custom_mcp_tool_approval_mode(sess.as_ref(), turn_context.as_ref(), &server, &tool_name)
.await
};
if server == CODEX_APPS_MCP_SERVER_NAME && !app_tool_policy.enabled {
@@ -722,12 +723,13 @@ const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate";
const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri";
const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId";
fn custom_mcp_tool_approval_mode(
async fn custom_mcp_tool_approval_mode(
sess: &Session,
turn_context: &TurnContext,
server: &str,
tool_name: &str,
) -> AppToolApproval {
turn_context
let user_configured_mode = turn_context
.config
.config_layer_stack
.effective_config()
@@ -739,6 +741,28 @@ fn custom_mcp_tool_approval_mode(
})
.and_then(|servers| {
let server_config = servers.get(server)?;
Some(
server_config
.tools
.get(tool_name)
.and_then(|tool| tool.approval_mode)
.or(server_config.default_tools_approval_mode)
.unwrap_or_default(),
)
});
if let Some(user_configured_mode) = user_configured_mode {
return user_configured_mode;
}
sess.services
.plugins_manager
.plugins_for_config(turn_context.config.as_ref())
.await
.plugins()
.iter()
.filter(|plugin| plugin.is_active())
.find_map(|plugin| {
let server_config = plugin.mcp_servers.get(server)?;
server_config
.tools
.get(tool_name)
@@ -1717,7 +1741,7 @@ async fn maybe_persist_mcp_tool_approval(
persist_codex_app_tool_approval(&turn_context.config.codex_home, &connector_id, &tool_name)
.await
} else {
persist_custom_mcp_tool_approval(&turn_context.config, &key.server, &tool_name).await
persist_non_app_mcp_tool_approval(sess, &turn_context.config, &key.server, &tool_name).await
};
if let Err(err) = persist_result {
@@ -1755,24 +1779,81 @@ async fn persist_codex_app_tool_approval(
.await
}
#[cfg(test)]
async fn persist_custom_mcp_tool_approval(
config: &Config,
server: &str,
tool_name: &str,
) -> anyhow::Result<()> {
let config_folder = if let Some(project_config_folder) =
project_mcp_tool_approval_config_folder(config, server)
{
project_config_folder
} else {
let servers = load_global_mcp_servers(&config.codex_home).await?;
if !servers.contains_key(server) {
anyhow::bail!("MCP server `{server}` is not configured in config.toml");
}
config.codex_home.clone()
let Some(config_folder) = custom_mcp_tool_approval_config_folder(config, server).await? else {
anyhow::bail!("MCP server `{server}` is not configured in config.toml");
};
ConfigEditsBuilder::new(&config_folder)
persist_custom_mcp_tool_approval_at(&config_folder, server, tool_name).await
}
async fn persist_non_app_mcp_tool_approval(
sess: &Session,
config: &Config,
server: &str,
tool_name: &str,
) -> anyhow::Result<()> {
if let Some(config_folder) = custom_mcp_tool_approval_config_folder(config, server).await? {
return persist_custom_mcp_tool_approval_at(&config_folder, server, tool_name).await;
}
let plugin_config_name = sess
.services
.plugins_manager
.plugins_for_config(config)
.await
.plugins()
.iter()
.filter(|plugin| plugin.is_active())
.find(|plugin| plugin.mcp_servers.contains_key(server))
.map(|plugin| plugin.config_name.clone());
if let Some(plugin_config_name) = plugin_config_name {
return ConfigEditsBuilder::new(&config.codex_home)
.with_edits([ConfigEdit::SetPath {
segments: vec![
"plugins".to_string(),
plugin_config_name,
"mcp_servers".to_string(),
server.to_string(),
"tools".to_string(),
tool_name.to_string(),
"approval_mode".to_string(),
],
value: value("approve"),
}])
.apply()
.await;
}
anyhow::bail!("MCP server `{server}` is not configured in config.toml or an enabled plugin")
}
async fn custom_mcp_tool_approval_config_folder(
config: &Config,
server: &str,
) -> anyhow::Result<Option<AbsolutePathBuf>> {
if let Some(project_config_folder) = project_mcp_tool_approval_config_folder(config, server) {
return Ok(Some(project_config_folder));
}
let servers = load_global_mcp_servers(&config.codex_home).await?;
Ok(servers
.contains_key(server)
.then(|| config.codex_home.clone()))
}
async fn persist_custom_mcp_tool_approval_at(
config_folder: &AbsolutePathBuf,
server: &str,
tool_name: &str,
) -> anyhow::Result<()> {
ConfigEditsBuilder::new(config_folder)
.with_edits([ConfigEdit::SetPath {
segments: vec![
"mcp_servers".to_string(),
+171 -4
View File
@@ -69,6 +69,30 @@ fn approval_metadata(
}
}
fn write_sample_plugin_mcp(codex_home: &std::path::Path) {
let plugin_root = codex_home.join("plugins/cache/test/sample/local");
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "sample"
}"#,
)
.expect("write plugin manifest");
std::fs::write(
plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://sample.example/mcp"
}
}
}"#,
)
.expect("write plugin mcp config");
}
fn prompt_options(
allow_session_remember: bool,
allow_persistent_approval: bool,
@@ -1486,23 +1510,116 @@ approval_mode = "prompt"
.build()
.await
.expect("load config");
let (_session, mut turn_context) = make_session_and_context().await;
let (session, mut turn_context) = make_session_and_context().await;
turn_context.config = Arc::new(config);
assert_eq!(
custom_mcp_tool_approval_mode(&turn_context, "docs", "read"),
custom_mcp_tool_approval_mode(&session, &turn_context, "docs", "read").await,
AppToolApproval::Approve
);
assert_eq!(
custom_mcp_tool_approval_mode(&turn_context, "docs", "search"),
custom_mcp_tool_approval_mode(&session, &turn_context, "docs", "search").await,
AppToolApproval::Prompt
);
assert_eq!(
custom_mcp_tool_approval_mode(&turn_context, "unknown", "search"),
custom_mcp_tool_approval_mode(&session, &turn_context, "unknown", "search").await,
AppToolApproval::Auto
);
}
#[tokio::test]
async fn custom_mcp_tool_approval_mode_uses_plugin_mcp_policy() {
let (session, mut turn_context) = make_session_and_context().await;
let codex_home = session.codex_home().await;
write_sample_plugin_mcp(codex_home.as_path());
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"
[features]
plugins = true
[plugins."sample@test"]
enabled = true
[plugins."sample@test".mcp_servers.sample]
default_tools_approval_mode = "prompt"
[plugins."sample@test".mcp_servers.sample.tools.search]
approval_mode = "approve"
"#,
)
.expect("seed config");
let config = ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.build()
.await
.expect("load config");
turn_context.config = Arc::new(config);
session.services.plugins_manager.clear_cache();
assert_eq!(
custom_mcp_tool_approval_mode(&session, &turn_context, "sample", "read").await,
AppToolApproval::Prompt
);
assert_eq!(
custom_mcp_tool_approval_mode(&session, &turn_context, "sample", "search").await,
AppToolApproval::Approve
);
}
#[tokio::test]
async fn custom_mcp_tool_approval_mode_uses_updated_plugin_mcp_policy_after_cache_warm() {
let (session, mut turn_context) = make_session_and_context().await;
let codex_home = session.codex_home().await;
write_sample_plugin_mcp(codex_home.as_path());
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"
[features]
plugins = true
[plugins."sample@test"]
enabled = true
"#,
)
.expect("seed config");
let initial_config = ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.build()
.await
.expect("load initial config");
session
.services
.plugins_manager
.plugins_for_config(&initial_config)
.await;
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"
[features]
plugins = true
[plugins."sample@test"]
enabled = true
[plugins."sample@test".mcp_servers.sample.tools.search]
approval_mode = "approve"
"#,
)
.expect("update config");
let updated_config = ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.build()
.await
.expect("load updated config");
turn_context.config = Arc::new(updated_config);
assert_eq!(
custom_mcp_tool_approval_mode(&session, &turn_context, "sample", "search").await,
AppToolApproval::Approve
);
}
#[tokio::test]
async fn maybe_persist_mcp_tool_approval_reloads_session_config() {
let (session, turn_context) = make_session_and_context().await;
@@ -1584,6 +1701,56 @@ async fn maybe_persist_mcp_tool_approval_reloads_session_config_for_custom_serve
assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true);
}
#[tokio::test]
async fn maybe_persist_mcp_tool_approval_writes_plugin_mcp_policy() {
let (session, mut turn_context) = make_session_and_context().await;
let codex_home = session.codex_home().await;
write_sample_plugin_mcp(codex_home.as_path());
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"
[features]
plugins = true
[plugins."sample@test"]
enabled = true
"#,
)
.expect("seed config");
let config = ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.build()
.await
.expect("load config");
turn_context.config = Arc::new(config);
session.services.plugins_manager.clear_cache();
let key = McpToolApprovalKey {
server: "sample".to_string(),
connector_id: None,
tool_name: "search".to_string(),
};
maybe_persist_mcp_tool_approval(&session, &turn_context, key.clone()).await;
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let parsed: ConfigToml = toml::from_str(&contents).expect("parse config");
let tool = parsed
.plugins
.get("sample@test")
.and_then(|plugin| plugin.mcp_servers.get("sample"))
.and_then(|server| server.tools.get("search"))
.expect("sample/search tool config exists");
assert_eq!(
tool,
&McpServerToolConfig {
approval_mode: Some(AppToolApproval::Approve),
}
);
assert!(contents.contains(r#"[plugins."sample@test".mcp_servers.sample.tools.search]"#));
assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true);
}
#[tokio::test]
async fn maybe_persist_mcp_tool_approval_writes_project_config_for_project_server() {
let (session, mut turn_context) = make_session_and_context().await;
+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();