use crate::mcp::McpServersConfig; use fancy_regex::Regex; use indexmap::IndexMap; use log::warn; use std::collections::HashMap; use std::fmt; /// The configuration level that contributed a layer of tool patterns for an /// MCP server, as rendered in diagnostics. #[derive(Debug, Clone, PartialEq, Eq)] pub enum LayerSource { Global, AppConfig, Role(String), Agent(String), Session, Skill(String), Node(String), } impl fmt::Display for LayerSource { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { LayerSource::Global => write!(f, "global (mcp.json)"), LayerSource::AppConfig => write!(f, "config (config.yaml)"), LayerSource::Role(name) => write!(f, "role ({name})"), LayerSource::Agent(name) => write!(f, "agent ({name})"), LayerSource::Session => write!(f, "session (.set)"), LayerSource::Skill(name) => write!(f, "skill ({name})"), LayerSource::Node(id) => write!(f, "node ({id})"), } } } impl LayerSource { /// The bare level keyword, for compact diagnostics. pub fn short_label(&self) -> &'static str { match self { LayerSource::Global => "global", LayerSource::AppConfig => "config", LayerSource::Role(_) => "role", LayerSource::Agent(_) => "agent", LayerSource::Session => "session", LayerSource::Skill(_) => "skill", LayerSource::Node(_) => "node", } } } #[derive(Debug, Clone)] pub struct CompiledPatterns { source: LayerSource, raw: Vec, regexes: Vec, } #[derive(Debug, Clone, Default)] pub struct ToolFilter { layers: Vec, } impl ToolFilter { pub fn push_layer(&mut self, source: LayerSource, patterns: &[String]) { self.layers.push(CompiledPatterns { source, raw: patterns.to_vec(), regexes: patterns.iter().map(|p| compile_glob(p)).collect(), }); } /// Each layer's source and raw patterns, in application order. pub fn layers(&self) -> impl Iterator { self.layers .iter() .map(|layer| (&layer.source, layer.raw.as_slice())) } /// A tool is allowed iff it matches at least one pattern in every layer. pub fn allows(&self, tool: &str) -> bool { self.layers.iter().all(|layer| { layer .regexes .iter() .any(|regex| regex.is_match(tool).unwrap_or(false)) }) } /// The first matching raw pattern per layer, in layer order, or the /// source of the first layer with no match. pub fn allows_explain(&self, tool: &str) -> Result, &LayerSource> { let mut matched = Vec::with_capacity(self.layers.len()); for layer in &self.layers { // fancy_regex can fail at match time (backtracking limits); // treat that as a non-match rather than allowing the tool. match layer .regexes .iter() .position(|regex| regex.is_match(tool).unwrap_or(false)) { Some(index) => matched.push((&layer.source, layer.raw[index].as_str())), None => return Err(&layer.source), } } Ok(matched) } /// Context-layer patterns that match none of the advertised tools /// surviving the global layer — dead weight, usually a typo. pub fn dead_context_patterns<'a>( &'a self, advertised: &[String], ) -> Vec<(&'a LayerSource, &'a str)> { let surviving: Vec<&String> = advertised .iter() .filter(|name| { self.layers .iter() .filter(|layer| layer.source == LayerSource::Global) .all(|layer| { layer .regexes .iter() .any(|regex| regex.is_match(name).unwrap_or(false)) }) }) .collect(); let mut dead = Vec::new(); for layer in self .layers .iter() .filter(|l| l.source != LayerSource::Global) { for (raw, regex) in layer.raw.iter().zip(&layer.regexes) { if !surviving .iter() .any(|name| regex.is_match(name).unwrap_or(false)) { dead.push((&layer.source, raw.as_str())); } } } dead } } /// Translates a glob pattern (`*` = any run of characters, `?` = exactly one) /// into an anchored regex. Patterns that fail to compile match nothing. fn compile_glob(pattern: &str) -> Regex { let translated = format!( "^{}$", fancy_regex::escape(pattern) .replace("\\*", ".*") .replace("\\?", ".") ); Regex::new(&translated).unwrap_or_else(|error| { warn!("Invalid MCP tool pattern '{pattern}': {error}. It will match nothing."); never_matching_regex() }) } fn never_matching_regex() -> Regex { Regex::new("(?!)").expect("'(?!)' is a valid never-matching regex") } pub struct SkillMcpLayer { pub name: String, pub enabled_servers: Vec, pub mcp_tools: IndexMap>, } pub struct McpToolPolicy; impl McpToolPolicy { #[allow(clippy::too_many_arguments)] pub fn effective( mcp_config: &McpServersConfig, session: Option<&IndexMap>>, agent: Option<(&str, &IndexMap>)>, role: Option<(&str, &IndexMap>)>, global: Option<&IndexMap>>, skills: &[SkillMcpLayer], node: Option<(&str, &IndexMap>)>, aliases: &IndexMap, ) -> HashMap { let mut filters: HashMap = HashMap::new(); for (server, spec) in &mcp_config.mcp_servers { if let Some(patterns) = &spec.allowed_tools { filters .entry(server.clone()) .or_default() .push_layer(LayerSource::Global, patterns); } } if let Some(map) = global { push_level( &mut filters, mcp_config, aliases, &LayerSource::AppConfig, map, None, ); } if let Some((name, map)) = role { push_level( &mut filters, mcp_config, aliases, &LayerSource::Role(name.to_string()), map, None, ); } if let Some((name, map)) = agent { push_level( &mut filters, mcp_config, aliases, &LayerSource::Agent(name.to_string()), map, None, ); } if let Some(map) = session { push_level( &mut filters, mcp_config, aliases, &LayerSource::Session, map, None, ); } for skill in skills { push_level( &mut filters, mcp_config, aliases, &LayerSource::Skill(skill.name.clone()), &skill.mcp_tools, Some(&skill.enabled_servers), ); } if let Some((id, map)) = node { push_level( &mut filters, mcp_config, aliases, &LayerSource::Node(id.to_string()), map, None, ); } filters } } fn push_level( filters: &mut HashMap, mcp_config: &McpServersConfig, aliases: &IndexMap, source: &LayerSource, map: &IndexMap>, enabled_servers: Option<&[String]>, ) { for (server, patterns) in expand_server_keys(mcp_config, aliases, map) { if let Some(enabled) = enabled_servers && !enabled.iter().any(|id| id == &server) { continue; } filters .entry(server) .or_default() .push_layer(source.clone(), &patterns); } } /// Expands map keys to server ids: a key that is a server id maps to itself; /// a key that is an alias expands to every configured id in its /// comma-separated value; anything else is dropped. Keys expanding to the /// same server merge their pattern lists. fn expand_server_keys( mcp_config: &McpServersConfig, aliases: &IndexMap, map: &IndexMap>, ) -> IndexMap> { let mut expanded: IndexMap> = IndexMap::new(); for (key, patterns) in map { let key = key.trim(); if mcp_config.mcp_servers.contains_key(key) { expanded .entry(key.to_string()) .or_default() .extend(patterns.iter().cloned()); } else { for mapped_id in expand_mcp_server_alias(aliases, key) { if mcp_config.mcp_servers.contains_key(&mapped_id) { expanded .entry(mapped_id) .or_default() .extend(patterns.iter().cloned()); } } } } expanded } /// Expands a `mapping_mcp_servers` alias key into its comma-separated server /// ids. A key with no alias entry expands to nothing. pub(crate) fn expand_mcp_server_alias( aliases: &IndexMap, key: &str, ) -> Vec { aliases .get(key) .map(|mapped| { mapped .split(',') .map(str::trim) .filter(|id| !id.is_empty()) .map(str::to_string) .collect() }) .unwrap_or_default() } #[cfg(test)] mod tests { use super::*; use crate::mcp::{McpServer, McpServersConfig, McpTransportType}; fn spec(allowed_tools: Option<&[&str]>) -> McpServer { McpServer { transport_type: McpTransportType::Stdio, command: Some("echo".to_string()), args: None, env: None, cwd: None, url: None, headers: None, oauth: None, allowed_tools: allowed_tools.map(list), } } fn config(servers: &[(&str, Option<&[&str]>)]) -> McpServersConfig { McpServersConfig { mcp_servers: servers .iter() .map(|(name, tools)| (name.to_string(), spec(*tools))) .collect(), } } fn list(items: &[&str]) -> Vec { items.iter().map(|s| s.to_string()).collect() } fn tool_map(entries: &[(&str, &[&str])]) -> IndexMap> { entries .iter() .map(|(server, patterns)| (server.to_string(), list(patterns))) .collect() } fn single_layer(patterns: &[&str]) -> ToolFilter { layered(&[(LayerSource::Global, patterns)]) } fn layered(layers: &[(LayerSource, &[&str])]) -> ToolFilter { let mut filter = ToolFilter::default(); for (source, patterns) in layers { filter.push_layer(source.clone(), &list(patterns)); } filter } fn no_aliases() -> IndexMap { IndexMap::new() } fn aliases(entries: &[(&str, &str)]) -> IndexMap { entries .iter() .map(|(key, value)| (key.to_string(), value.to_string())) .collect() } fn resolve( config: &McpServersConfig, session: Option<&IndexMap>>, role: Option<(&str, &IndexMap>)>, ) -> HashMap { McpToolPolicy::effective(config, session, None, role, None, &[], None, &no_aliases()) } #[test] fn literal_pattern_matches_only_the_exact_name() { let filter = single_layer(&["get_issue"]); assert!(filter.allows("get_issue")); assert!(!filter.allows("get_issues")); assert!(!filter.allows("get_issu")); assert!(!filter.allows("xget_issue")); } #[test] fn star_matches_any_run_of_characters() { let filter = single_layer(&["get_*"]); assert!(filter.allows("get_issue")); assert!(filter.allows("get_")); assert!(!filter.allows("set_issue")); let filter = single_layer(&["*_issue"]); assert!(filter.allows("create_issue")); assert!(!filter.allows("create_pr")); let filter = single_layer(&["get*sue"]); assert!(filter.allows("get_issue")); assert!(filter.allows("getsue")); let filter = single_layer(&["*"]); assert!(filter.allows("")); assert!(filter.allows("anything_at_all")); } #[test] fn question_mark_matches_exactly_one_character() { let filter = single_layer(&["get_?"]); assert!(filter.allows("get_a")); assert!(!filter.allows("get_")); assert!(!filter.allows("get_ab")); } #[test] fn regex_metacharacters_are_matched_literally() { let filter = single_layer(&["get.issue"]); assert!(filter.allows("get.issue")); assert!(!filter.allows("getXissue")); for pattern in ["a(b", "a[b", "a+b", "a|b", "a$b"] { let filter = single_layer(&[pattern]); assert!(filter.allows(pattern), "'{pattern}' should match itself"); assert!(!filter.allows("ab"), "'{pattern}' should not match 'ab'"); } } #[test] fn backslash_is_literal_and_star_still_wildcards() { let filter = single_layer(&["a\\b"]); assert!(filter.allows("a\\b")); assert!(!filter.allows("ab")); let filter = single_layer(&["a\\*b"]); assert!(filter.allows("a\\b")); assert!(filter.allows("a\\xyzb")); assert!(!filter.allows("ab")); } #[test] fn the_never_matching_placeholder_matches_nothing() { let regex = never_matching_regex(); assert!(!regex.is_match("").unwrap()); assert!(!regex.is_match("anything").unwrap()); } #[test] fn within_a_layer_any_pattern_may_match() { let filter = single_layer(&["get_*", "set_*"]); assert!(filter.allows("get_x")); assert!(filter.allows("set_x")); assert!(!filter.allows("delete_x")); } #[test] fn across_layers_every_layer_must_match() { let filter = layered(&[ (LayerSource::Global, &["get_*"]), (LayerSource::Session, &["*_issue"]), ]); assert!(filter.allows("get_issue")); assert!(!filter.allows("get_pr")); assert!(!filter.allows("create_issue")); } #[test] fn an_empty_layer_blocks_everything() { let filter = layered(&[(LayerSource::Global, &["*"]), (LayerSource::Session, &[])]); assert!(!filter.allows("anything")); assert_eq!( filter.allows_explain("anything"), Err(&LayerSource::Session) ); } #[test] fn allows_explain_reports_the_first_matching_pattern_per_layer() { let filter = layered(&[ (LayerSource::Global, &["x_*", "get_*"]), (LayerSource::Session, &["*"]), ]); assert_eq!( filter.allows_explain("get_issue").unwrap(), vec![ (&LayerSource::Global, "get_*"), (&LayerSource::Session, "*") ] ); } #[test] fn allows_explain_reports_the_first_layer_without_a_match() { let filter = layered(&[ (LayerSource::Global, &["get_*"]), (LayerSource::Session, &["*"]), ]); assert_eq!( filter.allows_explain("delete_repo"), Err(&LayerSource::Global) ); let filter = layered(&[ (LayerSource::Global, &["*"]), (LayerSource::Session, &["get_*"]), ]); assert_eq!( filter.allows_explain("delete_repo"), Err(&LayerSource::Session) ); } #[test] fn global_allowed_tools_from_mcp_json_is_the_first_layer() { let config = config(&[("gh", Some(&["get_*"]))]); let session_map = tool_map(&[("gh", &["*"])]); let filters = resolve(&config, Some(&session_map), None); assert_eq!( filters["gh"].allows_explain("get_issue").unwrap(), vec![ (&LayerSource::Global, "get_*"), (&LayerSource::Session, "*") ] ); } #[test] fn servers_without_patterns_at_any_level_are_absent() { let config = config(&[("gh", None)]); let filters = resolve(&config, None, None); assert!(filters.is_empty()); } #[test] fn server_absent_from_a_level_map_gets_no_layer_from_it() { let config = config(&[("gh", Some(&["get_*"])), ("gl", None)]); let role_map = tool_map(&[("gl", &["x_*"])]); let filters = resolve(&config, None, Some(("dev", &role_map))); assert!(filters["gh"].allows("get_issue")); assert!(!filters["gh"].allows("delete_repo")); assert_eq!(filters["gh"].allows_explain("get_issue").unwrap().len(), 1); assert!(filters["gl"].allows("x_1")); assert!(!filters["gl"].allows("y_1")); } #[test] fn empty_pattern_list_at_a_level_blocks_all_tools_for_that_server() { let config = config(&[("gh", Some(&["get_*"]))]); let session_map = tool_map(&[("gh", &[])]); let filters = resolve(&config, Some(&session_map), None); assert!(!filters["gh"].allows("get_issue")); assert_eq!( filters["gh"].allows_explain("get_issue"), Err(&LayerSource::Session) ); } #[test] fn session_cannot_widen_a_role_restriction() { let config = config(&[("gh", None)]); let role_map = tool_map(&[("gh", &["get_*"])]); let session_map = tool_map(&[("gh", &["*"])]); let filters = resolve(&config, Some(&session_map), Some(("dev", &role_map))); assert!(filters["gh"].allows("get_issue")); assert!(!filters["gh"].allows("delete_repo")); } #[test] fn app_config_map_contributes_its_own_layer() { let config = config(&[("gh", None)]); let app_map = tool_map(&[("gh", &["get_*"])]); let filters = McpToolPolicy::effective( &config, None, None, None, Some(&app_map), &[], None, &no_aliases(), ); assert_eq!( filters["gh"].allows_explain("get_issue").unwrap(), vec![(&LayerSource::AppConfig, "get_*")] ); assert!(!filters["gh"].allows("delete_repo")); } #[test] fn skill_layer_applies_only_to_its_enabled_servers() { let config = config(&[("gh", None), ("gl", None)]); let skill = SkillMcpLayer { name: "reviewer".to_string(), enabled_servers: vec!["gh".to_string()], mcp_tools: tool_map(&[("gh", &["get_*"]), ("gl", &["*"])]), }; let filters = McpToolPolicy::effective( &config, None, None, None, None, &[skill], None, &no_aliases(), ); assert!(filters.contains_key("gh")); assert!(!filters.contains_key("gl")); } #[test] fn two_skills_naming_the_same_server_stack_independent_layers() { let config = config(&[("gh", None)]); let skills = vec![ SkillMcpLayer { name: "a".to_string(), enabled_servers: vec!["gh".to_string()], mcp_tools: tool_map(&[("gh", &["get_*"])]), }, SkillMcpLayer { name: "b".to_string(), enabled_servers: vec!["gh".to_string()], mcp_tools: tool_map(&[("gh", &["*_issue"])]), }, ]; let filters = McpToolPolicy::effective( &config, None, None, None, None, &skills, None, &no_aliases(), ); assert!(filters["gh"].allows("get_issue")); assert!(!filters["gh"].allows("get_pr")); assert!(!filters["gh"].allows("create_issue")); assert_eq!(filters["gh"].allows_explain("get_issue").unwrap().len(), 2); } #[test] fn layers_stack_in_documented_order_with_node_last() { let config = config(&[("gh", Some(&["*"]))]); let app_map = tool_map(&[("gh", &["*"])]); let role_map = tool_map(&[("gh", &["*"])]); let agent_map = tool_map(&[("gh", &["*"])]); let session_map = tool_map(&[("gh", &["*"])]); let skills = vec![SkillMcpLayer { name: "reviewer".to_string(), enabled_servers: vec!["gh".to_string()], mcp_tools: tool_map(&[("gh", &["*"])]), }]; let node_map = tool_map(&[("gh", &["*"])]); let filters = McpToolPolicy::effective( &config, Some(&session_map), Some(("worker", &agent_map)), Some(("dev", &role_map)), Some(&app_map), &skills, Some(("n1", &node_map)), &no_aliases(), ); let sources: Vec = filters["gh"] .allows_explain("anything") .unwrap() .iter() .map(|(source, _)| source.to_string()) .collect(); assert_eq!( sources, vec![ "global (mcp.json)", "config (config.yaml)", "role (dev)", "agent (worker)", "session (.set)", "skill (reviewer)", "node (n1)", ] ); } #[test] fn alias_key_expands_to_all_mapped_servers() { let config = config(&[("github", None), ("gitlab", None)]); let role_map = tool_map(&[("gh", &["get_*"])]); let filters = McpToolPolicy::effective( &config, None, None, Some(("dev", &role_map)), None, &[], None, &aliases(&[("gh", "github,gitlab")]), ); assert!(filters["github"].allows("get_issue")); assert!(!filters["github"].allows("delete_repo")); assert!(filters["gitlab"].allows("get_issue")); assert!(!filters["gitlab"].allows("delete_repo")); } #[test] fn alias_ids_missing_from_the_config_are_skipped() { let config = config(&[("github", None)]); let role_map = tool_map(&[("gh", &["get_*"])]); let filters = McpToolPolicy::effective( &config, None, None, Some(("dev", &role_map)), None, &[], None, &aliases(&[("gh", "github,missing")]), ); assert_eq!(filters.len(), 1); assert!(filters.contains_key("github")); } #[test] fn unknown_map_keys_are_dropped() { let config = config(&[("github", None)]); let role_map = tool_map(&[("nope", &["get_*"])]); let filters = resolve(&config, None, Some(("dev", &role_map))); assert!(filters.is_empty()); } #[test] fn alias_and_direct_key_for_the_same_server_merge_into_one_layer() { let config = config(&[("github", None)]); let role_map = tool_map(&[("gh", &["get_*"]), ("github", &["set_*"])]); let filters = McpToolPolicy::effective( &config, None, None, Some(("dev", &role_map)), None, &[], None, &aliases(&[("gh", "github")]), ); assert!(filters["github"].allows("get_issue")); assert!(filters["github"].allows("set_topic")); assert!(!filters["github"].allows("delete_repo")); assert_eq!( filters["github"].allows_explain("get_issue").unwrap().len(), 1 ); } #[test] fn layer_source_display() { assert_eq!(LayerSource::Global.to_string(), "global (mcp.json)"); assert_eq!(LayerSource::AppConfig.to_string(), "config (config.yaml)"); assert_eq!(LayerSource::Role("dev".into()).to_string(), "role (dev)"); assert_eq!( LayerSource::Agent("worker".into()).to_string(), "agent (worker)" ); assert_eq!(LayerSource::Session.to_string(), "session (.set)"); assert_eq!( LayerSource::Skill("review".into()).to_string(), "skill (review)" ); assert_eq!(LayerSource::Node("n1".into()).to_string(), "node (n1)"); } #[test] fn dead_context_patterns_flags_patterns_matching_nothing() { let filter = layered(&[ (LayerSource::Global, &["get_*"]), (LayerSource::Role("dev".into()), &["get_issue", "set_*"]), ]); let advertised = vec!["get_issue".to_string(), "set_topic".to_string()]; let dead = filter.dead_context_patterns(&advertised); // set_* only matches set_topic, which the global layer hides. assert_eq!(dead, vec![(&LayerSource::Role("dev".into()), "set_*")]); } #[test] fn dead_context_patterns_is_empty_when_every_pattern_is_live() { let filter = layered(&[ (LayerSource::Global, &["get_*"]), (LayerSource::Session, &["get_issue"]), ]); let advertised = vec!["get_issue".to_string()]; assert!(filter.dead_context_patterns(&advertised).is_empty()); } #[test] fn dead_context_patterns_ignores_the_global_layer_itself() { let filter = layered(&[(LayerSource::Global, &["zzz_*"])]); let advertised = vec!["get_issue".to_string()]; assert!(filter.dead_context_patterns(&advertised).is_empty()); } #[test] fn expand_mcp_server_alias_splits_and_trims() { let aliases = aliases(&[("gh", "github, gitlab,")]); assert_eq!( expand_mcp_server_alias(&aliases, "gh"), vec!["github".to_string(), "gitlab".to_string()] ); assert!(expand_mcp_server_alias(&aliases, "nope").is_empty()); } }