[MCP] Add configuration options to enable or disable specific tools (#5367)
Some MCP servers expose a lot of tools. In those cases, it is reasonable to allow/denylist tools for Codex to use so it doesn't get overwhelmed with too many tools. The new configuration options available in the `mcp_server` toml table are: * `enabled_tools` * `disabled_tools` Fixes #4796
This commit is contained in:
@@ -253,6 +253,8 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
startup_timeout_sec: None,
|
startup_timeout_sec: None,
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
|
enabled_tools: None,
|
||||||
|
disabled_tools: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
servers.insert(name.clone(), new_entry);
|
servers.insert(name.clone(), new_entry);
|
||||||
@@ -676,6 +678,8 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
|
|||||||
"name": get_args.name,
|
"name": get_args.name,
|
||||||
"enabled": server.enabled,
|
"enabled": server.enabled,
|
||||||
"transport": transport,
|
"transport": transport,
|
||||||
|
"enabled_tools": server.enabled_tools.clone(),
|
||||||
|
"disabled_tools": server.disabled_tools.clone(),
|
||||||
"startup_timeout_sec": server
|
"startup_timeout_sec": server
|
||||||
.startup_timeout_sec
|
.startup_timeout_sec
|
||||||
.map(|timeout| timeout.as_secs_f64()),
|
.map(|timeout| timeout.as_secs_f64()),
|
||||||
@@ -687,8 +691,28 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !server.enabled {
|
||||||
|
println!("{} (disabled)", get_args.name);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
println!("{}", get_args.name);
|
println!("{}", get_args.name);
|
||||||
println!(" enabled: {}", server.enabled);
|
println!(" enabled: {}", server.enabled);
|
||||||
|
let format_tool_list = |tools: &Option<Vec<String>>| -> String {
|
||||||
|
match tools {
|
||||||
|
Some(list) if list.is_empty() => "[]".to_string(),
|
||||||
|
Some(list) => list.join(", "),
|
||||||
|
None => "-".to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if server.enabled_tools.is_some() {
|
||||||
|
let enabled_tools_display = format_tool_list(&server.enabled_tools);
|
||||||
|
println!(" enabled_tools: {enabled_tools_display}");
|
||||||
|
}
|
||||||
|
if server.disabled_tools.is_some() {
|
||||||
|
let disabled_tools_display = format_tool_list(&server.disabled_tools);
|
||||||
|
println!(" disabled_tools: {disabled_tools_display}");
|
||||||
|
}
|
||||||
match &server.transport {
|
match &server.transport {
|
||||||
McpServerTransportConfig::Stdio {
|
McpServerTransportConfig::Stdio {
|
||||||
command,
|
command,
|
||||||
|
|||||||
@@ -134,3 +134,28 @@ async fn list_and_get_render_expected_output() -> Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_disabled_server_shows_single_line() -> Result<()> {
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
|
||||||
|
let mut add = codex_command(codex_home.path())?;
|
||||||
|
add.args(["mcp", "add", "docs", "--", "docs-server"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let mut servers = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
|
let docs = servers
|
||||||
|
.get_mut("docs")
|
||||||
|
.expect("docs server should exist after add");
|
||||||
|
docs.enabled = false;
|
||||||
|
write_global_mcp_servers(codex_home.path(), &servers)?;
|
||||||
|
|
||||||
|
let mut get_cmd = codex_command(codex_home.path())?;
|
||||||
|
let get_output = get_cmd.args(["mcp", "get", "docs"]).output()?;
|
||||||
|
assert!(get_output.status.success());
|
||||||
|
let stdout = String::from_utf8(get_output.stdout)?;
|
||||||
|
assert_eq!(stdout.trim_end(), "docs (disabled)");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -484,6 +484,16 @@ pub fn write_global_mcp_servers(
|
|||||||
entry["tool_timeout_sec"] = toml_edit::value(timeout.as_secs_f64());
|
entry["tool_timeout_sec"] = toml_edit::value(timeout.as_secs_f64());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(enabled_tools) = &config.enabled_tools {
|
||||||
|
entry["enabled_tools"] =
|
||||||
|
TomlItem::Value(enabled_tools.iter().collect::<TomlArray>().into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(disabled_tools) = &config.disabled_tools {
|
||||||
|
entry["disabled_tools"] =
|
||||||
|
TomlItem::Value(disabled_tools.iter().collect::<TomlArray>().into());
|
||||||
|
}
|
||||||
|
|
||||||
doc["mcp_servers"][name.as_str()] = TomlItem::Table(entry);
|
doc["mcp_servers"][name.as_str()] = TomlItem::Table(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1923,6 +1933,8 @@ approve_all = true
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
startup_timeout_sec: Some(Duration::from_secs(3)),
|
startup_timeout_sec: Some(Duration::from_secs(3)),
|
||||||
tool_timeout_sec: Some(Duration::from_secs(5)),
|
tool_timeout_sec: Some(Duration::from_secs(5)),
|
||||||
|
enabled_tools: None,
|
||||||
|
disabled_tools: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2059,6 +2071,8 @@ bearer_token = "secret"
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
startup_timeout_sec: None,
|
startup_timeout_sec: None,
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
|
enabled_tools: None,
|
||||||
|
disabled_tools: None,
|
||||||
},
|
},
|
||||||
)]);
|
)]);
|
||||||
|
|
||||||
@@ -2121,6 +2135,8 @@ ZIG_VAR = "3"
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
startup_timeout_sec: None,
|
startup_timeout_sec: None,
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
|
enabled_tools: None,
|
||||||
|
disabled_tools: None,
|
||||||
},
|
},
|
||||||
)]);
|
)]);
|
||||||
|
|
||||||
@@ -2163,6 +2179,8 @@ ZIG_VAR = "3"
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
startup_timeout_sec: None,
|
startup_timeout_sec: None,
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
|
enabled_tools: None,
|
||||||
|
disabled_tools: None,
|
||||||
},
|
},
|
||||||
)]);
|
)]);
|
||||||
|
|
||||||
@@ -2204,6 +2222,8 @@ ZIG_VAR = "3"
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
startup_timeout_sec: Some(Duration::from_secs(2)),
|
startup_timeout_sec: Some(Duration::from_secs(2)),
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
|
enabled_tools: None,
|
||||||
|
disabled_tools: None,
|
||||||
},
|
},
|
||||||
)]);
|
)]);
|
||||||
|
|
||||||
@@ -2261,6 +2281,8 @@ startup_timeout_sec = 2.0
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
startup_timeout_sec: Some(Duration::from_secs(2)),
|
startup_timeout_sec: Some(Duration::from_secs(2)),
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
|
enabled_tools: None,
|
||||||
|
disabled_tools: None,
|
||||||
},
|
},
|
||||||
)]);
|
)]);
|
||||||
write_global_mcp_servers(codex_home.path(), &servers)?;
|
write_global_mcp_servers(codex_home.path(), &servers)?;
|
||||||
@@ -2330,6 +2352,8 @@ X-Auth = "DOCS_AUTH"
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
startup_timeout_sec: Some(Duration::from_secs(2)),
|
startup_timeout_sec: Some(Duration::from_secs(2)),
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
|
enabled_tools: None,
|
||||||
|
disabled_tools: None,
|
||||||
},
|
},
|
||||||
)]);
|
)]);
|
||||||
|
|
||||||
@@ -2351,6 +2375,8 @@ X-Auth = "DOCS_AUTH"
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
startup_timeout_sec: None,
|
startup_timeout_sec: None,
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
|
enabled_tools: None,
|
||||||
|
disabled_tools: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
write_global_mcp_servers(codex_home.path(), &servers)?;
|
write_global_mcp_servers(codex_home.path(), &servers)?;
|
||||||
@@ -2410,6 +2436,8 @@ url = "https://example.com/mcp"
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
startup_timeout_sec: Some(Duration::from_secs(2)),
|
startup_timeout_sec: Some(Duration::from_secs(2)),
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
|
enabled_tools: None,
|
||||||
|
disabled_tools: None,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -2425,6 +2453,8 @@ url = "https://example.com/mcp"
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
startup_timeout_sec: None,
|
startup_timeout_sec: None,
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
|
enabled_tools: None,
|
||||||
|
disabled_tools: None,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -2499,6 +2529,8 @@ url = "https://example.com/mcp"
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
startup_timeout_sec: None,
|
startup_timeout_sec: None,
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
|
enabled_tools: None,
|
||||||
|
disabled_tools: None,
|
||||||
},
|
},
|
||||||
)]);
|
)]);
|
||||||
|
|
||||||
@@ -2518,6 +2550,49 @@ url = "https://example.com/mcp"
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn write_global_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> {
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
|
||||||
|
let servers = BTreeMap::from([(
|
||||||
|
"docs".to_string(),
|
||||||
|
McpServerConfig {
|
||||||
|
transport: McpServerTransportConfig::Stdio {
|
||||||
|
command: "docs-server".to_string(),
|
||||||
|
args: Vec::new(),
|
||||||
|
env: None,
|
||||||
|
env_vars: Vec::new(),
|
||||||
|
cwd: None,
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
startup_timeout_sec: None,
|
||||||
|
tool_timeout_sec: None,
|
||||||
|
enabled_tools: Some(vec!["allowed".to_string()]),
|
||||||
|
disabled_tools: Some(vec!["blocked".to_string()]),
|
||||||
|
},
|
||||||
|
)]);
|
||||||
|
|
||||||
|
write_global_mcp_servers(codex_home.path(), &servers)?;
|
||||||
|
|
||||||
|
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
|
||||||
|
let serialized = std::fs::read_to_string(&config_path)?;
|
||||||
|
assert!(serialized.contains(r#"enabled_tools = ["allowed"]"#));
|
||||||
|
assert!(serialized.contains(r#"disabled_tools = ["blocked"]"#));
|
||||||
|
|
||||||
|
let loaded = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
|
let docs = loaded.get("docs").expect("docs entry");
|
||||||
|
assert_eq!(
|
||||||
|
docs.enabled_tools.as_ref(),
|
||||||
|
Some(&vec!["allowed".to_string()])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
docs.disabled_tools.as_ref(),
|
||||||
|
Some(&vec!["blocked".to_string()])
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn persist_model_selection_updates_defaults() -> anyhow::Result<()> {
|
async fn persist_model_selection_updates_defaults() -> anyhow::Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ pub struct McpServerConfig {
|
|||||||
/// Default timeout for MCP tool calls initiated via this server.
|
/// Default timeout for MCP tool calls initiated via this server.
|
||||||
#[serde(default, with = "option_duration_secs")]
|
#[serde(default, with = "option_duration_secs")]
|
||||||
pub tool_timeout_sec: Option<Duration>,
|
pub tool_timeout_sec: Option<Duration>,
|
||||||
|
|
||||||
|
/// Explicit allow-list of tools exposed from this server. When set, only these tools will be registered.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enabled_tools: Option<Vec<String>>,
|
||||||
|
|
||||||
|
/// Explicit deny-list of tools. These tools will be removed after applying `enabled_tools`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub disabled_tools: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for McpServerConfig {
|
impl<'de> Deserialize<'de> for McpServerConfig {
|
||||||
@@ -42,7 +50,7 @@ impl<'de> Deserialize<'de> for McpServerConfig {
|
|||||||
where
|
where
|
||||||
D: Deserializer<'de>,
|
D: Deserializer<'de>,
|
||||||
{
|
{
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, Clone)]
|
||||||
struct RawMcpServerConfig {
|
struct RawMcpServerConfig {
|
||||||
// stdio
|
// stdio
|
||||||
command: Option<String>,
|
command: Option<String>,
|
||||||
@@ -72,9 +80,13 @@ impl<'de> Deserialize<'de> for McpServerConfig {
|
|||||||
tool_timeout_sec: Option<Duration>,
|
tool_timeout_sec: Option<Duration>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
enabled: Option<bool>,
|
enabled: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
enabled_tools: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
disabled_tools: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
let raw = RawMcpServerConfig::deserialize(deserializer)?;
|
let mut raw = RawMcpServerConfig::deserialize(deserializer)?;
|
||||||
|
|
||||||
let startup_timeout_sec = match (raw.startup_timeout_sec, raw.startup_timeout_ms) {
|
let startup_timeout_sec = match (raw.startup_timeout_sec, raw.startup_timeout_ms) {
|
||||||
(Some(sec), _) => {
|
(Some(sec), _) => {
|
||||||
@@ -84,6 +96,10 @@ impl<'de> Deserialize<'de> for McpServerConfig {
|
|||||||
(None, Some(ms)) => Some(Duration::from_millis(ms)),
|
(None, Some(ms)) => Some(Duration::from_millis(ms)),
|
||||||
(None, None) => None,
|
(None, None) => None,
|
||||||
};
|
};
|
||||||
|
let tool_timeout_sec = raw.tool_timeout_sec;
|
||||||
|
let enabled = raw.enabled.unwrap_or_else(default_enabled);
|
||||||
|
let enabled_tools = raw.enabled_tools.clone();
|
||||||
|
let disabled_tools = raw.disabled_tools.clone();
|
||||||
|
|
||||||
fn throw_if_set<E, T>(transport: &str, field: &str, value: Option<&T>) -> Result<(), E>
|
fn throw_if_set<E, T>(transport: &str, field: &str, value: Option<&T>) -> Result<(), E>
|
||||||
where
|
where
|
||||||
@@ -97,72 +113,46 @@ impl<'de> Deserialize<'de> for McpServerConfig {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
let transport = match raw {
|
let transport = if let Some(command) = raw.command.clone() {
|
||||||
RawMcpServerConfig {
|
throw_if_set("stdio", "url", raw.url.as_ref())?;
|
||||||
command: Some(command),
|
throw_if_set(
|
||||||
args,
|
"stdio",
|
||||||
env,
|
"bearer_token_env_var",
|
||||||
env_vars,
|
raw.bearer_token_env_var.as_ref(),
|
||||||
cwd,
|
)?;
|
||||||
url,
|
throw_if_set("stdio", "bearer_token", raw.bearer_token.as_ref())?;
|
||||||
bearer_token_env_var,
|
throw_if_set("stdio", "http_headers", raw.http_headers.as_ref())?;
|
||||||
http_headers,
|
throw_if_set("stdio", "env_http_headers", raw.env_http_headers.as_ref())?;
|
||||||
env_http_headers,
|
McpServerTransportConfig::Stdio {
|
||||||
..
|
|
||||||
} => {
|
|
||||||
throw_if_set("stdio", "url", url.as_ref())?;
|
|
||||||
throw_if_set(
|
|
||||||
"stdio",
|
|
||||||
"bearer_token_env_var",
|
|
||||||
bearer_token_env_var.as_ref(),
|
|
||||||
)?;
|
|
||||||
throw_if_set("stdio", "http_headers", http_headers.as_ref())?;
|
|
||||||
throw_if_set("stdio", "env_http_headers", env_http_headers.as_ref())?;
|
|
||||||
McpServerTransportConfig::Stdio {
|
|
||||||
command,
|
|
||||||
args: args.unwrap_or_default(),
|
|
||||||
env,
|
|
||||||
env_vars: env_vars.unwrap_or_default(),
|
|
||||||
cwd,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RawMcpServerConfig {
|
|
||||||
url: Some(url),
|
|
||||||
bearer_token,
|
|
||||||
bearer_token_env_var,
|
|
||||||
command,
|
command,
|
||||||
args,
|
args: raw.args.clone().unwrap_or_default(),
|
||||||
env,
|
env: raw.env.clone(),
|
||||||
env_vars,
|
env_vars: raw.env_vars.clone().unwrap_or_default(),
|
||||||
cwd,
|
cwd: raw.cwd.take(),
|
||||||
http_headers,
|
|
||||||
env_http_headers,
|
|
||||||
startup_timeout_sec: _,
|
|
||||||
tool_timeout_sec: _,
|
|
||||||
startup_timeout_ms: _,
|
|
||||||
enabled: _,
|
|
||||||
} => {
|
|
||||||
throw_if_set("streamable_http", "command", command.as_ref())?;
|
|
||||||
throw_if_set("streamable_http", "args", args.as_ref())?;
|
|
||||||
throw_if_set("streamable_http", "env", env.as_ref())?;
|
|
||||||
throw_if_set("streamable_http", "env_vars", env_vars.as_ref())?;
|
|
||||||
throw_if_set("streamable_http", "cwd", cwd.as_ref())?;
|
|
||||||
throw_if_set("streamable_http", "bearer_token", bearer_token.as_ref())?;
|
|
||||||
McpServerTransportConfig::StreamableHttp {
|
|
||||||
url,
|
|
||||||
bearer_token_env_var,
|
|
||||||
http_headers,
|
|
||||||
env_http_headers,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => return Err(SerdeError::custom("invalid transport")),
|
} else if let Some(url) = raw.url.clone() {
|
||||||
|
throw_if_set("streamable_http", "args", raw.args.as_ref())?;
|
||||||
|
throw_if_set("streamable_http", "env", raw.env.as_ref())?;
|
||||||
|
throw_if_set("streamable_http", "env_vars", raw.env_vars.as_ref())?;
|
||||||
|
throw_if_set("streamable_http", "cwd", raw.cwd.as_ref())?;
|
||||||
|
throw_if_set("streamable_http", "bearer_token", raw.bearer_token.as_ref())?;
|
||||||
|
McpServerTransportConfig::StreamableHttp {
|
||||||
|
url,
|
||||||
|
bearer_token_env_var: raw.bearer_token_env_var.clone(),
|
||||||
|
http_headers: raw.http_headers.clone(),
|
||||||
|
env_http_headers: raw.env_http_headers.take(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(SerdeError::custom("invalid transport"));
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
transport,
|
transport,
|
||||||
startup_timeout_sec,
|
startup_timeout_sec,
|
||||||
tool_timeout_sec: raw.tool_timeout_sec,
|
tool_timeout_sec,
|
||||||
enabled: raw.enabled.unwrap_or_else(default_enabled),
|
enabled,
|
||||||
|
enabled_tools,
|
||||||
|
disabled_tools,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -527,6 +517,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert!(cfg.enabled);
|
assert!(cfg.enabled);
|
||||||
|
assert!(cfg.enabled_tools.is_none());
|
||||||
|
assert!(cfg.disabled_tools.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -701,6 +693,21 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize_server_config_with_tool_filters() {
|
||||||
|
let cfg: McpServerConfig = toml::from_str(
|
||||||
|
r#"
|
||||||
|
command = "echo"
|
||||||
|
enabled_tools = ["allowed"]
|
||||||
|
disabled_tools = ["blocked"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("should deserialize tool filters");
|
||||||
|
|
||||||
|
assert_eq!(cfg.enabled_tools, Some(vec!["allowed".to_string()]));
|
||||||
|
assert_eq!(cfg.disabled_tools, Some(vec!["blocked".to_string()]));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deserialize_rejects_command_and_url() {
|
fn deserialize_rejects_command_and_url() {
|
||||||
toml::from_str::<McpServerConfig>(
|
toml::from_str::<McpServerConfig>(
|
||||||
|
|||||||
@@ -237,6 +237,9 @@ pub(crate) struct McpConnectionManager {
|
|||||||
|
|
||||||
/// Fully qualified tool name -> tool instance.
|
/// Fully qualified tool name -> tool instance.
|
||||||
tools: HashMap<String, ToolInfo>,
|
tools: HashMap<String, ToolInfo>,
|
||||||
|
|
||||||
|
/// Server-name -> configured tool filters.
|
||||||
|
tool_filters: HashMap<String, ToolFilter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl McpConnectionManager {
|
impl McpConnectionManager {
|
||||||
@@ -261,6 +264,7 @@ impl McpConnectionManager {
|
|||||||
// Launch all configured servers concurrently.
|
// Launch all configured servers concurrently.
|
||||||
let mut join_set = JoinSet::new();
|
let mut join_set = JoinSet::new();
|
||||||
let mut errors = ClientStartErrors::new();
|
let mut errors = ClientStartErrors::new();
|
||||||
|
let mut tool_filters: HashMap<String, ToolFilter> = HashMap::new();
|
||||||
|
|
||||||
for (server_name, cfg) in mcp_servers {
|
for (server_name, cfg) in mcp_servers {
|
||||||
// Validate server name before spawning
|
// Validate server name before spawning
|
||||||
@@ -273,11 +277,13 @@ impl McpConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !cfg.enabled {
|
if !cfg.enabled {
|
||||||
|
tool_filters.insert(server_name, ToolFilter::from_config(&cfg));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let startup_timeout = cfg.startup_timeout_sec.unwrap_or(DEFAULT_STARTUP_TIMEOUT);
|
let startup_timeout = cfg.startup_timeout_sec.unwrap_or(DEFAULT_STARTUP_TIMEOUT);
|
||||||
let tool_timeout = cfg.tool_timeout_sec.unwrap_or(DEFAULT_TOOL_TIMEOUT);
|
let tool_timeout = cfg.tool_timeout_sec.unwrap_or(DEFAULT_TOOL_TIMEOUT);
|
||||||
|
tool_filters.insert(server_name.clone(), ToolFilter::from_config(&cfg));
|
||||||
|
|
||||||
let resolved_bearer_token = match &cfg.transport {
|
let resolved_bearer_token = match &cfg.transport {
|
||||||
McpServerTransportConfig::StreamableHttp {
|
McpServerTransportConfig::StreamableHttp {
|
||||||
@@ -393,9 +399,17 @@ impl McpConnectionManager {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let tools = qualify_tools(all_tools);
|
let filtered_tools = filter_tools(all_tools, &tool_filters);
|
||||||
|
let tools = qualify_tools(filtered_tools);
|
||||||
|
|
||||||
Ok((Self { clients, tools }, errors))
|
Ok((
|
||||||
|
Self {
|
||||||
|
clients,
|
||||||
|
tools,
|
||||||
|
tool_filters,
|
||||||
|
},
|
||||||
|
errors,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a single map that contains all tools. Each key is the
|
/// Returns a single map that contains all tools. Each key is the
|
||||||
@@ -541,6 +555,13 @@ impl McpConnectionManager {
|
|||||||
tool: &str,
|
tool: &str,
|
||||||
arguments: Option<serde_json::Value>,
|
arguments: Option<serde_json::Value>,
|
||||||
) -> Result<mcp_types::CallToolResult> {
|
) -> Result<mcp_types::CallToolResult> {
|
||||||
|
if let Some(filter) = self.tool_filters.get(server)
|
||||||
|
&& !filter.allows(tool)
|
||||||
|
{
|
||||||
|
return Err(anyhow!(
|
||||||
|
"tool '{tool}' is disabled for MCP server '{server}'"
|
||||||
|
));
|
||||||
|
}
|
||||||
let managed = self
|
let managed = self
|
||||||
.clients
|
.clients
|
||||||
.get(server)
|
.get(server)
|
||||||
@@ -619,6 +640,52 @@ impl McpConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A tool is allowed to be used if both are true:
|
||||||
|
/// 1. enabled is None (no allowlist is set) or the tool is explicitly enabled.
|
||||||
|
/// 2. The tool is not explicitly disabled.
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
struct ToolFilter {
|
||||||
|
enabled: Option<HashSet<String>>,
|
||||||
|
disabled: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolFilter {
|
||||||
|
fn from_config(cfg: &McpServerConfig) -> Self {
|
||||||
|
let enabled = cfg
|
||||||
|
.enabled_tools
|
||||||
|
.as_ref()
|
||||||
|
.map(|tools| tools.iter().cloned().collect::<HashSet<_>>());
|
||||||
|
let disabled = cfg
|
||||||
|
.disabled_tools
|
||||||
|
.as_ref()
|
||||||
|
.map(|tools| tools.iter().cloned().collect::<HashSet<_>>())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Self { enabled, disabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allows(&self, tool_name: &str) -> bool {
|
||||||
|
if let Some(enabled) = &self.enabled
|
||||||
|
&& !enabled.contains(tool_name)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
!self.disabled.contains(tool_name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_tools(tools: Vec<ToolInfo>, filters: &HashMap<String, ToolFilter>) -> Vec<ToolInfo> {
|
||||||
|
tools
|
||||||
|
.into_iter()
|
||||||
|
.filter(|tool| {
|
||||||
|
filters
|
||||||
|
.get(&tool.server_name)
|
||||||
|
.is_none_or(|filter| filter.allows(&tool.tool_name))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_bearer_token(
|
fn resolve_bearer_token(
|
||||||
server_name: &str,
|
server_name: &str,
|
||||||
bearer_token_env_var: Option<&str>,
|
bearer_token_env_var: Option<&str>,
|
||||||
@@ -711,6 +778,7 @@ fn is_valid_mcp_server_name(server_name: &str) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use mcp_types::ToolInputSchema;
|
use mcp_types::ToolInputSchema;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo {
|
fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo {
|
||||||
ToolInfo {
|
ToolInfo {
|
||||||
@@ -793,4 +861,75 @@ mod tests {
|
|||||||
"mcp__my_server__yet_anot419a82a89325c1b477274a41f8c65ea5f3a7f341"
|
"mcp__my_server__yet_anot419a82a89325c1b477274a41f8c65ea5f3a7f341"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_filter_allows_by_default() {
|
||||||
|
let filter = ToolFilter::default();
|
||||||
|
|
||||||
|
assert!(filter.allows("any"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_filter_applies_enabled_list() {
|
||||||
|
let filter = ToolFilter {
|
||||||
|
enabled: Some(HashSet::from(["allowed".to_string()])),
|
||||||
|
disabled: HashSet::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(filter.allows("allowed"));
|
||||||
|
assert!(!filter.allows("denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_filter_applies_disabled_list() {
|
||||||
|
let filter = ToolFilter {
|
||||||
|
enabled: None,
|
||||||
|
disabled: HashSet::from(["blocked".to_string()]),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!filter.allows("blocked"));
|
||||||
|
assert!(filter.allows("open"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_filter_applies_enabled_then_disabled() {
|
||||||
|
let filter = ToolFilter {
|
||||||
|
enabled: Some(HashSet::from(["keep".to_string(), "remove".to_string()])),
|
||||||
|
disabled: HashSet::from(["remove".to_string()]),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(filter.allows("keep"));
|
||||||
|
assert!(!filter.allows("remove"));
|
||||||
|
assert!(!filter.allows("unknown"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_tools_applies_per_server_filters() {
|
||||||
|
let tools = vec![
|
||||||
|
create_test_tool("server1", "tool_a"),
|
||||||
|
create_test_tool("server1", "tool_b"),
|
||||||
|
create_test_tool("server2", "tool_a"),
|
||||||
|
];
|
||||||
|
let mut filters = HashMap::new();
|
||||||
|
filters.insert(
|
||||||
|
"server1".to_string(),
|
||||||
|
ToolFilter {
|
||||||
|
enabled: Some(HashSet::from(["tool_a".to_string(), "tool_b".to_string()])),
|
||||||
|
disabled: HashSet::from(["tool_b".to_string()]),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
filters.insert(
|
||||||
|
"server2".to_string(),
|
||||||
|
ToolFilter {
|
||||||
|
enabled: None,
|
||||||
|
disabled: HashSet::from(["tool_a".to_string()]),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let filtered = filter_tools(tools, &filters);
|
||||||
|
|
||||||
|
assert_eq!(filtered.len(), 1);
|
||||||
|
assert_eq!(filtered[0].server_name, "server1");
|
||||||
|
assert_eq!(filtered[0].tool_name, "tool_a");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
startup_timeout_sec: Some(Duration::from_secs(10)),
|
startup_timeout_sec: Some(Duration::from_secs(10)),
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
|
enabled_tools: None,
|
||||||
|
disabled_tools: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -230,6 +232,8 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
startup_timeout_sec: Some(Duration::from_secs(10)),
|
startup_timeout_sec: Some(Duration::from_secs(10)),
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
|
enabled_tools: None,
|
||||||
|
disabled_tools: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -381,6 +385,8 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
startup_timeout_sec: Some(Duration::from_secs(10)),
|
startup_timeout_sec: Some(Duration::from_secs(10)),
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
|
enabled_tools: None,
|
||||||
|
disabled_tools: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -564,6 +570,8 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
startup_timeout_sec: Some(Duration::from_secs(10)),
|
startup_timeout_sec: Some(Duration::from_secs(10)),
|
||||||
tool_timeout_sec: None,
|
tool_timeout_sec: None,
|
||||||
|
enabled_tools: None,
|
||||||
|
disabled_tools: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1056,18 +1056,21 @@ pub(crate) fn new_mcp_tools_output(
|
|||||||
.collect();
|
.collect();
|
||||||
names.sort();
|
names.sort();
|
||||||
|
|
||||||
let status = auth_statuses
|
let auth_status = auth_statuses
|
||||||
.get(server.as_str())
|
.get(server.as_str())
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(McpAuthStatus::Unsupported);
|
.unwrap_or(McpAuthStatus::Unsupported);
|
||||||
lines.push(vec![" • Server: ".into(), server.clone().into()].into());
|
let mut header: Vec<Span<'static>> = vec![" • ".into(), server.clone().into()];
|
||||||
let status_line = if cfg.enabled {
|
if !cfg.enabled {
|
||||||
vec![" • Status: ".into(), "enabled".green()].into()
|
header.push(" ".into());
|
||||||
} else {
|
header.push("(disabled)".red());
|
||||||
vec![" • Status: ".into(), "disabled".red()].into()
|
lines.push(header.into());
|
||||||
};
|
lines.push(Line::from(""));
|
||||||
lines.push(status_line);
|
continue;
|
||||||
lines.push(vec![" • Auth: ".into(), status.to_string().into()].into());
|
}
|
||||||
|
lines.push(header.into());
|
||||||
|
lines.push(vec![" • Status: ".into(), "enabled".green()].into());
|
||||||
|
lines.push(vec![" • Auth: ".into(), auth_status.to_string().into()].into());
|
||||||
|
|
||||||
match &cfg.transport {
|
match &cfg.transport {
|
||||||
McpServerTransportConfig::Stdio {
|
McpServerTransportConfig::Stdio {
|
||||||
@@ -1128,15 +1131,6 @@ pub(crate) fn new_mcp_tools_output(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cfg.enabled {
|
|
||||||
let disabled = "(disabled)".red();
|
|
||||||
lines.push(vec![" • Tools: ".into(), disabled.clone()].into());
|
|
||||||
lines.push(vec![" • Resources: ".into(), disabled.clone()].into());
|
|
||||||
lines.push(vec![" • Resource templates: ".into(), disabled].into());
|
|
||||||
lines.push(Line::from(""));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if names.is_empty() {
|
if names.is_empty() {
|
||||||
lines.push(" • Tools: (none)".into());
|
lines.push(" • Tools: (none)".into());
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -441,8 +441,14 @@ startup_timeout_sec = 20
|
|||||||
tool_timeout_sec = 30
|
tool_timeout_sec = 30
|
||||||
# Optional: disable a server without removing it
|
# Optional: disable a server without removing it
|
||||||
enabled = false
|
enabled = false
|
||||||
|
# Optional: only expose a subset of tools from this server
|
||||||
|
enabled_tools = ["search", "summarize"]
|
||||||
|
# Optional: hide specific tools (applied after `enabled_tools`, if set)
|
||||||
|
disabled_tools = ["search"]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When both `enabled_tools` and `disabled_tools` are specified, Codex first restricts the server to the allow-list and then removes any tools that appear in the deny-list.
|
||||||
|
|
||||||
#### Experimental RMCP client
|
#### Experimental RMCP client
|
||||||
|
|
||||||
Codex is transitioning to the [official Rust MCP SDK](https://github.com/modelcontextprotocol/rust-sdk).
|
Codex is transitioning to the [official Rust MCP SDK](https://github.com/modelcontextprotocol/rust-sdk).
|
||||||
@@ -871,6 +877,8 @@ If `forced_chatgpt_workspace_id` is set but `forced_login_method` is not set, AP
|
|||||||
| `mcp_servers.<id>.enabled` | boolean | When false, Codex skips starting the server (default: true). |
|
| `mcp_servers.<id>.enabled` | boolean | When false, Codex skips starting the server (default: true). |
|
||||||
| `mcp_servers.<id>.startup_timeout_sec` | number | Startup timeout in seconds (default: 10). Timeout is applied both for initializing MCP server and initially listing tools. |
|
| `mcp_servers.<id>.startup_timeout_sec` | number | Startup timeout in seconds (default: 10). Timeout is applied both for initializing MCP server and initially listing tools. |
|
||||||
| `mcp_servers.<id>.tool_timeout_sec` | number | Per-tool timeout in seconds (default: 60). Accepts fractional values; omit to use the default. |
|
| `mcp_servers.<id>.tool_timeout_sec` | number | Per-tool timeout in seconds (default: 60). Accepts fractional values; omit to use the default. |
|
||||||
|
| `mcp_servers.<id>.enabled_tools` | array<string> | Restrict the server to the listed tool names. |
|
||||||
|
| `mcp_servers.<id>.disabled_tools` | array<string> | Remove the listed tool names after applying `enabled_tools`, if any. |
|
||||||
| `model_providers.<id>.name` | string | Display name. |
|
| `model_providers.<id>.name` | string | Display name. |
|
||||||
| `model_providers.<id>.base_url` | string | API base URL. |
|
| `model_providers.<id>.base_url` | string | API base URL. |
|
||||||
| `model_providers.<id>.env_key` | string | Env var for API key. |
|
| `model_providers.<id>.env_key` | string | Env var for API key. |
|
||||||
|
|||||||
Reference in New Issue
Block a user