From 60f573346d92501610091c3717d6dbf6b27d0443 Mon Sep 17 00:00:00 2001 From: vigneshnrfs Date: Sun, 29 Mar 2026 13:26:35 +0530 Subject: [PATCH] feat: add wildcard support for tool capabilities Implement prefix wildcard matching in capabilities.tools to allow patterns like 'mcp_filesystem_*' to grant permission to all matching MCP tools. Changes: - Add tool_matches_pattern() helper supporting exact match, prefix wildcard (*), and universal wildcard (*) - Update builtin tools, skill tools, MCP tools, and profile-based filtering - Add unit tests in kernel.rs (5 new tests) - Update docs: troubleshooting.md, getting-started.md, agent-templates.md, mcp-a2a.md with wildcard syntax examples This resolves the common issue where agents can't use MCP tools even after configuring mcp_servers, because tool permissions require explicit grants. --- crates/openfang-kernel/src/kernel.rs | 130 +++++++++++++++++++++++---- docs/agent-templates.md | 1 + docs/getting-started.md | 2 +- docs/mcp-a2a.md | 16 ++++ docs/troubleshooting.md | 8 ++ 5 files changed, 137 insertions(+), 20 deletions(-) diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index f449addac8..5e5256038b 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -502,6 +502,25 @@ fn gethostname() -> Option { } } +/// Check if a tool name matches a pattern, supporting exact matches and prefix wildcards (ending with *). +/// Examples: +/// "exact_tool" matches "exact_tool" +/// "prefix_*" matches "prefix_anything" +/// "*" matches everything +pub(crate) fn tool_matches_pattern(pattern: &str, tool_name: &str) -> bool { + if pattern == "*" { + return true; + } + if pattern == tool_name { + return true; + } + if let Some(prefix) = pattern.strip_suffix('*') { + let prefix = prefix.strip_suffix('_').unwrap_or(prefix); + return tool_name.starts_with(prefix); + } + false +} + impl OpenFangKernel { /// Boot the kernel with configuration from the given path. pub fn boot(config_path: Option<&Path>) -> KernelResult { @@ -5097,12 +5116,12 @@ impl OpenFangKernel { caps.iter().any(|c| matches!(c, Capability::ToolAll)) }); - let mut all_tools: Vec = if !tools_unrestricted { - // Agent declares specific tools — only include matching builtins - all_builtins - .into_iter() - .filter(|t| declared_tools.iter().any(|d| d == &t.name)) - .collect() + let mut all_tools: Vec = if !tools_unrestricted { + // Agent declares specific tools — only include matching builtins + all_builtins + .into_iter() + .filter(|t| declared_tools.iter().any(|d| tool_matches_pattern(d, &t.name))) + .collect() } else { // No specific tools declared — fall back to profile or all builtins match &tool_profile { @@ -5112,7 +5131,7 @@ impl OpenFangKernel { let allowed = profile.tools(); all_builtins .into_iter() - .filter(|t| allowed.iter().any(|a| a == "*" || a == &t.name)) + .filter(|t| allowed.iter().any(|a| a == "*" || tool_matches_pattern(a, &t.name))) .collect() } _ if has_tool_all => all_builtins, @@ -5141,11 +5160,11 @@ impl OpenFangKernel { registry.tool_definitions_for_skills(&skill_allowlist) } }; - for skill_tool in skill_tools { - // If agent declares specific tools, only include matching skill tools - if !tools_unrestricted && !declared_tools.iter().any(|d| d == &skill_tool.name) { - continue; - } + for skill_tool in skill_tools { + // If agent declares specific tools, only include matching skill tools + if !tools_unrestricted && !declared_tools.iter().any(|d| tool_matches_pattern(d, &skill_tool.name)) { + continue; + } all_tools.push(ToolDefinition { name: skill_tool.name.clone(), description: skill_tool.description.clone(), @@ -5173,13 +5192,13 @@ impl OpenFangKernel { .cloned() .collect() }; - for t in mcp_candidates { - // If agent declares specific tools, only include matching MCP tools - if !tools_unrestricted && !declared_tools.iter().any(|d| d == &t.name) { - continue; - } - all_tools.push(t); - } + for t in mcp_candidates { + // If agent declares specific tools, only include matching MCP tools + if !tools_unrestricted && !declared_tools.iter().any(|d| tool_matches_pattern(d, &t.name)) { + continue; + } + all_tools.push(t); + } } // Step 4: Apply per-agent tool_allowlist/tool_blocklist overrides. @@ -6844,4 +6863,77 @@ mod tests { kernel.shutdown(); } + + #[test] + fn test_tool_matches_pattern_exact() { + assert!(tool_matches_pattern("exact_tool", "exact_tool")); + assert!(!tool_matches_pattern("exact_tool", "different_tool")); + } + + #[test] + fn test_tool_matches_pattern_prefix_wildcard() { + assert!(tool_matches_pattern("prefix_*", "prefix_anything")); + assert!(tool_matches_pattern("prefix_*", "prefix_")); + assert!(tool_matches_pattern("prefix_*", "prefix")); + assert!(!tool_matches_pattern("prefix_*", "different_prefix_")); + assert!(!tool_matches_pattern("prefix_*", "suffix_prefix")); + } + + #[test] + fn test_tool_matches_pattern_star() { + assert!(tool_matches_pattern("*", "anything")); + assert!(tool_matches_pattern("*", "")); + assert!(tool_matches_pattern("*", "exact_tool")); + assert!(tool_matches_pattern("*", "prefix_*")); + } + + #[test] + fn test_tool_matches_pattern_edge_cases() { + assert!(!tool_matches_pattern("", "nonempty")); + assert!(tool_matches_pattern("", "")); + assert!(!tool_matches_pattern("nomatch*", "prefix")); + } + + #[test] + fn test_tool_matches_pattern_with_mcp_tools() { + let mcp_tools = vec![ + ToolDefinition { + name: "mcp_filesystem_list_allowed_directories".to_string(), + description: "List allowed directories".to_string(), + input_schema: serde_json::json!({}), + }, + ToolDefinition { + name: "mcp_filesystem_read_file".to_string(), + description: "Read a file".to_string(), + input_schema: serde_json::json!({}), + }, + ToolDefinition { + name: "mcp_github_create_issue".to_string(), + description: "Create a GitHub issue".to_string(), + input_schema: serde_json::json!({}), + }, + ]; + + #[allow(clippy::useless_vec)] + let caps = vec![Capability::ToolInvoke("mcp_filesystem_*".to_string())]; + + let available: Vec = mcp_tools + .into_iter() + .filter(|t| { + caps.iter().any(|cap| { + if let Capability::ToolInvoke(pattern) = cap { + tool_matches_pattern(pattern, &t.name) + } else { + false + } + }) + }) + .collect(); + + let tool_names: Vec<_> = available.iter().map(|t| t.name.as_str()).collect(); + + assert!(tool_names.contains(&"mcp_filesystem_list_allowed_directories")); + assert!(tool_names.contains(&"mcp_filesystem_read_file")); + assert!(!tool_names.contains(&"mcp_github_create_issue")); + } } diff --git a/docs/agent-templates.md b/docs/agent-templates.md index 51bdd5d750..62ef28550b 100644 --- a/docs/agent-templates.md +++ b/docs/agent-templates.md @@ -847,6 +847,7 @@ max_concurrent_tools = 5 # Max parallel tool executions tools = ["file_read", "file_write", "file_list", "shell_exec", "memory_store", "memory_recall", "web_fetch", "agent_send", "agent_list", "agent_spawn", "agent_kill"] + # Or use wildcards: "mcp_filesystem_*", "mcp_*" for MCP tools network = ["*"] # Network access patterns memory_read = ["*"] # Memory namespaces agent can read memory_write = ["self.*"] # Memory namespaces agent can write diff --git a/docs/getting-started.md b/docs/getting-started.md index a06379f1da..142e7bf81b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -187,7 +187,7 @@ provider = "groq" model = "llama-3.3-70b-versatile" [capabilities] -tools = ["file_read", "file_list", "web_fetch"] +tools = ["file_read", "file_list", "web_fetch"] # Or use wildcards: "mcp_*", "mcp_filesystem_*" memory_read = ["*"] memory_write = ["self.*"] ``` diff --git a/docs/mcp-a2a.md b/docs/mcp-a2a.md index 47789a68b0..16a3a13cb6 100644 --- a/docs/mcp-a2a.md +++ b/docs/mcp-a2a.md @@ -106,6 +106,22 @@ Helper functions (exported from `openfang_runtime::mcp`): - `is_mcp_tool(name)` -- checks if a tool name starts with `mcp_` - `extract_mcp_server(tool_name)` -- extracts the server name from a namespaced tool +#### Granting MCP Tool Access + +MCP tools are namespaced as `mcp_{server}_{tool}` and require explicit capability grants in the agent manifest. Use wildcards to grant access to all tools from a server: + +```toml +[capabilities] +# Grant all filesystem MCP tools +tools = ["mcp_filesystem_*"] + +# Grant all MCP tools from any server +tools = ["mcp_*"] + +# Grant specific MCP tools +tools = ["mcp_github_create_issue", "mcp_github_search_repos"] +``` + #### Auto-Connection on Kernel Boot When the kernel starts (`start_background_agents()`), it checks `config.mcp_servers`. If any are configured, it spawns a background task that calls `connect_mcp_servers()`. This method: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0f21ae0af7..11dc8be80f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -318,8 +318,16 @@ Auto-compaction is enabled by default when the session reaches the threshold (co tools = ["file_read", "web_fetch", "shell_exec"] # Must list each tool # OR # tools = ["*"] # Grant all tools (use with caution) +# OR use wildcards to match multiple tools: +# tools = ["mcp_filesystem_*"] # Grants all filesystem MCP tools +# tools = ["mcp_*"] # Grants all MCP tools (any server) ``` +**Note on MCP tools**: MCP tools are namespaced as `mcp_{server}_{tool}` (e.g., `mcp_filesystem_read_file`). Use wildcards to grant access without listing each tool: +- `mcp_filesystem_*` - all filesystem server tools +- `mcp_github_*` - all GitHub server tools +- `mcp_*` - all MCP tools from any server + ### "Permission denied" errors in agent responses **Cause**: The agent is trying to use a tool or access a resource not in its capabilities.