feat(mcp): add mcp_tools allowlist config surfaces across roles, sessions, agents, graphs, and skills

This commit is contained in:
2026-08-27 14:13:35 -06:00
parent 5711432ac4
commit a92ebb5b94
9 changed files with 480 additions and 0 deletions
+97
View File
@@ -30,6 +30,7 @@ pub trait RoleLike {
fn top_p(&self) -> Option<f64>;
fn enabled_tools(&self) -> Option<Vec<String>>;
fn enabled_mcp_servers(&self) -> Option<Vec<String>>;
fn mcp_tools(&self) -> Option<IndexMap<String, Vec<String>>>;
fn set_model(&mut self, model: Model);
fn set_temperature(&mut self, value: Option<f64>);
fn reasoning_effort(&self) -> Option<String>;
@@ -37,6 +38,7 @@ pub trait RoleLike {
fn set_reasoning_effort(&mut self, value: Option<String>);
fn set_enabled_tools(&mut self, value: Option<Vec<String>>);
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>);
fn set_mcp_tools(&mut self, value: Option<IndexMap<String, Vec<String>>>);
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -67,6 +69,8 @@ pub struct Role {
deserialize_with = "super::deserialize_csv_or_vec"
)]
enabled_mcp_servers: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
mcp_tools: Option<IndexMap<String, Vec<String>>>,
#[serde(skip_serializing_if = "Option::is_none")]
skills_enabled: Option<bool>,
#[serde(
@@ -133,6 +137,7 @@ impl Role {
"enabled_mcp_servers" => {
role.enabled_mcp_servers = parse_string_or_array(value)
}
"mcp_tools" => role.mcp_tools = parse_mcp_tools_map(value),
"skills_enabled" => role.skills_enabled = value.as_bool(),
"enabled_skills" => role.enabled_skills = parse_string_or_array(value),
"enabled_macros" => role.enabled_macros = parse_string_or_array(value),
@@ -196,6 +201,10 @@ impl Role {
serde_json::to_string(enabled_mcp_servers).unwrap_or_else(|_| "[]".to_string());
metadata.push(format!("enabled_mcp_servers: {inline}"));
}
if let Some(mcp_tools) = &self.mcp_tools {
let inline = serde_json::to_string(mcp_tools).unwrap_or_else(|_| "{}".to_string());
metadata.push(format!("mcp_tools: {inline}"));
}
if let Some(skills_enabled) = self.skills_enabled {
metadata.push(format!("skills_enabled: {skills_enabled}"));
}
@@ -279,6 +288,10 @@ impl Role {
enabled_tools,
enabled_mcp_servers,
);
let mcp_tools = role_like.mcp_tools();
if mcp_tools.is_some() {
self.set_mcp_tools(mcp_tools);
}
}
pub fn batch_set(
@@ -453,6 +466,10 @@ impl RoleLike for Role {
self.enabled_mcp_servers.clone()
}
fn mcp_tools(&self) -> Option<IndexMap<String, Vec<String>>> {
self.mcp_tools.clone()
}
fn set_model(&mut self, model: Model) {
if !self.model().id().is_empty() {
self.model_id = Some(model.id().to_string());
@@ -479,6 +496,10 @@ impl RoleLike for Role {
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>) {
self.enabled_mcp_servers = value;
}
fn set_mcp_tools(&mut self, value: Option<IndexMap<String, Vec<String>>>) {
self.mcp_tools = value;
}
}
fn parse_string_or_array(value: &Value) -> Option<Vec<String>> {
@@ -503,6 +524,19 @@ fn parse_string_or_array(value: &Value) -> Option<Vec<String>> {
None
}
fn parse_mcp_tools_map(value: &Value) -> Option<IndexMap<String, Vec<String>>> {
let map = value.as_object()?;
let mut mcp_tools = IndexMap::new();
for (server, tools) in map {
if tools.is_null() {
mcp_tools.insert(server.clone(), Vec::new());
} else if let Some(tools) = parse_string_or_array(tools) {
mcp_tools.insert(server.clone(), tools);
}
}
Some(mcp_tools)
}
fn parse_structure_prompt(prompt: &str) -> (&str, Vec<(&str, &str)>) {
let mut text = prompt;
let mut search_input = true;
@@ -652,6 +686,69 @@ mod tests {
assert_eq!(role.enabled_macros, None);
}
#[test]
fn role_new_parses_mcp_tools_list_and_csv_values() {
let content = "---\nmcp_tools:\n github: [get_*, list_*, search_code]\n slack: conversations_history,conversations_replies\n---\nPrompt";
let role = Role::new("test", content);
let mcp_tools = role.mcp_tools().unwrap();
assert_eq!(
mcp_tools.get("github"),
Some(&vec![
"get_*".to_string(),
"list_*".to_string(),
"search_code".to_string()
])
);
assert_eq!(
mcp_tools.get("slack"),
Some(&vec![
"conversations_history".to_string(),
"conversations_replies".to_string()
])
);
}
#[test]
fn role_new_mcp_tools_empty_list_server_is_some_empty() {
let role = Role::new("test", "---\nmcp_tools:\n github: []\n---\nPrompt");
assert_eq!(role.mcp_tools().unwrap().get("github"), Some(&vec![]));
}
#[test]
fn role_new_mcp_tools_per_server_null_is_some_empty() {
let role = Role::new("test", "---\nmcp_tools:\n github:\n---\nPrompt");
assert_eq!(role.mcp_tools().unwrap().get("github"), Some(&vec![]));
}
#[test]
fn role_new_mcp_tools_absent_is_none() {
let role = Role::new("test", "---\ntemperature: 0.5\n---\nPrompt");
assert_eq!(role.mcp_tools(), None);
}
#[test]
fn role_new_mcp_tools_null_is_none() {
let role = Role::new("test", "---\nmcp_tools: null\n---\nPrompt");
assert_eq!(role.mcp_tools(), None);
}
#[test]
fn role_export_mcp_tools_round_trips() {
let content = "---\nmcp_tools:\n github: [get_issue]\n slack: a,b\n---\nPrompt";
let role = Role::new("test", content);
let reparsed = Role::new("test", &role.export());
assert_eq!(reparsed.mcp_tools(), role.mcp_tools());
assert!(role.mcp_tools().is_some());
}
#[test]
fn role_export_includes_enabled_macros() {
let role = Role::new("test", "---\nenabled_macros: [a]\n---\nPrompt");