Add server-level approval defaults for custom MCP servers (#17843)

## Summary
- Add `default_tools_approval_mode` support for custom MCP server
configs, matching the existing `codex_apps` behavior
- Apply approval precedence as per-tool override, then server default,
then `auto`
- Update config serialization, CLI display, schema generation, docs, and
tests

## Testing
- `cargo check -p codex-config`
- `cargo check -p codex-core`
- `just write-config-schema`
- `just fmt`
- `cargo test -p codex-config`
- Targeted `codex-core` tests for config parsing, config writes, and MCP
approval precedence
- `just fix -p codex-config -p codex-core`
This commit is contained in:
Matthew Zeng
2026-04-16 11:18:07 -07:00
committed by GitHub
Unverified
parent 206dd13c32
commit 71174574ad
27 changed files with 231 additions and 12 deletions
+27 -6
View File
@@ -90,6 +90,7 @@ fn stdio_mcp(command: &str) -> McpServerConfig {
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -113,6 +114,7 @@ fn http_mcp(url: &str) -> McpServerConfig {
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -2130,6 +2132,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> {
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(3)),
tool_timeout_sec: Some(Duration::from_secs(5)),
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -2243,23 +2246,28 @@ fn mcp_servers_toml_parses_per_tool_approval_overrides() {
[mcp_servers.docs]
command = "docs-server"
name = "Docs"
default_tools_approval_mode = "prompt"
[mcp_servers.docs.tools.search]
approval_mode = "approve"
"#,
)
.expect("TOML deserialization should succeed");
let tool = config
let server = config
.mcp_servers
.get("docs")
.and_then(|server| server.tools.get("search"))
.expect("docs/search tool config exists");
.expect("docs server config exists");
assert_eq!(
tool,
&McpServerToolConfig {
server.default_tools_approval_mode,
Some(AppToolApproval::Prompt)
);
assert_eq!(
server.tools.get("search"),
Some(&McpServerToolConfig {
approval_mode: Some(AppToolApproval::Approve),
}
})
);
}
@@ -2380,6 +2388,7 @@ async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> {
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -2455,6 +2464,7 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> {
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -2510,6 +2520,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> {
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -2563,6 +2574,7 @@ async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(2)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -2632,6 +2644,7 @@ async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyh
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(2)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -2713,6 +2726,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(2)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -2747,6 +2761,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -2816,6 +2831,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers()
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(2)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -2840,6 +2856,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers()
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -2927,6 +2944,7 @@ async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> {
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -2976,6 +2994,7 @@ async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> {
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -3025,6 +3044,7 @@ async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> {
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: Some(vec!["allowed".to_string()]),
disabled_tools: Some(vec!["blocked".to_string()]),
scopes: None,
@@ -3078,6 +3098,7 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
+7
View File
@@ -237,6 +237,13 @@ mod document_helpers {
if let Some(timeout) = config.tool_timeout_sec {
entry["tool_timeout_sec"] = value(timeout.as_secs_f64());
}
if let Some(approval_mode) = config.default_tools_approval_mode {
entry["default_tools_approval_mode"] = value(match approval_mode {
AppToolApproval::Auto => "auto",
AppToolApproval::Prompt => "prompt",
AppToolApproval::Approve => "approve",
});
}
if let Some(enabled_tools) = &config.enabled_tools
&& !enabled_tools.is_empty()
{
+8
View File
@@ -582,6 +582,7 @@ fn blocking_replace_mcp_servers_round_trips() {
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: Some(vec!["one".to_string(), "two".to_string()]),
disabled_tools: None,
scopes: None,
@@ -610,6 +611,7 @@ fn blocking_replace_mcp_servers_round_trips() {
disabled_reason: None,
startup_timeout_sec: Some(std::time::Duration::from_secs(5)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: Some(vec!["forbidden".to_string()]),
scopes: None,
@@ -675,6 +677,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: Some(AppToolApproval::Prompt),
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -699,6 +702,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
let expected = "\
[mcp_servers.docs]
command = \"docs-server\"
default_tools_approval_mode = \"prompt\"
[mcp_servers.docs.tools.search]
approval_mode = \"approve\"
@@ -737,6 +741,7 @@ foo = { command = "cmd" }
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -790,6 +795,7 @@ foo = { command = "cmd" } # keep me
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -842,6 +848,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -895,6 +902,7 @@ foo = { command = "cmd" }
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
+43
View File
@@ -164,6 +164,49 @@ async fn write_value_supports_nested_app_paths() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn write_value_supports_custom_mcp_server_default_tool_approval_mode() -> Result<()> {
let tmp = tempdir().expect("tempdir");
std::fs::write(
tmp.path().join(CONFIG_TOML_FILE),
"[mcp_servers.docs]\ncommand = \"docs-server\"\n",
)?;
let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf());
service
.write_value(ConfigValueWriteParams {
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
key_path: "mcp_servers.docs.default_tools_approval_mode".to_string(),
value: serde_json::json!("approve"),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await
.expect("write mcp server default_tools_approval_mode succeeds");
let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE))?;
assert!(contents.contains("default_tools_approval_mode = \"approve\""));
let read = service
.read(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await
.expect("config read succeeds");
assert_eq!(
read.config
.additional
.get("mcp_servers")
.and_then(|servers| servers.get("docs"))
.and_then(|docs| docs.get("default_tools_approval_mode")),
Some(&serde_json::json!("approve"))
);
Ok(())
}
#[tokio::test]
async fn read_includes_origins_and_layers() {
let tmp = tempdir().expect("tempdir");
@@ -371,6 +371,7 @@ fn mcp_dependency_to_server_config(
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -399,6 +400,7 @@ fn mcp_dependency_to_server_config(
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
+8 -3
View File
@@ -676,9 +676,14 @@ fn custom_mcp_tool_approval_mode(
.and_then(|value| {
HashMap::<String, codex_config::types::McpServerConfig>::deserialize(value).ok()
})
.and_then(|servers| servers.get(server).cloned())
.and_then(|server| server.tools.get(tool_name).cloned())
.and_then(|tool| tool.approval_mode)
.and_then(|servers| {
let server_config = servers.get(server)?;
server_config
.tools
.get(tool_name)
.and_then(|tool| tool.approval_mode)
.or(server_config.default_tools_approval_mode)
})
.unwrap_or_default()
}
+37
View File
@@ -1146,6 +1146,43 @@ async fn persist_custom_mcp_tool_approval_writes_tool_override() {
assert!(contents.contains("[mcp_servers.docs.tools.search]"));
}
#[tokio::test]
async fn custom_mcp_tool_approval_mode_uses_server_default_with_tool_override() {
let tmp = tempdir().expect("tempdir");
std::fs::write(
tmp.path().join(CONFIG_TOML_FILE),
r#"
[mcp_servers.docs]
command = "docs-server"
default_tools_approval_mode = "approve"
[mcp_servers.docs.tools.search]
approval_mode = "prompt"
"#,
)
.expect("seed config");
let config = ConfigBuilder::default()
.codex_home(tmp.path().to_path_buf())
.build()
.await
.expect("load config");
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"),
AppToolApproval::Approve
);
assert_eq!(
custom_mcp_tool_approval_mode(&turn_context, "docs", "search"),
AppToolApproval::Prompt
);
assert_eq!(
custom_mcp_tool_approval_mode(&turn_context, "unknown", "search"),
AppToolApproval::Auto
);
}
#[tokio::test]
async fn maybe_persist_mcp_tool_approval_reloads_session_config() {
let (session, turn_context) = make_session_and_context().await;
@@ -183,6 +183,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -520,6 +521,7 @@ async fn load_plugins_uses_manifest_configured_component_paths() {
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -630,6 +632,7 @@ async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -788,6 +791,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,