Merge pull request #21 from Dark-Alex-17/feat/mcp-tool-whitelist
CI / All (ubuntu-latest) (push) Failing after 29s
CI / All (macos-latest) (push) Canceled after 0s
CI / All (windows-latest) (push) Canceled after 0s

feat(mcp): per-server MCP tool allowlists with glob support
This commit is contained in:
Alex Clarke
2026-08-28 12:56:42 -06:00
committed by GitHub
29 changed files with 3233 additions and 304 deletions
Generated
+23
View File
@@ -1685,6 +1685,7 @@ dependencies = [
"colored", "colored",
"comfy-table", "comfy-table",
"crossterm 0.29.0", "crossterm 0.29.0",
"ctor",
"dirs", "dirs",
"duckdb", "duckdb",
"duct", "duct",
@@ -1907,6 +1908,16 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "ctor"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
dependencies = [
"link-section",
"linktime-proc-macro",
]
[[package]] [[package]]
name = "ctutils" name = "ctutils"
version = "0.4.2" version = "0.4.2"
@@ -3833,6 +3844,18 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "link-section"
version = "0.19.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9"
[[package]]
name = "linktime-proc-macro"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.4.15" version = "0.4.15"
+1
View File
@@ -142,6 +142,7 @@ arboard = { version = "3.3.0", default-features = false }
libc = "0.2" libc = "0.2"
[dev-dependencies] [dev-dependencies]
ctor = "1.0.13"
pretty_assertions = "1.4.0" pretty_assertions = "1.4.0"
rmcp = { version = "3.1.2", features = ["server"] } rmcp = { version = "3.1.2", features = ["server"] }
serial_test = "3" serial_test = "3"
+18 -8
View File
@@ -14,7 +14,8 @@
model: openai:gpt-4o # Specify the LLM to use model: openai:gpt-4o # Specify the LLM to use
temperature: null # Set default temperature parameter, range (0, 1) temperature: null # Set default temperature parameter, range (0, 1)
top_p: null # Set default top-p parameter, with a range of (0, 1) or (0, 2) depending on the model top_p: null # Set default top-p parameter, with a range of (0, 1) or (0, 2) depending on the model
reasoning_effort: null # Reasoning effort level for models that support it (e.g. low, medium, high). reasoning_effort:
null # Reasoning effort level for models that support it (e.g. low, medium, high).
# Only valid when the agent's model declares reasoning_levels. # Only valid when the agent's model declares reasoning_levels.
agent_session: null # Set a session to use when starting the agent. (e.g. temp, default); defaults to globally set agent_session agent_session: null # Set a session to use when starting the agent. (e.g. temp, default); defaults to globally set agent_session
name: <agent-name> # Name of the agent, used in the UI and logs name: <agent-name> # Name of the agent, used in the UI and logs
@@ -37,11 +38,12 @@ can_spawn_agents: false # Enable the agent to spawn child agents
# - explore # If omitted (the default), ALL installed agents are spawnable. This is the unrestricted default. # - explore # If omitted (the default), ALL installed agents are spawnable. This is the unrestricted default.
# - coder # Provide a list to restrict. Match is exact and case-sensitive (use directory names). # - coder # Provide a list to restrict. Match is exact and case-sensitive (use directory names).
# - oracle # An empty list ([]) means literally nothing spawnable. # - oracle # An empty list ([]) means literally nothing spawnable.
# Also filters `agent__list_available` output so the LLM only sees what it can spawn. # Also filters `agent__list_available` output so the LLM only sees what it can spawn.
# Graph agents (graph.yaml) ignore this; they declare spawn targets in agent nodes. # Graph agents (graph.yaml) ignore this; they declare spawn targets in agent nodes.
max_concurrent_agents: 4 # Maximum number of agents that can run simultaneously max_concurrent_agents: 4 # Maximum number of agents that can run simultaneously
max_agent_depth: 3 # Maximum nesting depth for sub-agents (prevents runaway spawning) max_agent_depth: 3 # Maximum nesting depth for sub-agents (prevents runaway spawning)
max_concurrent_jobs: 5 # Max background jobs (`job__*` tools) running at once for this agent max_concurrent_jobs:
5 # Max background jobs (`job__*` tools) running at once for this agent
# (overrides the global setting; 0 disables background jobs for this agent) # (overrides the global setting; 0 disables background jobs for this agent)
inject_spawn_instructions: true # Inject the default agent spawning instructions into the agent's system prompt inject_spawn_instructions: true # Inject the default agent spawning instructions into the agent's system prompt
summarization_model: null # Model to use for summarizing sub-agent output (e.g. 'openai:gpt-4o-mini'); defaults to current model summarization_model: null # Model to use for summarizing sub-agent output (e.g. 'openai:gpt-4o-mini'); defaults to current model
@@ -49,26 +51,34 @@ summarization_threshold: 4000 # Character threshold above which sub-agent out
escalation_timeout: 300 # Seconds a sub-agent waits for a user interaction response before timing out (default: 5 minutes) escalation_timeout: 300 # Seconds a sub-agent waits for a user interaction response before timing out (default: 5 minutes)
mcp_servers: # Optional list of MCP servers that the agent utilizes mcp_servers: # Optional list of MCP servers that the agent utilizes
- github # Corresponds to the name of an MCP server in the `<coyote-config-dir>/mcp.json` file - github # Corresponds to the name of an MCP server in the `<coyote-config-dir>/mcp.json` file
mcp_tools: # Optional per-server tool allowlist for the agent's MCP servers
github: # (glob patterns: * and ?). Intersects with the global config,
- get_* # mcp.json `allowedTools`, and every other configured layer. It
- search_* # can only narrow access, never widen it.
global_tools: # Optional list of additional global tools to enable for the agent; i.e. not tools specific to the agent global_tools: # Optional list of additional global tools to enable for the agent; i.e. not tools specific to the agent
- web_search - web_search
- fs - fs
- python - python
skills_enabled: true # Master switch for skills in this agent (default: inherit from global). skills_enabled:
true # Master switch for skills in this agent (default: inherit from global).
# Skills also require `function_calling_support: true` in the global config. # Skills also require `function_calling_support: true` in the global config.
enabled_skills: # Optional list of skills available when this agent runs. enabled_skills: # Optional list of skills available when this agent runs.
# Must be a subset of global `visible_skills`. Omit to inherit the global default. # Must be a subset of global `visible_skills`. Omit to inherit the global default.
- git-master - git-master
- ai-slop-remover - ai-slop-remover
inject_skill_instructions: true # Inject a short hint pointing the model at `skill__list` when skills are enabled inject_skill_instructions:
true # Inject a short hint pointing the model at `skill__list` when skills are enabled
# (default: true). Suppressed automatically when no skills are available. # (default: true). Suppressed automatically when no skills are available.
skill_instructions: null # Custom text for the skill hint (optional; uses built-in default if null) skill_instructions: null # Custom text for the skill hint (optional; uses built-in default if null)
enabled_macros: # Optional list of macros invocable when this agent is active in the REPL. enabled_macros: # Optional list of macros invocable when this agent is active in the REPL.
- generate-commit-message # An empty list disables all macros. Omit to inherit the role/global default. - generate-commit-message # An empty list disables all macros. Omit to inherit the role/global default.
memory: null # Per-agent memory override (default: inherit). Set to `false` to disable memory memory:
null # Per-agent memory override (default: inherit). Set to `false` to disable memory
# for this agent regardless of workspace/global presence. See the Memory wiki page. # for this agent regardless of workspace/global presence. See the Memory wiki page.
dynamic_instructions: false # Whether to use dynamic instructions for the agent; if false, static instructions are used dynamic_instructions: false # Whether to use dynamic instructions for the agent; if false, static instructions are used
instructions: | # Static instructions for the agent; ignored if dynamic instructions are used instructions:
| # Static instructions for the agent; ignored if dynamic instructions are used
You are a AI agent designed to demonstrate agent capabilities. You are a AI agent designed to demonstrate agent capabilities.
<tools> <tools>
+65 -38
View File
@@ -2,7 +2,8 @@
model: openai:gpt-4o # Specify the LLM to use model: openai:gpt-4o # Specify the LLM to use
temperature: null # Set default temperature parameter (0, 1) temperature: null # Set default temperature parameter (0, 1)
top_p: null # Set default top-p parameter, with a range of (0, 1) or (0, 2) depending on the model top_p: null # Set default top-p parameter, with a range of (0, 1) or (0, 2) depending on the model
reasoning_effort: null # Reasoning effort level for models that support it (e.g. low, medium, high). reasoning_effort:
null # Reasoning effort level for models that support it (e.g. low, medium, high).
# Only valid when the active model declares reasoning_levels. See the Clients docs. # Only valid when the active model declares reasoning_levels. See the Clients docs.
# ---- Behavior ---- # ---- Behavior ----
@@ -31,10 +32,8 @@ sync_models_url: > # URL to sync model changes from
# ---- REPL Prompt ---- # ---- REPL Prompt ----
# Custom REPL left/right prompts; see the [REPL Prompt Documentation](https://github.com/Dark-Alex-17/coyote/wiki/REPL-Prompt) for more information # Custom REPL left/right prompts; see the [REPL Prompt Documentation](https://github.com/Dark-Alex-17/coyote/wiki/REPL-Prompt) for more information
left_prompt: left_prompt: '{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} '
'{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} ' right_prompt: '{color.cyan}{?reasoning_effort [{reasoning_effort}] }{color.purple}{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}'
right_prompt:
'{color.cyan}{?reasoning_effort [{reasoning_effort}] }{color.purple}{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}'
# ---- Vault ---- # ---- Vault ----
# See the [Vault documentation](https://github.com/Dark-Alex-17/coyote/wiki/Vault) for more information on the Coyote vault. # See the [Vault documentation](https://github.com/Dark-Alex-17/coyote/wiki/Vault) for more information on the Coyote vault.
@@ -85,7 +84,8 @@ vault_password_file: null # Path to a file containing the password for th
function_calling_support: true # Enables or disables function calling (Globally). function_calling_support: true # Enables or disables function calling (Globally).
mapping_tools: # Alias for a tool or toolset mapping_tools: # Alias for a tool or toolset
fs: 'fs_cat,fs_ls,fs_mkdir,fs_rm,fs_write,fs_read,fs_glob,fs_grep' fs: 'fs_cat,fs_ls,fs_mkdir,fs_rm,fs_write,fs_read,fs_glob,fs_grep'
enabled_tools: null # Which tools to enable by default. enabled_tools:
null # Which tools to enable by default.
# Accepts either a YAML list or a comma-separated string. Use 'all' to enable everything. # Accepts either a YAML list or a comma-separated string. Use 'all' to enable everything.
# Example (list form): # Example (list form):
# enabled_tools: # enabled_tools:
@@ -94,26 +94,26 @@ enabled_tools: null # Which tools to enable by default.
# Example (comma-separated form): # Example (comma-separated form):
# enabled_tools: fs,web_search_coyote # enabled_tools: fs,web_search_coyote
visible_tools: # Which tools are visible to be compiled (and are thus able to be defined in 'enabled_tools') visible_tools: # Which tools are visible to be compiled (and are thus able to be defined in 'enabled_tools')
# - ast_grep.sh # - ast_grep.sh
# - demo_py.py # - demo_py.py
# - demo_sh.sh # - demo_sh.sh
# - demo_ts.ts # - demo_ts.ts
- execute_command.sh - execute_command.sh
# - execute_py_code.py # - execute_py_code.py
# - execute_sql_code.sh # - execute_sql_code.sh
# - fetch_url_via_curl.sh # - fetch_url_via_curl.sh
# - fetch_url_via_jina.sh # - fetch_url_via_jina.sh
- fs_cat.sh - fs_cat.sh
- fs_ls.sh - fs_ls.sh
# - fs_read.sh # - fs_read.sh
# - fs_glob.sh # - fs_glob.sh
# - fs_grep.sh # - fs_grep.sh
# - fs_mkdir.sh # - fs_mkdir.sh
# - fs_patch.sh # - fs_patch.sh
# - fs_write.sh # - fs_write.sh
- get_current_time.sh - get_current_time.sh
# - get_current_weather.py # - get_current_weather.py
# - get_current_weather.ts # - get_current_weather.ts
- get_current_weather.sh - get_current_weather.sh
# - search_arxiv.sh # - search_arxiv.sh
# - search_wikipedia.sh # - search_wikipedia.sh
@@ -129,7 +129,8 @@ visible_tools: # Which tools are visible to be compiled (and a
mcp_server_support: true # Enables or disables MCP servers (globally). mcp_server_support: true # Enables or disables MCP servers (globally).
mapping_mcp_servers: # Alias for an MCP server or set of servers mapping_mcp_servers: # Alias for an MCP server or set of servers
git: github,gitmcp git: github,gitmcp
enabled_mcp_servers: null # Which MCP servers to enable by default. enabled_mcp_servers:
null # Which MCP servers to enable by default.
# Accepts either a YAML list or a comma-separated string. Use 'all' to enable everything. # Accepts either a YAML list or a comma-separated string. Use 'all' to enable everything.
# Example (list form): # Example (list form):
# enabled_mcp_servers: # enabled_mcp_servers:
@@ -137,7 +138,21 @@ enabled_mcp_servers: null # Which MCP servers to enable by default.
# - slack # - slack
# Example (comma-separated form): # Example (comma-separated form):
# enabled_mcp_servers: github,slack,ddg-search # enabled_mcp_servers: github,slack,ddg-search
no_workspace_mcp: false # Disable loading workspace-local MCP servers (default: false). mcp_tools:
null # Per-server MCP tool allowlists (glob patterns: * and ? supported).
# Tools that match no pattern are hidden from the model as if they
# don't exist. Stacks with the other allowlist layers (mcp.json
# `allowedTools`, role, agent, session, skill, graph node). Every
# configured layer must allow a tool, so layers only ever narrow.
# An empty list blocks all of a server's tools.
# Example:
# mcp_tools:
# github:
# - get_*
# - list_*
# slack: []
no_workspace_mcp:
false # Disable loading workspace-local MCP servers (default: false).
# When false (the default), Coyote merges the first workspace MCP config it finds # When false (the default), Coyote merges the first workspace MCP config it finds
# into the global MCP registry at startup, checking in order: # into the global MCP registry at startup, checking in order:
# 1. .coyote/mcp.json # 1. .coyote/mcp.json
@@ -149,14 +164,16 @@ no_workspace_mcp: false # Disable loading workspace-local MCP servers (
# ---- Skills ---- # ---- Skills ----
# Skills are modular knowledge or capability packs the LLM can load and unload mid-conversation. # Skills are modular knowledge or capability packs the LLM can load and unload mid-conversation.
# See the [Skills documentation](https://github.com/Dark-Alex-17/coyote/wiki/Skills) for more details. # See the [Skills documentation](https://github.com/Dark-Alex-17/coyote/wiki/Skills) for more details.
skills_enabled: true # Master switch. Set to false to hide all skill management tools from the model. skills_enabled:
true # Master switch. Set to false to hide all skill management tools from the model.
# Skills also require `function_calling_support: true` above to work at all. # Skills also require `function_calling_support: true` above to work at all.
visible_skills: # The universe of skills allowed to be enabled in any context. Omit (null) for "all installed". visible_skills: # The universe of skills allowed to be enabled in any context. Omit (null) for "all installed".
- ai-slop-remover - ai-slop-remover
- code-review - code-review
- frontend-ui-ux - frontend-ui-ux
- git-master - git-master
enabled_skills: null # Which skills are available by default (no role/agent/session active). null = all visible. enabled_skills:
null # Which skills are available by default (no role/agent/session active). null = all visible.
# Accepts either a YAML list or a comma-separated string. # Accepts either a YAML list or a comma-separated string.
# Example (list form): # Example (list form):
# enabled_skills: # enabled_skills:
@@ -164,7 +181,8 @@ enabled_skills: null # Which skills are available by default (no ro
# - ai-slop-remover # - ai-slop-remover
# Example (comma-separated form): # Example (comma-separated form):
# enabled_skills: git-master,ai-slop-remover # enabled_skills: git-master,ai-slop-remover
inject_skill_instructions: true # Inject a short hint pointing the model at `skill__list` when skills are enabled in inject_skill_instructions:
true # Inject a short hint pointing the model at `skill__list` when skills are enabled in
# this context. Only injected if `function_calling_support`, `skills_enabled`, and the # this context. Only injected if `function_calling_support`, `skills_enabled`, and the
# effective enabled skill set is non-empty (default: true). # effective enabled skill set is non-empty (default: true).
skill_instructions: null # Custom text used for the skill hint when injected. If null, uses built-in default. skill_instructions: null # Custom text used for the skill hint when injected. If null, uses built-in default.
@@ -174,7 +192,8 @@ skill_instructions: null # Custom text used for the skill hint when inj
# (a macro file named `review.yaml` runs as `.review [args]`; built-in commands always win a name collision). # (a macro file named `review.yaml` runs as `.review [args]`; built-in commands always win a name collision).
# Workspace-local macros in `.coyote/macros/` shadow same-named global macros (skip them with --no-workspace-macros). # Workspace-local macros in `.coyote/macros/` shadow same-named global macros (skip them with --no-workspace-macros).
# See the [Macros documentation](https://github.com/Dark-Alex-17/coyote/wiki/Macros) for more details. # See the [Macros documentation](https://github.com/Dark-Alex-17/coyote/wiki/Macros) for more details.
enabled_macros: null # Which macros are invocable by default (no role/agent/session active). null = all visible. enabled_macros:
null # Which macros are invocable by default (no role/agent/session active). null = all visible.
# An empty list means NO macros are invocable. Accepts either a YAML list or a # An empty list means NO macros are invocable. Accepts either a YAML list or a
# comma-separated string. Roles, agents, and sessions may define their own # comma-separated string. Roles, agents, and sessions may define their own
# `enabled_macros`; the most specific active one wins (session > agent > role > global). # `enabled_macros`; the most specific active one wins (session > agent > role > global).
@@ -198,9 +217,11 @@ continuation_prompt: null # Custom prompt used when auto-continuing. If
# See the [Session documentation](https://github.com/Dark-Alex-17/coyote/wiki/Sessions) for more information # See the [Session documentation](https://github.com/Dark-Alex-17/coyote/wiki/Sessions) for more information
save_session: null # Controls the persistence of the session. If true, auto save; if false, don't auto-save save; if null, ask the user what to do save_session: null # Controls the persistence of the session. If true, auto save; if false, don't auto-save save; if null, ask the user what to do
compression_threshold: 4000 # Compress the session when the token count reaches or exceeds this threshold compression_threshold: 4000 # Compress the session when the token count reaches or exceeds this threshold
summarization_prompt: > # The text prompt used for creating a concise summary of session message summarization_prompt:
> # The text prompt used for creating a concise summary of session message
'Summarize the discussion briefly in 200 words or less to use as a prompt for future context.' 'Summarize the discussion briefly in 200 words or less to use as a prompt for future context.'
summary_context_prompt: > # The text prompt used for including the summary of the entire session as context to the model summary_context_prompt:
> # The text prompt used for including the summary of the entire session as context to the model
'This is a summary of the chat history as a recap: ' 'This is a summary of the chat history as a recap: '
compression_keep_last: 0 # Number of most-recent messages to keep visible after compression (0 = compress all messages) compression_keep_last: 0 # Number of most-recent messages to keep visible after compression (0 = compress all messages)
max_tool_result_chars: null # Cap on tool result characters forwarded to the model per call (null = no cap) max_tool_result_chars: null # Cap on tool result characters forwarded to the model per call (null = no cap)
@@ -214,9 +235,11 @@ max_concurrent_jobs: 5 # Max background jobs (`job__*` tools) running
# Bootstrap with `coyote --init-memory [global|workspace]` to create the marker file # Bootstrap with `coyote --init-memory [global|workspace]` to create the marker file
# the LLM needs before it will write any memory. # the LLM needs before it will write any memory.
memory: null # null = enabled when memory exists on disk; true = force on; false = force off memory: null # null = enabled when memory exists on disk; true = force on; false = force off
memory_cap_with_tools: null # Char cap for injected memory when function calling is available (default: 6000). memory_cap_with_tools:
null # Char cap for injected memory when function calling is available (default: 6000).
# Only MEMORY.md indexes are injected; the LLM uses memory__read to fetch drill files. # Only MEMORY.md indexes are injected; the LLM uses memory__read to fetch drill files.
memory_cap_without_tools: null # Char cap when function calling is unavailable (default: 12000). memory_cap_without_tools:
null # Char cap when function calling is unavailable (default: 12000).
# Indexes plus drill file bodies are injected up to this cap. # Indexes plus drill file bodies are injected up to this cap.
# ---- Workspace Instructions ---- # ---- Workspace Instructions ----
@@ -226,7 +249,8 @@ memory_cap_without_tools: null # Char cap when function calling is unavailable
# Disable per-invocation with --no-workspace-instructions, or override the chain with # Disable per-invocation with --no-workspace-instructions, or override the chain with
# repeatable --workspace-instructions-file flags. # repeatable --workspace-instructions-file flags.
workspace_instructions: null # null/true = inject when an instructions file exists; false = never inject workspace_instructions: null # null/true = inject when an instructions file exists; false = never inject
workspace_instructions_files: null # File name chain to search, in priority order. workspace_instructions_files:
null # File name chain to search, in priority order.
# Default: [COYOTE.md, AGENTS.md, CLAUDE.md, GEMINI.md] # Default: [COYOTE.md, AGENTS.md, CLAUDE.md, GEMINI.md]
# Set to a custom list to reorder or drop fallbacks, e.g.: # Set to a custom list to reorder or drop fallbacks, e.g.:
# workspace_instructions_files: [COYOTE.md] # workspace_instructions_files: [COYOTE.md]
@@ -277,7 +301,8 @@ document_loaders:
# (see https://pandoc.org for details on how to install pandoc) # (see https://pandoc.org for details on how to install pandoc)
jina: 'curl -fsSL https://r.jina.ai/$1 -H "Authorization: Bearer {{JINA_API_KEY}}' # Use Jina to translate a website into text; jina: 'curl -fsSL https://r.jina.ai/$1 -H "Authorization: Bearer {{JINA_API_KEY}}' # Use Jina to translate a website into text;
# Requires a Jina API key to be added to the Coyote vault # Requires a Jina API key to be added to the Coyote vault
git: > # Use yek to load a git repository into the knowledgebase (https://github.com/bodo-run/yek) git:
> # Use yek to load a git repository into the knowledgebase (https://github.com/bodo-run/yek)
sh -c "yek $1 --json | jq 'map({ path: .filename, contents: .content })'" sh -c "yek $1 --json | jq 'map({ path: .filename, contents: .content })'"
# ---- Clients ---- # ---- Clients ----
@@ -339,7 +364,8 @@ clients:
- type: gemini - type: gemini
api_base: https://generativelanguage.googleapis.com/v1beta api_base: https://generativelanguage.googleapis.com/v1beta
api_key: '{{GEMINI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{GEMINI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
auth: null # When set to 'oauth', Coyote will use OAuth instead of an API key auth:
null # When set to 'oauth', Coyote will use OAuth instead of an API key
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL # Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
patch: patch:
chat_completions: chat_completions:
@@ -359,7 +385,8 @@ clients:
- type: claude - type: claude
api_base: https://api.anthropic.com/v1 # Optional api_base: https://api.anthropic.com/v1 # Optional
api_key: '{{ANTHROPIC_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{ANTHROPIC_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
auth: null # When set to 'oauth', Coyote will use OAuth instead of an API key auth:
null # When set to 'oauth', Coyote will use OAuth instead of an API key
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL # Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
# See https://docs.mistral.ai/ # See https://docs.mistral.ai/
@@ -373,7 +400,8 @@ clients:
name: xai name: xai
api_base: https://api.x.ai/v1 api_base: https://api.x.ai/v1
api_key: '{{XAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{XAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
auth: null # When set to 'oauth', Coyote will use OAuth instead of an API key auth:
null # When set to 'oauth', Coyote will use OAuth instead of an API key
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL # Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
# Note: Oauth requires SuperGrok/X Premium+ subscription # Note: Oauth requires SuperGrok/X Premium+ subscription
@@ -529,7 +557,6 @@ clients:
api_base: https://api.deepinfra.com/v1/openai api_base: https://api.deepinfra.com/v1/openai
api_key: '{{DEEPINFRA_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{DEEPINFRA_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
# ----- RAG dedicated ----- # ----- RAG dedicated -----
# See https://jina.ai # See https://jina.ai
+4
View File
@@ -16,6 +16,10 @@ enabled_tools: # Tools to enable for this role. Accepts a
enabled_mcp_servers: # MCP servers to enable for this role. Accepts a YAML list (preferred) enabled_mcp_servers: # MCP servers to enable for this role. Accepts a YAML list (preferred)
- github # or a comma-separated string (e.g. `enabled_mcp_servers: github,gitmcp`). - github # or a comma-separated string (e.g. `enabled_mcp_servers: github,gitmcp`).
- gitmcp # Use `all` to enable every configured MCP server. - gitmcp # Use `all` to enable every configured MCP server.
mcp_tools: # Per-server MCP tool allowlists for this role (globs: * and ?).
github: # Intersects with the global config / mcp.json `allowedTools`.
- get_* # Layers only narrow. Tools matching no pattern are hidden from
- search_* # the model as if they don't exist.
skills_enabled: true # Master switch for skills in this role (default: inherit from global). skills_enabled: true # Master switch for skills in this role (default: inherit from global).
# Skills also require `function_calling_support: true` in the global config. # Skills also require `function_calling_support: true` in the global config.
enabled_skills: # Skills available when this role is active. Accepts a YAML list (preferred) enabled_skills: # Skills available when this role is active. Accepts a YAML list (preferred)
+7
View File
@@ -51,6 +51,10 @@ global_tools: # Tool universe an `llm` node's `tools:` whit
mcp_servers: # MCP servers an `llm` node may reference via `mcp:<server>` mcp_servers: # MCP servers an `llm` node may reference via `mcp:<server>`
- ddg-search - ddg-search
mcp_tools: # Optional per-server tool allowlists (globs: * and ?) applied to
ddg-search: # every node that uses `mcp:<server>`; intersects with the other
- search # allowlist layers (global config, agent, mcp.json `allowedTools`).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Skills policy (optional) # Skills policy (optional)
# Skills only attach to `llm` nodes inside a graph. Both fields are optional. # Skills only attach to `llm` nodes inside a graph. Both fields are optional.
@@ -402,6 +406,9 @@ nodes:
tools: # Narrow whitelist: exactly these entries, nothing else tools: # Narrow whitelist: exactly these entries, nothing else
- web_search_coyote # an exact global-tool / custom-tool name - web_search_coyote # an exact global-tool / custom-tool name
- mcp:ddg-search # `mcp:<server>` includes that server's functions - mcp:ddg-search # `mcp:<server>` includes that server's functions
mcp_tools: # Optional per-node narrowing of MCP tools (globs: * and ?)
ddg-search: # keys must be servers this graph enables; intersects with
- search # the graph-level mcp_tools above and every other layer
model: claude:claude-haiku-4-5 # Optional per-node model override model: claude:claude-haiku-4-5 # Optional per-node model override
temperature: 0.3 # Optional per-node sampling override temperature: 0.3 # Optional per-node sampling override
reasoning_effort: null # Optional per-node reasoning effort override (e.g. low, medium, high) reasoning_effort: null # Optional per-node reasoning effort override (e.g. low, medium, high)
+41
View File
@@ -590,12 +590,33 @@ mod tests {
parse(&["--install-builtins", "agents"]).install_builtins, parse(&["--install-builtins", "agents"]).install_builtins,
Some(AssetCategory::Agents) Some(AssetCategory::Agents)
); );
assert_eq!(
parse(&["--install-builtins", "mcp-config"]).install_builtins,
Some(AssetCategory::McpConfig)
);
assert_eq!( assert_eq!(
parse(&["--install-builtins", "mcp_config"]).install_builtins, parse(&["--install-builtins", "mcp_config"]).install_builtins,
Some(AssetCategory::McpConfig) Some(AssetCategory::McpConfig)
); );
} }
#[test]
fn mcp_config_canonical_value_is_kebab_case() {
use clap::ValueEnum;
let category = AssetCategory::McpConfig.to_possible_value().unwrap();
assert_eq!(category.get_name(), "mcp-config");
assert!(category.get_name_and_aliases().any(|n| n == "mcp_config"));
assert!(AssetCategory::NAMES.contains(&"mcp-config"));
assert!(!AssetCategory::NAMES.contains(&"mcp_config"));
let filter = InstallFilter::McpConfig.to_possible_value().unwrap();
assert_eq!(filter.get_name(), "mcp-config");
assert!(filter.get_name_and_aliases().any(|n| n == "mcp_config"));
assert!(InstallFilter::NAMES.contains(&"mcp-config"));
assert!(!InstallFilter::NAMES.contains(&"mcp_config"));
}
#[test] #[test]
fn parse_install_builtins_conflicts_with_install() { fn parse_install_builtins_conflicts_with_install() {
assert!( assert!(
@@ -633,6 +654,26 @@ mod tests {
parse(&["--install", "https://github.com/x/y", "--filter", "agents"]).filter, parse(&["--install", "https://github.com/x/y", "--filter", "agents"]).filter,
Some(InstallFilter::Agents) Some(InstallFilter::Agents)
); );
assert_eq!(
parse(&[
"--install",
"https://github.com/x/y",
"--filter",
"mcp-config"
])
.filter,
Some(InstallFilter::McpConfig)
);
assert_eq!(
parse(&[
"--install",
"https://github.com/x/y",
"--filter",
"mcp_config"
])
.filter,
Some(InstallFilter::McpConfig)
);
} }
#[test] #[test]
+38
View File
@@ -682,6 +682,10 @@ impl RoleLike for Agent {
Some(self.config.mcp_servers.clone()) Some(self.config.mcp_servers.clone())
} }
fn mcp_tools(&self) -> Option<IndexMap<String, Vec<String>>> {
self.config.mcp_tools.clone()
}
fn set_model(&mut self, model: Model) { fn set_model(&mut self, model: Model) {
self.config.model_id = Some(model.id()); self.config.model_id = Some(model.id());
self.model = model; self.model = model;
@@ -723,6 +727,10 @@ impl RoleLike for Agent {
} }
} }
} }
fn set_mcp_tools(&mut self, value: Option<IndexMap<String, Vec<String>>>) {
self.config.mcp_tools = value;
}
} }
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -774,6 +782,8 @@ pub struct AgentConfig {
pub version: String, pub version: String,
#[serde(default)] #[serde(default)]
pub mcp_servers: Vec<String>, pub mcp_servers: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
#[serde(default)] #[serde(default)]
pub global_tools: Vec<String>, pub global_tools: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -846,6 +856,7 @@ impl AgentConfig {
description: graph.description.clone(), description: graph.description.clone(),
global_tools: graph.global_tools.clone(), global_tools: graph.global_tools.clone(),
mcp_servers: graph.mcp_servers.clone(), mcp_servers: graph.mcp_servers.clone(),
mcp_tools: graph.mcp_tools.clone(),
skills_enabled: graph.skills_enabled, skills_enabled: graph.skills_enabled,
enabled_skills: graph.enabled_skills.clone(), enabled_skills: graph.enabled_skills.clone(),
inject_skill_instructions: graph.inject_skill_instructions.unwrap_or(true), inject_skill_instructions: graph.inject_skill_instructions.unwrap_or(true),
@@ -1285,6 +1296,33 @@ variables:
assert_eq!(config.enabled_macros, None); assert_eq!(config.enabled_macros, None);
} }
#[test]
fn agent_config_parses_mcp_tools() {
let yaml =
"name: minimal\ninstructions: hi\nmcp_tools:\n github:\n - get_*\n - list_*\n";
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
let mcp_tools = config.mcp_tools.unwrap();
assert_eq!(
mcp_tools.get("github"),
Some(&vec!["get_*".to_string(), "list_*".to_string()])
);
}
#[test]
fn agent_mcp_tools_role_like_round_trip() {
let config: AgentConfig =
serde_yaml::from_str("name: minimal\ninstructions: hi\n").unwrap();
let mut agent = Agent::test_new(config);
assert_eq!(agent.mcp_tools(), None);
let mut mcp_tools = IndexMap::new();
mcp_tools.insert("github".to_string(), vec!["get_*".to_string()]);
agent.set_mcp_tools(Some(mcp_tools.clone()));
assert_eq!(agent.mcp_tools(), Some(mcp_tools));
}
#[test] #[test]
fn agent_config_enabled_macros_empty_list_is_some_empty() { fn agent_config_enabled_macros_empty_list_is_some_empty() {
let yaml = "name: minimal\ninstructions: hi\nenabled_macros: []\n"; let yaml = "name: minimal\ninstructions: hi\nenabled_macros: []\n";
+19
View File
@@ -50,6 +50,7 @@ pub struct AppConfig {
pub mapping_mcp_servers: IndexMap<String, String>, pub mapping_mcp_servers: IndexMap<String, String>,
#[serde(default, deserialize_with = "super::deserialize_csv_or_vec")] #[serde(default, deserialize_with = "super::deserialize_csv_or_vec")]
pub enabled_mcp_servers: Option<Vec<String>>, pub enabled_mcp_servers: Option<Vec<String>>,
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
pub auto_continue: bool, pub auto_continue: bool,
pub max_auto_continues: usize, pub max_auto_continues: usize,
@@ -136,6 +137,7 @@ impl Default for AppConfig {
mcp_server_support: true, mcp_server_support: true,
mapping_mcp_servers: Default::default(), mapping_mcp_servers: Default::default(),
enabled_mcp_servers: None, enabled_mcp_servers: None,
mcp_tools: None,
auto_continue: false, auto_continue: false,
max_auto_continues: 10, max_auto_continues: 10,
@@ -223,6 +225,7 @@ impl AppConfig {
mcp_server_support: config.mcp_server_support, mcp_server_support: config.mcp_server_support,
mapping_mcp_servers: config.mapping_mcp_servers, mapping_mcp_servers: config.mapping_mcp_servers,
enabled_mcp_servers: config.enabled_mcp_servers, enabled_mcp_servers: config.enabled_mcp_servers,
mcp_tools: config.mcp_tools,
auto_continue: config.auto_continue, auto_continue: config.auto_continue,
max_auto_continues: config.max_auto_continues, max_auto_continues: config.max_auto_continues,
@@ -786,6 +789,22 @@ mod tests {
); );
} }
#[test]
fn from_config_copies_mcp_tools() {
let mut mcp_tools = IndexMap::new();
mcp_tools.insert("github".to_string(), vec!["get_*".to_string()]);
let cfg = Config {
model_id: "test-model".to_string(),
clients: vec![ClientConfig::default()],
mcp_tools: Some(mcp_tools.clone()),
..Config::default()
};
let app = AppConfig::from_config(cfg).unwrap();
assert_eq!(app.mcp_tools, Some(mcp_tools));
}
#[test] #[test]
#[serial_test::serial] #[serial_test::serial]
fn from_config_copies_enabled_macros() { fn from_config_copies_enabled_macros() {
+47
View File
@@ -4158,6 +4158,10 @@ mod tests {
classify_install_target("agents", &owned_names(&["agents"])), classify_install_target("agents", &owned_names(&["agents"])),
InstallTarget::Category(AssetCategory::Agents) InstallTarget::Category(AssetCategory::Agents)
); );
assert_eq!(
classify_install_target("mcp-config", &[]),
InstallTarget::Category(AssetCategory::McpConfig)
);
assert_eq!( assert_eq!(
classify_install_target("mcp_config", &[]), classify_install_target("mcp_config", &[]),
InstallTarget::Category(AssetCategory::McpConfig) InstallTarget::Category(AssetCategory::McpConfig)
@@ -4790,6 +4794,49 @@ mod tests {
let _ = fs::remove_dir_all(&dir); let _ = fs::remove_dir_all(&dir);
} }
#[test]
fn uninstall_mcp_preserves_allowed_tools_on_surviving_entries() {
let dir = fresh_temp_dir("uninst-mcp-allowed-tools-");
let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap();
store
.upsert_bundle("omc", test_metadata("https://github.com/x/omc"))
.unwrap();
let mcp = dir.join("mcp.json");
write_mcp(
&mcp,
r#"{"mcpServers": {
"srv": {"type": "stdio", "command": "echo"},
"user-srv": {"type": "stdio", "command": "mine", "allowedTools": ["get_*", "list_issues"]}
}}"#,
);
let parsed: McpServersConfig =
serde_json::from_str(&fs::read_to_string(&mcp).unwrap()).unwrap();
let hash = hash_bytes(
serde_json::to_string(parsed.mcp_servers.get("srv").unwrap())
.unwrap()
.as_bytes(),
);
store
.record_mcp_servers(
"omc",
vec![mcp_server_record("srv", McpAction::Added, Some(hash))],
)
.unwrap();
let servers = store.get("omc").unwrap().mcp_servers.clone();
let summary = uninstall_mcp_entries(&mut store, "omc", &servers, &mcp, true).unwrap();
assert_eq!(summary.removed, vec!["srv"]);
let raw = fs::read_to_string(&mcp).unwrap();
assert!(raw.contains("allowedTools"));
let written: McpServersConfig = serde_json::from_str(&raw).unwrap();
assert_eq!(
written.mcp_servers.get("user-srv").unwrap().allowed_tools,
Some(vec!["get_*".to_string(), "list_issues".to_string()])
);
let _ = fs::remove_dir_all(&dir);
}
#[test] #[test]
fn uninstall_mcp_reports_referenced_secrets_without_removing_them() { fn uninstall_mcp_reports_referenced_secrets_without_removing_them() {
let dir = fresh_temp_dir("uninst-mcp-secrets-"); let dir = fresh_temp_dir("uninst-mcp-secrets-");
+2
View File
@@ -139,6 +139,7 @@ mod tests {
url: None, url: None,
headers: None, headers: None,
oauth: None, oauth: None,
allowed_tools: None,
} }
} }
@@ -156,6 +157,7 @@ mod tests {
url: Some(url.to_string()), url: Some(url.to_string()),
headers, headers,
oauth: None, oauth: None,
allowed_tools: None,
} }
} }
+859
View File
@@ -0,0 +1,859 @@
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 {
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<String>,
regexes: Vec<Regex>,
}
#[derive(Debug, Clone, Default)]
pub struct ToolFilter {
layers: Vec<CompiledPatterns>,
}
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(),
});
}
pub fn layers(&self) -> impl Iterator<Item = (&LayerSource, &[String])> {
self.layers
.iter()
.map(|layer| (&layer.source, layer.raw.as_slice()))
}
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<Vec<(&LayerSource, &str)>, &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)
}
pub fn dead_context_patterns(&self, advertised: &[String]) -> Vec<(&LayerSource, &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<String>,
pub mcp_tools: IndexMap<String, Vec<String>>,
}
pub struct McpToolPolicy;
impl McpToolPolicy {
#[allow(clippy::too_many_arguments)]
pub fn effective(
mcp_config: &McpServersConfig,
session: Option<&IndexMap<String, Vec<String>>>,
agent: Option<(&str, &IndexMap<String, Vec<String>>)>,
role: Option<(&str, &IndexMap<String, Vec<String>>)>,
global: Option<&IndexMap<String, Vec<String>>>,
skills: &[SkillMcpLayer],
node: Option<(&str, &IndexMap<String, Vec<String>>)>,
aliases: &IndexMap<String, String>,
) -> HashMap<String, ToolFilter> {
let mut filters: HashMap<String, ToolFilter> = 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<String, ToolFilter>,
mcp_config: &McpServersConfig,
aliases: &IndexMap<String, String>,
source: &LayerSource,
map: &IndexMap<String, Vec<String>>,
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);
}
}
fn expand_server_keys(
mcp_config: &McpServersConfig,
aliases: &IndexMap<String, String>,
map: &IndexMap<String, Vec<String>>,
) -> IndexMap<String, Vec<String>> {
let mut expanded: IndexMap<String, Vec<String>> = 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
}
pub(crate) fn expand_mcp_server_alias(
aliases: &IndexMap<String, String>,
key: &str,
) -> Vec<String> {
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<String> {
items.iter().map(|s| s.to_string()).collect()
}
fn tool_map(entries: &[(&str, &[&str])]) -> IndexMap<String, Vec<String>> {
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<String, String> {
IndexMap::new()
}
fn aliases(entries: &[(&str, &str)]) -> IndexMap<String, String> {
entries
.iter()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect()
}
fn resolve(
config: &McpServersConfig,
session: Option<&IndexMap<String, Vec<String>>>,
role: Option<(&str, &IndexMap<String, Vec<String>>)>,
) -> HashMap<String, ToolFilter> {
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<String> = 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());
}
}
+23 -6
View File
@@ -8,6 +8,7 @@ pub(crate) mod instructions;
mod macro_policy; mod macro_policy;
mod macros; mod macros;
mod mcp_factory; mod mcp_factory;
mod mcp_tool_policy;
pub(crate) mod memory; pub(crate) mod memory;
pub(crate) mod paths; pub(crate) mod paths;
pub(crate) mod prompts; pub(crate) mod prompts;
@@ -41,6 +42,9 @@ pub use self::install_remote::{
pub use self::macro_policy::{ pub use self::macro_policy::{
MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, ResolvedMacro, MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, ResolvedMacro,
}; };
pub(crate) use self::mcp_tool_policy::expand_mcp_server_alias;
#[cfg(test)]
pub(crate) use self::mcp_tool_policy::{LayerSource, ToolFilter};
#[allow(unused_imports)] #[allow(unused_imports)]
pub use self::request_context::{ pub use self::request_context::{
RenderMode, RequestContext, effective_max_concurrent_jobs, jobs_enabled, RenderMode, RequestContext, effective_max_concurrent_jobs, jobs_enabled,
@@ -250,6 +254,7 @@ pub struct Config {
pub mapping_mcp_servers: IndexMap<String, String>, pub mapping_mcp_servers: IndexMap<String, String>,
#[serde(default, deserialize_with = "deserialize_csv_or_vec")] #[serde(default, deserialize_with = "deserialize_csv_or_vec")]
pub enabled_mcp_servers: Option<Vec<String>>, pub enabled_mcp_servers: Option<Vec<String>>,
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
pub auto_continue: bool, pub auto_continue: bool,
pub max_auto_continues: usize, pub max_auto_continues: usize,
@@ -333,6 +338,7 @@ impl Default for Config {
mcp_server_support: true, mcp_server_support: true,
mapping_mcp_servers: Default::default(), mapping_mcp_servers: Default::default(),
enabled_mcp_servers: None, enabled_mcp_servers: None,
mcp_tools: None,
auto_continue: false, auto_continue: false,
max_auto_continues: 10, max_auto_continues: 10,
@@ -401,12 +407,12 @@ pub enum AssetCategory {
Macros, Macros,
Functions, Functions,
Skills, Skills,
#[value(name = "mcp_config")] #[value(name = "mcp-config", alias = "mcp_config")]
McpConfig, McpConfig,
} }
impl AssetCategory { impl AssetCategory {
pub const NAMES: [&'static str; 5] = ["agents", "macros", "functions", "skills", "mcp_config"]; pub const NAMES: [&'static str; 5] = ["agents", "macros", "functions", "skills", "mcp-config"];
pub fn parse(name: &str) -> Option<Self> { pub fn parse(name: &str) -> Option<Self> {
match name { match name {
@@ -414,7 +420,7 @@ impl AssetCategory {
"macros" => Some(Self::Macros), "macros" => Some(Self::Macros),
"functions" => Some(Self::Functions), "functions" => Some(Self::Functions),
"skills" => Some(Self::Skills), "skills" => Some(Self::Skills),
"mcp_config" => Some(Self::McpConfig), "mcp-config" | "mcp_config" => Some(Self::McpConfig),
_ => None, _ => None,
} }
} }
@@ -433,7 +439,7 @@ pub enum InstallFilter {
Skills, Skills,
Macros, Macros,
Functions, Functions,
#[value(name = "mcp_config")] #[value(name = "mcp-config", alias = "mcp_config")]
McpConfig, McpConfig,
} }
@@ -444,7 +450,7 @@ impl InstallFilter {
"skills", "skills",
"macros", "macros",
"functions", "functions",
"mcp_config", "mcp-config",
]; ];
pub fn parse(name: &str) -> Option<Self> { pub fn parse(name: &str) -> Option<Self> {
@@ -454,7 +460,7 @@ impl InstallFilter {
"skills" => Some(Self::Skills), "skills" => Some(Self::Skills),
"macros" => Some(Self::Macros), "macros" => Some(Self::Macros),
"functions" => Some(Self::Functions), "functions" => Some(Self::Functions),
"mcp_config" => Some(Self::McpConfig), "mcp-config" | "mcp_config" => Some(Self::McpConfig),
_ => None, _ => None,
} }
} }
@@ -1128,6 +1134,17 @@ clients:
assert!(validate_no_template_in_secrets_provider(yaml).is_ok()); assert!(validate_no_template_in_secrets_provider(yaml).is_ok());
} }
#[test]
fn config_yaml_parses_mcp_tools() {
let cfg: Config = serde_yaml::from_str("mcp_tools:\n github:\n - get_*\n").unwrap();
assert_eq!(
cfg.mcp_tools.as_ref().unwrap().get("github"),
Some(&vec!["get_*".to_string()])
);
assert_eq!(Config::default().mcp_tools, None);
}
#[test] #[test]
fn config_defaults_match_expected() { fn config_defaults_match_expected() {
let cfg = Config::default(); let cfg = Config::default();
File diff suppressed because it is too large Load Diff
+97
View File
@@ -30,6 +30,7 @@ pub trait RoleLike {
fn top_p(&self) -> Option<f64>; fn top_p(&self) -> Option<f64>;
fn enabled_tools(&self) -> Option<Vec<String>>; fn enabled_tools(&self) -> Option<Vec<String>>;
fn enabled_mcp_servers(&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_model(&mut self, model: Model);
fn set_temperature(&mut self, value: Option<f64>); fn set_temperature(&mut self, value: Option<f64>);
fn reasoning_effort(&self) -> Option<String>; fn reasoning_effort(&self) -> Option<String>;
@@ -37,6 +38,7 @@ pub trait RoleLike {
fn set_reasoning_effort(&mut self, value: Option<String>); fn set_reasoning_effort(&mut self, value: Option<String>);
fn set_enabled_tools(&mut self, value: Option<Vec<String>>); fn set_enabled_tools(&mut self, value: Option<Vec<String>>);
fn set_enabled_mcp_servers(&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)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -67,6 +69,8 @@ pub struct Role {
deserialize_with = "super::deserialize_csv_or_vec" deserialize_with = "super::deserialize_csv_or_vec"
)] )]
enabled_mcp_servers: Option<Vec<String>>, 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")] #[serde(skip_serializing_if = "Option::is_none")]
skills_enabled: Option<bool>, skills_enabled: Option<bool>,
#[serde( #[serde(
@@ -133,6 +137,7 @@ impl Role {
"enabled_mcp_servers" => { "enabled_mcp_servers" => {
role.enabled_mcp_servers = parse_string_or_array(value) 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(), "skills_enabled" => role.skills_enabled = value.as_bool(),
"enabled_skills" => role.enabled_skills = parse_string_or_array(value), "enabled_skills" => role.enabled_skills = parse_string_or_array(value),
"enabled_macros" => role.enabled_macros = 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()); serde_json::to_string(enabled_mcp_servers).unwrap_or_else(|_| "[]".to_string());
metadata.push(format!("enabled_mcp_servers: {inline}")); 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 { if let Some(skills_enabled) = self.skills_enabled {
metadata.push(format!("skills_enabled: {skills_enabled}")); metadata.push(format!("skills_enabled: {skills_enabled}"));
} }
@@ -279,6 +288,10 @@ impl Role {
enabled_tools, enabled_tools,
enabled_mcp_servers, 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( pub fn batch_set(
@@ -453,6 +466,10 @@ impl RoleLike for Role {
self.enabled_mcp_servers.clone() 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) { fn set_model(&mut self, model: Model) {
if !self.model().id().is_empty() { if !self.model().id().is_empty() {
self.model_id = Some(model.id().to_string()); 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>>) { fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>) {
self.enabled_mcp_servers = value; 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>> { 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 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)>) { fn parse_structure_prompt(prompt: &str) -> (&str, Vec<(&str, &str)>) {
let mut text = prompt; let mut text = prompt;
let mut search_input = true; let mut search_input = true;
@@ -652,6 +686,69 @@ mod tests {
assert_eq!(role.enabled_macros, None); 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] #[test]
fn role_export_includes_enabled_macros() { fn role_export_includes_enabled_macros() {
let role = Role::new("test", "---\nenabled_macros: [a]\n---\nPrompt"); let role = Role::new("test", "---\nenabled_macros: [a]\n---\nPrompt");
+62
View File
@@ -40,6 +40,8 @@ pub struct Session {
deserialize_with = "super::deserialize_csv_or_vec" deserialize_with = "super::deserialize_csv_or_vec"
)] )]
enabled_mcp_servers: Option<Vec<String>>, 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")] #[serde(skip_serializing_if = "Option::is_none")]
skills_enabled: Option<bool>, skills_enabled: Option<bool>,
#[serde( #[serde(
@@ -249,6 +251,9 @@ impl Session {
if let Some(enabled_mcp_servers) = self.enabled_mcp_servers() { if let Some(enabled_mcp_servers) = self.enabled_mcp_servers() {
data["enabled_mcp_servers"] = json!(enabled_mcp_servers); data["enabled_mcp_servers"] = json!(enabled_mcp_servers);
} }
if let Some(mcp_tools) = self.mcp_tools() {
data["mcp_tools"] = json!(mcp_tools);
}
if let Some(skills_enabled) = self.skills_enabled() { if let Some(skills_enabled) = self.skills_enabled() {
data["skills_enabled"] = skills_enabled.into(); data["skills_enabled"] = skills_enabled.into();
} }
@@ -329,6 +334,13 @@ impl Session {
items.push(("enabled_mcp_servers", enabled_mcp_servers.join(","))); items.push(("enabled_mcp_servers", enabled_mcp_servers.join(",")));
} }
if let Some(mcp_tools) = self.mcp_tools() {
items.push((
"mcp_tools",
serde_json::to_string(&mcp_tools).unwrap_or_default(),
));
}
if let Some(skills_enabled) = self.skills_enabled() { if let Some(skills_enabled) = self.skills_enabled() {
items.push(("skills_enabled", skills_enabled.to_string())); items.push(("skills_enabled", skills_enabled.to_string()));
} }
@@ -870,6 +882,10 @@ impl RoleLike for Session {
self.enabled_mcp_servers.clone() 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) { fn set_model(&mut self, model: Model) {
if self.model().id() != model.id() { if self.model().id() != model.id() {
self.model_id = model.id(); self.model_id = model.id();
@@ -913,6 +929,13 @@ impl RoleLike for Session {
self.dirty = true; self.dirty = true;
} }
} }
fn set_mcp_tools(&mut self, value: Option<IndexMap<String, Vec<String>>>) {
if self.mcp_tools != value {
self.mcp_tools = value;
self.dirty = true;
}
}
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@@ -1044,6 +1067,45 @@ mod tests {
assert_eq!(session.enabled_macros, None); assert_eq!(session.enabled_macros, None);
} }
#[test]
fn session_mcp_tools_survives_yaml_round_trip() {
let mut session = Session::default();
let mut mcp_tools = IndexMap::new();
mcp_tools.insert("github".to_string(), vec!["get_*".to_string()]);
mcp_tools.insert("slack".to_string(), vec![]);
session.set_mcp_tools(Some(mcp_tools.clone()));
let yaml = serde_yaml::to_string(&session).unwrap();
let reloaded: Session = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(reloaded.mcp_tools(), Some(mcp_tools));
}
#[test]
fn session_set_role_does_not_copy_mcp_tools() {
let role = Role::new(
"test",
"---\nmcp_tools:\n github: [get_issue]\n---\nPrompt",
);
assert!(role.mcp_tools().is_some());
let mut session = Session::default();
session.set_role(role);
assert_eq!(session.mcp_tools(), None);
}
#[test]
fn session_set_mcp_tools_marks_dirty() {
let mut session = Session::default();
assert!(!session.dirty());
session.set_mcp_tools(Some(IndexMap::new()));
assert!(session.dirty());
assert_eq!(session.mcp_tools(), Some(IndexMap::new()));
}
#[test] #[test]
fn session_enabled_macros_empty_list_is_some_empty() { fn session_enabled_macros_empty_list_is_some_empty() {
let session: Session = let session: Session =
+62
View File
@@ -37,6 +37,8 @@ pub struct Skill {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
enabled_mcp_servers: Option<Vec<String>>, enabled_mcp_servers: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
mcp_tools: Option<IndexMap<String, Vec<String>>>,
#[serde(skip_serializing_if = "Option::is_none")]
auto_unload: Option<bool>, auto_unload: Option<bool>,
} }
@@ -74,6 +76,9 @@ impl Skill {
"enabled_mcp_servers" => { "enabled_mcp_servers" => {
skill.enabled_mcp_servers = parse_skill_string_or_array(value); skill.enabled_mcp_servers = parse_skill_string_or_array(value);
} }
"mcp_tools" => {
skill.mcp_tools = parse_skill_mcp_tools_map(value);
}
"auto_unload" => { "auto_unload" => {
skill.auto_unload = value.as_bool(); skill.auto_unload = value.as_bool();
} }
@@ -147,6 +152,10 @@ impl Skill {
self.enabled_mcp_servers.as_deref() self.enabled_mcp_servers.as_deref()
} }
pub fn mcp_tools(&self) -> Option<&IndexMap<String, Vec<String>>> {
self.mcp_tools.as_ref()
}
pub fn auto_unload(&self) -> bool { pub fn auto_unload(&self) -> bool {
self.auto_unload.unwrap_or(false) self.auto_unload.unwrap_or(false)
} }
@@ -185,6 +194,21 @@ fn parse_skill_string_or_array(value: &Value) -> Option<Vec<String>> {
None None
} }
fn parse_skill_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_skill_string_or_array(tools) {
mcp_tools.insert(server.clone(), tools);
}
}
Some(mcp_tools)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -198,6 +222,44 @@ mod tests {
assert_eq!(skill.description(), ""); assert_eq!(skill.description(), "");
} }
#[test]
fn skill_new_parses_mcp_tools_list_and_csv_values() {
let content = "---\nmcp_tools:\n github: [get_*, list_*]\n slack: a,b\n---\nBody";
let skill = Skill::new("test", content);
let mcp_tools = skill.mcp_tools().unwrap();
assert_eq!(
mcp_tools.get("github"),
Some(&vec!["get_*".to_string(), "list_*".to_string()])
);
assert_eq!(
mcp_tools.get("slack"),
Some(&vec!["a".to_string(), "b".to_string()])
);
}
#[test]
fn skill_new_mcp_tools_absent_is_none() {
let skill = Skill::new("test", "---\ndescription: d\n---\nBody");
assert_eq!(skill.mcp_tools(), None);
}
#[test]
fn skill_new_mcp_tools_empty_list_server_is_some_empty() {
let skill = Skill::new("test", "---\nmcp_tools:\n github: []\n---\nBody");
assert_eq!(skill.mcp_tools().unwrap().get("github"), Some(&vec![]));
}
#[test]
fn skill_new_mcp_tools_per_server_null_is_some_empty() {
let skill = Skill::new("test", "---\nmcp_tools:\n github:\n---\nBody");
assert_eq!(skill.mcp_tools().unwrap().get("github"), Some(&vec![]));
}
#[test] #[test]
fn skill_new_parses_full_metadata() { fn skill_new_parses_full_metadata() {
let content = "---\n\ let content = "---\n\
+4
View File
@@ -34,6 +34,10 @@ impl SkillRegistry {
self.loaded.keys().cloned().collect() self.loaded.keys().cloned().collect()
} }
pub fn loaded_skills(&self) -> impl Iterator<Item = &Skill> {
self.loaded.values()
}
pub fn loaded_mcp_servers(&self) -> BTreeSet<String> { pub fn loaded_mcp_servers(&self) -> BTreeSet<String> {
let mut out = BTreeSet::new(); let mut out = BTreeSet::new();
for skill in self.loaded.values() { for skill in self.loaded.values() {
+209 -8
View File
@@ -1,3 +1,4 @@
use super::mcp_tool_policy::ToolFilter;
use crate::function::{Functions, ToolCallTracker}; use crate::function::{Functions, ToolCallTracker};
use crate::mcp::{CatalogItem, CatalogItemKind, ConnectedServer, McpRegistry, McpServerFeatures}; use crate::mcp::{CatalogItem, CatalogItemKind, ConnectedServer, McpRegistry, McpServerFeatures};
@@ -44,6 +45,8 @@ pub enum McpPromptCompletion {
#[derive(Default, Clone)] #[derive(Default, Clone)]
pub struct McpRuntime { pub struct McpRuntime {
pub servers: HashMap<String, Arc<ConnectedServer>>, pub servers: HashMap<String, Arc<ConnectedServer>>,
/// Per-server effective tool allowlists; a server absent here is unfiltered.
pub tool_filters: HashMap<String, ToolFilter>,
} }
impl McpRuntime { impl McpRuntime {
@@ -100,12 +103,27 @@ impl McpRuntime {
if features.tools { if features.tools {
match server_handle.list_all_tools().await { match server_handle.list_all_tools().await {
Ok(tools) => merge_catalog_items( Ok(mut tools) => {
if let Some(filter) = self.tool_filters.get(server) {
let advertised: Vec<String> =
tools.iter().map(|tool| tool.name.to_string()).collect();
for (source, pattern) in filter.dead_context_patterns(&advertised) {
warn!(
"MCP tool pattern '{pattern}' from {source} matches no allowed tools on server '{server}'"
);
}
tools.retain(|tool| filter.allows(&tool.name));
}
merge_catalog_items(
&mut items, &mut items,
tools tools
.into_iter() .into_iter()
.map(|tool| tool_catalog_item(server, tool)), .map(|tool| tool_catalog_item(server, tool)),
), )
}
Err(e) => warn!("Failed to list tools on MCP server {server}: {e}"), Err(e) => warn!("Failed to list tools on MCP server {server}: {e}"),
} }
} }
@@ -195,6 +213,11 @@ impl McpRuntime {
match kind { match kind {
"tool" => { "tool" => {
if let Some(filter) = self.tool_filters.get(server)
&& !filter.allows(tool)
{
return Err(anyhow!("{tool} not found in {server} MCP server catalog"));
}
let tool_schema = server_handle let tool_schema = server_handle
.list_all_tools() .list_all_tools()
.await? .await?
@@ -297,6 +320,12 @@ impl McpRuntime {
.cloned() .cloned()
.with_context(|| format!("Invoked MCP server does not exist: {server}"))?; .with_context(|| format!("Invoked MCP server does not exist: {server}"))?;
if let Some(filter) = self.tool_filters.get(server)
&& !filter.allows(tool)
{
return Err(anyhow!("{tool} not found in {server} MCP server catalog"));
}
let mut request = CallToolRequestParams::new(tool.to_owned()); let mut request = CallToolRequestParams::new(tool.to_owned());
request.arguments = arguments.as_object().cloned(); request.arguments = arguments.as_object().cloned();
@@ -614,11 +643,15 @@ pub(crate) mod test_fixtures {
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct FixtureServer { pub(crate) struct FixtureServer {
pub(crate) tools_capability: bool, pub(crate) tools_capability: bool,
pub(crate) tool_names: Vec<&'static str>,
pub(crate) resources_capability: bool, pub(crate) resources_capability: bool,
pub(crate) prompts_capability: bool, pub(crate) prompts_capability: bool,
pub(crate) hostile_prompt: bool, pub(crate) hostile_prompt: bool,
pub(crate) fail_resource_listings: bool, pub(crate) fail_resource_listings: bool,
pub(crate) fail_template_listings: bool,
pub(crate) fail_prompt_listings: bool, pub(crate) fail_prompt_listings: bool,
pub(crate) empty_resource_listings: bool,
pub(crate) empty_prompt_listings: bool,
pub(crate) fail_get_prompt: bool, pub(crate) fail_get_prompt: bool,
pub(crate) prompt_delay: Option<Duration>, pub(crate) prompt_delay: Option<Duration>,
pub(crate) tool_result: Option<CallToolResult>, pub(crate) tool_result: Option<CallToolResult>,
@@ -632,11 +665,15 @@ pub(crate) mod test_fixtures {
fn default() -> Self { fn default() -> Self {
Self { Self {
tools_capability: true, tools_capability: true,
tool_names: vec!["dup"],
resources_capability: false, resources_capability: false,
prompts_capability: false, prompts_capability: false,
hostile_prompt: false, hostile_prompt: false,
fail_resource_listings: false, fail_resource_listings: false,
fail_template_listings: false,
fail_prompt_listings: false, fail_prompt_listings: false,
empty_resource_listings: false,
empty_prompt_listings: false,
fail_get_prompt: false, fail_get_prompt: false,
prompt_delay: None, prompt_delay: None,
tool_result: None, tool_result: None,
@@ -672,11 +709,12 @@ pub(crate) mod test_fixtures {
.as_object() .as_object()
.cloned() .cloned()
.unwrap(); .unwrap();
Ok(ListToolsResult::with_all_items(vec![Tool::new( Ok(ListToolsResult::with_all_items(
"dup", self.tool_names
"Duplicate-named tool", .iter()
schema, .map(|name| Tool::new(*name, "Fixture tool", schema.clone()))
)])) .collect(),
))
} }
async fn call_tool( async fn call_tool(
@@ -703,6 +741,9 @@ pub(crate) mod test_fixtures {
if self.fail_resource_listings { if self.fail_resource_listings {
return Err(ErrorData::internal_error("resource listing exploded", None)); return Err(ErrorData::internal_error("resource listing exploded", None));
} }
if self.empty_resource_listings {
return Ok(ListResourcesResult::with_all_items(vec![]));
}
Ok(ListResourcesResult::with_all_items(vec![ Ok(ListResourcesResult::with_all_items(vec![
Resource::new("dup", "dup-resource") Resource::new("dup", "dup-resource")
.with_description("Duplicate-named resource") .with_description("Duplicate-named resource")
@@ -720,9 +761,12 @@ pub(crate) mod test_fixtures {
_request: Option<PaginatedRequestParams>, _request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>, _context: RequestContext<RoleServer>,
) -> Result<ListResourceTemplatesResult, ErrorData> { ) -> Result<ListResourceTemplatesResult, ErrorData> {
if self.fail_resource_listings { if self.fail_resource_listings || self.fail_template_listings {
return Err(ErrorData::internal_error("template listing exploded", None)); return Err(ErrorData::internal_error("template listing exploded", None));
} }
if self.empty_resource_listings {
return Ok(ListResourceTemplatesResult::with_all_items(vec![]));
}
Ok(ListResourceTemplatesResult::with_all_items(vec![ Ok(ListResourceTemplatesResult::with_all_items(vec![
ResourceTemplate::new("file:///{path}/{name}", "file-template") ResourceTemplate::new("file:///{path}/{name}", "file-template")
.with_description("Read a file") .with_description("Read a file")
@@ -778,6 +822,9 @@ pub(crate) mod test_fixtures {
if self.fail_prompt_listings { if self.fail_prompt_listings {
return Err(ErrorData::internal_error("prompt listing exploded", None)); return Err(ErrorData::internal_error("prompt listing exploded", None));
} }
if self.empty_prompt_listings {
return Ok(ListPromptsResult::with_all_items(vec![]));
}
let mut prompts = vec![Prompt::new( let mut prompts = vec![Prompt::new(
"summarize", "summarize",
Some("Summarize a document"), Some("Summarize a document"),
@@ -869,6 +916,7 @@ mod tests {
FIXTURE_ANNOTATED_URI, FixtureServer, add_fixture_server, fixture_runtime, FIXTURE_ANNOTATED_URI, FixtureServer, add_fixture_server, fixture_runtime,
}; };
use super::*; use super::*;
use crate::config::mcp_tool_policy::LayerSource;
use crate::function::ToolCall; use crate::function::ToolCall;
use log::{Level, LevelFilter, Log, Metadata, Record}; use log::{Level, LevelFilter, Log, Metadata, Record};
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
@@ -1643,4 +1691,157 @@ mod tests {
assert_eq!(prompt, "summarize"); assert_eq!(prompt, "summarize");
assert_eq!(typed_keys, vec!["path".to_string()]); assert_eq!(typed_keys, vec!["path".to_string()]);
} }
fn single_layer_filter(source: LayerSource, patterns: &[&str]) -> ToolFilter {
let mut filter = ToolFilter::default();
filter.push_layer(
source,
&patterns.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
);
filter
}
fn deny_all_filter() -> ToolFilter {
single_layer_filter(LayerSource::Global, &[])
}
#[tokio::test]
async fn catalog_items_drops_filtered_tools_but_keeps_other_kinds() {
let fixture = FixtureServer {
resources_capability: true,
prompts_capability: true,
..Default::default()
};
let (mut runtime, _server) = fixture_runtime(fixture).await;
runtime
.tool_filters
.insert("fixture".to_string(), deny_all_filter());
let items = runtime.catalog_items("fixture").await.unwrap();
assert!(!items.contains_key("tool:dup"));
assert!(items.contains_key("resource:dup"));
assert!(items.contains_key("resource_template:file:///{path}/{name}"));
assert!(items.contains_key("prompt:summarize"));
}
#[tokio::test]
async fn catalog_items_keeps_tools_the_filter_allows() {
let (mut runtime, _server) = fixture_runtime(FixtureServer::default()).await;
runtime.tool_filters.insert(
"fixture".to_string(),
single_layer_filter(LayerSource::Global, &["d*"]),
);
let items = runtime.catalog_items("fixture").await.unwrap();
assert!(items.contains_key("tool:dup"));
}
#[tokio::test]
async fn search_never_surfaces_filtered_tools() {
let fixture = FixtureServer {
resources_capability: true,
..Default::default()
};
let (mut runtime, _server) = fixture_runtime(fixture).await;
runtime
.tool_filters
.insert("fixture".to_string(), deny_all_filter());
let results = runtime.search("fixture", "dup", 10).await.unwrap();
assert!(
results
.iter()
.all(|item| item.kind != CatalogItemKind::Tool)
);
assert!(
results
.iter()
.any(|item| item.kind == CatalogItemKind::Resource)
);
}
#[tokio::test]
async fn describe_blocked_tool_is_indistinguishable_from_missing() {
let (unfiltered, _server) = fixture_runtime(FixtureServer::default()).await;
let missing = unfiltered
.describe("fixture", "tool", "ghost")
.await
.unwrap_err()
.to_string();
let (mut filtered, _other_server) = fixture_runtime(FixtureServer::default()).await;
filtered
.tool_filters
.insert("fixture".to_string(), deny_all_filter());
let blocked = filtered
.describe("fixture", "tool", "dup")
.await
.unwrap_err()
.to_string();
assert_eq!(blocked, "dup not found in fixture MCP server catalog");
assert_eq!(blocked, missing.replace("ghost", "dup"));
}
#[tokio::test]
async fn invoke_blocked_tool_errors_like_describe_and_never_reaches_server() {
let fixture = FixtureServer::default();
let call_tool_calls = Arc::clone(&fixture.call_tool_calls);
let (mut runtime, _server) = fixture_runtime(fixture).await;
runtime
.tool_filters
.insert("fixture".to_string(), deny_all_filter());
let invoke_err = runtime
.invoke("fixture", "dup", json!({}))
.await
.unwrap_err()
.to_string();
let describe_err = runtime
.describe("fixture", "tool", "dup")
.await
.unwrap_err()
.to_string();
assert_eq!(invoke_err, "dup not found in fixture MCP server catalog");
assert_eq!(invoke_err, describe_err);
assert_eq!(call_tool_calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn invoke_allowed_tool_still_reaches_the_server() {
let fixture = FixtureServer::default();
let call_tool_calls = Arc::clone(&fixture.call_tool_calls);
let (mut runtime, _server) = fixture_runtime(fixture).await;
runtime.tool_filters.insert(
"fixture".to_string(),
single_layer_filter(LayerSource::Global, &["d*"]),
);
let _ = runtime.invoke("fixture", "dup", json!({})).await;
assert_eq!(call_tool_calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn catalog_items_warns_on_dead_context_patterns() {
install_warn_collector();
let (mut runtime, _server) = fixture_runtime(FixtureServer::default()).await;
let mut filter = single_layer_filter(LayerSource::Global, &["*"]);
filter.push_layer(LayerSource::Session, &["zzz_*".to_string()]);
runtime.tool_filters.insert("fixture".to_string(), filter);
runtime.catalog_items("fixture").await.unwrap();
let messages = warn_messages().lock().unwrap();
assert!(
messages.iter().any(|msg| msg.contains("'zzz_*'")
&& msg.contains("session (.set)")
&& msg.contains("'fixture'")),
"missing dead-pattern warning in: {messages:?}"
);
}
} }
+58
View File
@@ -686,6 +686,7 @@ pub async fn run_agent_for_graph(
sync_agent_functions_to_ctx(&mut child_ctx)?; sync_agent_functions_to_ctx(&mut child_ctx)?;
} else { } else {
populate_agent_mcp_runtime(&mut child_ctx, &agent_mcp_servers).await?; populate_agent_mcp_runtime(&mut child_ctx, &agent_mcp_servers).await?;
child_ctx.refresh_mcp_tool_filters();
sync_agent_functions_to_ctx(&mut child_ctx)?; sync_agent_functions_to_ctx(&mut child_ctx)?;
child_ctx.init_agent_shared_variables()?; child_ctx.init_agent_shared_variables()?;
} }
@@ -869,6 +870,7 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
sync_agent_functions_to_ctx(&mut child_ctx)?; sync_agent_functions_to_ctx(&mut child_ctx)?;
} else { } else {
populate_agent_mcp_runtime(&mut child_ctx, &agent_mcp_servers).await?; populate_agent_mcp_runtime(&mut child_ctx, &agent_mcp_servers).await?;
child_ctx.refresh_mcp_tool_filters();
sync_agent_functions_to_ctx(&mut child_ctx)?; sync_agent_functions_to_ctx(&mut child_ctx)?;
child_ctx.init_agent_shared_variables()?; child_ctx.init_agent_shared_variables()?;
} }
@@ -1604,6 +1606,7 @@ mod tests {
use crate::config::test_fixtures::{FixtureServer, fixture_runtime}; use crate::config::test_fixtures::{FixtureServer, fixture_runtime};
use crate::config::{AgentConfig, AppState, WorkingMode}; use crate::config::{AgentConfig, AppState, WorkingMode};
use crate::function::jobs::RingBuf; use crate::function::jobs::RingBuf;
use crate::mcp::{McpServer, McpServersConfig, McpTransportType};
use crate::supervisor::escalation::{EscalationQueue, EscalationRequest}; use crate::supervisor::escalation::{EscalationQueue, EscalationRequest};
use crate::supervisor::{JobHandle, JobResult, JobState, JobStatus}; use crate::supervisor::{JobHandle, JobResult, JobState, JobStatus};
use parking_lot::Mutex; use parking_lot::Mutex;
@@ -1791,6 +1794,61 @@ mod tests {
assert!(!functions.contains("mcp_invoke_fixture")); assert!(!functions.contains("mcp_invoke_fixture"));
} }
fn app_state_with_fixture_mcp_config() -> Arc<AppState> {
let mut state = AppState::test_default();
state.mcp_config = Some(McpServersConfig {
mcp_servers: [(
"fixture".to_string(),
McpServer {
transport_type: McpTransportType::Stdio,
command: Some("echo".to_string()),
args: None,
env: None,
cwd: None,
url: None,
headers: None,
oauth: None,
allowed_tools: None,
},
)]
.into_iter()
.collect(),
});
Arc::new(state)
}
#[tokio::test]
async fn spawned_child_runtime_enforces_child_agent_filters() {
use std::sync::atomic::Ordering;
let config = AgentConfig {
mcp_tools: Some(IndexMap::from([(
"fixture".to_string(),
vec!["get_*".to_string()],
)])),
..Default::default()
};
let mut ctx = RequestContext::new(app_state_with_fixture_mcp_config(), WorkingMode::Cmd);
ctx.agent = Some(Agent::test_new(config));
let fixture = FixtureServer::default();
let call_tool_calls = Arc::clone(&fixture.call_tool_calls);
let (runtime, _server) = fixture_runtime(fixture).await;
ctx.tool_scope.mcp_runtime = runtime;
populate_agent_mcp_runtime(&mut ctx, &[]).await.unwrap();
ctx.refresh_mcp_tool_filters();
let err = ctx
.tool_scope
.mcp_runtime
.invoke("fixture", "dup", json!({}))
.await
.unwrap_err()
.to_string();
assert_eq!(err, "dup not found in fixture MCP server catalog");
assert_eq!(call_tool_calls.load(Ordering::SeqCst), 0);
}
#[test] #[test]
fn handle_list_running_empty_supervisor() { fn handle_list_running_empty_supervisor() {
let mut ctx = ctx_with_supervisor(4, 3); let mut ctx = ctx_with_supervisor(4, 3);
+44 -3
View File
@@ -363,9 +363,6 @@ fn whitelist_rejection(tool: &str) -> Option<Value> {
}) })
} }
/// Whether a declared tool could be run as a background job. This is the
/// declare-side twin of `whitelist_rejection`: a tool is backgroundable
/// exactly when `job__start` would not reject it by name.
pub fn is_backgroundable_tool(tool: &str) -> bool { pub fn is_backgroundable_tool(tool: &str) -> bool {
whitelist_rejection(tool).is_none() whitelist_rejection(tool).is_none()
} }
@@ -457,6 +454,11 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
.unwrap_or_else(|| json!({})); .unwrap_or_else(|| json!({}));
let mut mcp_runtime = McpRuntime::new(); let mut mcp_runtime = McpRuntime::new();
mcp_runtime.insert(server.clone(), Arc::clone(server_handle)); mcp_runtime.insert(server.clone(), Arc::clone(server_handle));
if let Some(filter) = ctx.tool_scope.mcp_runtime.tool_filters.get(&server) {
mcp_runtime
.tool_filters
.insert(server.clone(), filter.clone());
}
let job_ctx = JobCtx { let job_ctx = JobCtx {
mcp_runtime, mcp_runtime,
current_depth: ctx.current_depth, current_depth: ctx.current_depth,
@@ -1263,7 +1265,9 @@ fn tail_chars(text: &str, max_chars: usize) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::test_fixtures::{FixtureServer, fixture_runtime};
use crate::config::{AppConfig, AppState, WorkingMode}; use crate::config::{AppConfig, AppState, WorkingMode};
use crate::config::{LayerSource, ToolFilter};
use crate::function::agents::{ use crate::function::agents::{
GuardrailAction, check_pending_tasks_guardrail, handle_agent_tool, GuardrailAction, check_pending_tasks_guardrail, handle_agent_tool,
}; };
@@ -1697,6 +1701,43 @@ mod tests {
}); });
} }
#[test]
fn job_runtime_carries_the_servers_tool_filter() {
run_async(async {
let mut ctx = plain_ctx();
ctx.declared_function_names
.insert("mcp_invoke_fixture".into());
let fixture = FixtureServer::default();
let call_tool_calls = Arc::clone(&fixture.call_tool_calls);
let (mut runtime, _server) = fixture_runtime(fixture).await;
let mut filter = ToolFilter::default();
filter.push_layer(LayerSource::Global, &[]);
runtime.tool_filters.insert("fixture".to_string(), filter);
ctx.tool_scope.mcp_runtime = runtime;
let started = handle_start(
&mut ctx,
&json!({"tool": "mcp_invoke_fixture", "arguments": {"tool": "dup"}}),
)
.await
.unwrap();
let job_id = started["job_id"].as_str().unwrap().to_string();
let collected = handle_collect(&ctx, &json!({"id": job_id})).await.unwrap();
assert_eq!(collected["status"], "failed");
assert!(
collected["error"]
.as_str()
.unwrap()
.contains("dup not found in fixture MCP server catalog"),
"unexpected error: {}",
collected["error"]
);
assert_eq!(call_tool_calls.load(std::sync::atomic::Ordering::SeqCst), 0);
});
}
#[test] #[test]
fn handle_start_rejects_unconnected_mcp_server() { fn handle_start_rejects_unconnected_mcp_server() {
let mut ctx = plain_ctx(); let mut ctx = plain_ctx();
+1 -1
View File
@@ -424,7 +424,7 @@ async fn step(
Ok(StepResult::Continue(vec![next])) Ok(StepResult::Continue(vec![next]))
} }
NodeType::Llm(llm_node) => { NodeType::Llm(llm_node) => {
let outcome = LlmNodeExecutor::execute(llm_node, state, ctx).await?; let outcome = LlmNodeExecutor::execute(current, llm_node, state, ctx).await?;
let targets = match outcome { let targets = match outcome {
LlmExecutionOutcome::Continue => static_next_targets(node, current, "llm")?, LlmExecutionOutcome::Continue => static_next_targets(node, current, "llm")?,
LlmExecutionOutcome::FellBack(target) => vec![target], LlmExecutionOutcome::FellBack(target) => vec![target],
+13 -1
View File
@@ -30,11 +30,12 @@ pub struct LlmNodeExecutor;
impl LlmNodeExecutor { impl LlmNodeExecutor {
pub(super) async fn execute( pub(super) async fn execute(
node_id: &str,
node: &LlmNode, node: &LlmNode,
state_manager: &mut StateManager, state_manager: &mut StateManager,
parent_ctx: &mut RequestContext, parent_ctx: &mut RequestContext,
) -> Result<LlmExecutionOutcome> { ) -> Result<LlmExecutionOutcome> {
let result = run(node, state_manager, parent_ctx).await; let result = run(node_id, node, state_manager, parent_ctx).await;
let (output, failure_reason) = match result { let (output, failure_reason) = match result {
Ok(raw) => match &node.output_schema { Ok(raw) => match &node.output_schema {
Some(schema) => match structured::extract(&raw, schema, parent_ctx).await { Some(schema) => match structured::extract(&raw, schema, parent_ctx).await {
@@ -79,6 +80,7 @@ fn outcome_from(
} }
async fn run( async fn run(
node_id: &str,
node: &LlmNode, node: &LlmNode,
state_manager: &mut StateManager, state_manager: &mut StateManager,
parent_ctx: &mut RequestContext, parent_ctx: &mut RequestContext,
@@ -177,6 +179,13 @@ async fn run(
// Jobs are node-local: everything job__start registers while this node // Jobs are node-local: everything job__start registers while this node
// runs is recorded here and reaped on every exit path below. // runs is recorded here and reaped on every exit path below.
let saved_job_scope = parent_ctx.node_job_scope.replace(Vec::new()); let saved_job_scope = parent_ctx.node_job_scope.replace(Vec::new());
// The node's tool filter layer lives in tracked context state so any
// mid-node filter recompute (e.g. a skill load) re-applies it last.
let saved_node_mcp_tools = std::mem::replace(
&mut parent_ctx.active_node_mcp_tools,
node.mcp_tools.clone().map(|map| (node_id.to_string(), map)),
);
parent_ctx.refresh_mcp_tool_filters();
let result = match node.timeout { let result = match node.timeout {
Some(secs) => match timeout( Some(secs) => match timeout(
Duration::from_secs(secs), Duration::from_secs(secs),
@@ -193,6 +202,8 @@ async fn run(
let node_jobs = let node_jobs =
std::mem::replace(&mut parent_ctx.node_job_scope, saved_job_scope).unwrap_or_default(); std::mem::replace(&mut parent_ctx.node_job_scope, saved_job_scope).unwrap_or_default();
reap_jobs(parent_ctx.supervisor.as_ref(), &node_jobs).await; reap_jobs(parent_ctx.supervisor.as_ref(), &node_jobs).await;
parent_ctx.active_node_mcp_tools = saved_node_mcp_tools;
parent_ctx.refresh_mcp_tool_filters();
restore_agent_skill_policy(parent_ctx, saved_agent_skill_state); restore_agent_skill_policy(parent_ctx, saved_agent_skill_state);
result result
} }
@@ -506,6 +517,7 @@ mod tests {
instructions: Some("sys".into()), instructions: Some("sys".into()),
prompt: "user".into(), prompt: "user".into(),
tools: None, tools: None,
mcp_tools: None,
model: None, model: None,
temperature: None, temperature: None,
top_p: None, top_p: None,
+4 -2
View File
@@ -85,9 +85,11 @@ impl MapNodeExecutor {
let mut ctx = sub_ctx; let mut ctx = sub_ctx;
let exec_result: Result<()> = match &branch_clone.node_type { let exec_result: Result<()> = match &branch_clone.node_type {
NodeType::Llm(n) => LlmNodeExecutor::execute(n, &mut state, &mut ctx) NodeType::Llm(n) => {
LlmNodeExecutor::execute(&branch_clone.id, n, &mut state, &mut ctx)
.await .await
.map(|_| ()), .map(|_| ())
}
NodeType::Agent(n) => AgentNodeExecutor::execute(n, &mut state, &mut ctx) NodeType::Agent(n) => AgentNodeExecutor::execute(n, &mut state, &mut ctx)
.await .await
.map(|_| ()), .map(|_| ()),
+47
View File
@@ -37,6 +37,9 @@ pub struct Graph {
#[serde(default)] #[serde(default)]
pub mcp_servers: Vec<String>, pub mcp_servers: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub skills_enabled: Option<bool>, pub skills_enabled: Option<bool>,
@@ -285,6 +288,9 @@ pub struct LlmNode {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<String>>, pub tools: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>, pub model: Option<String>,
@@ -530,6 +536,47 @@ nodes:
} }
} }
#[test]
fn deserializes_mcp_tools_at_graph_and_node_level() {
let yaml = r#"
name: g
mcp_servers: [github]
mcp_tools:
github:
- get_*
- list_*
start: l
nodes:
l:
id: l
type: llm
prompt: hi
mcp_tools:
github:
- search_code
next: e
e:
id: e
type: end
output: done
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
graph.mcp_tools.as_ref().unwrap().get("github"),
Some(&vec!["get_*".to_string(), "list_*".to_string()])
);
match &graph.get_node("l").unwrap().node_type {
NodeType::Llm(llm) => {
assert_eq!(
llm.mcp_tools.as_ref().unwrap().get("github"),
Some(&vec!["search_code".to_string()])
);
}
_ => panic!("expected Llm variant"),
}
}
#[test] #[test]
fn deserializes_every_node_type() { fn deserializes_every_node_type() {
let yaml = r#" let yaml = r#"
+140
View File
@@ -1,6 +1,7 @@
use super::state::template_root_keys; use super::state::template_root_keys;
use super::types::{Graph, Node, NodeType}; use super::types::{Graph, Node, NodeType};
use crate::client::{Model, ModelType}; use crate::client::{Model, ModelType};
use crate::config;
use crate::config::{Agent, AppConfig, paths}; use crate::config::{Agent, AppConfig, paths};
use crate::rag::{GraphRagConfig, RagData}; use crate::rag::{GraphRagConfig, RagData};
use anyhow::{Result, bail}; use anyhow::{Result, bail};
@@ -214,6 +215,14 @@ impl GraphValidator {
return; return;
}; };
let expand_alias =
|name: &str| config::expand_mcp_server_alias(&ctx.app_config.mapping_mcp_servers, name);
let mut enabled_servers: HashSet<String> = ctx.mcp_servers.clone();
for server in &ctx.mcp_servers {
enabled_servers.extend(expand_alias(server));
}
let all_servers_enabled = ctx.mcp_servers.iter().any(|s| s.trim() == "all");
for (node_id, node) in &graph.nodes { for (node_id, node) in &graph.nodes {
let NodeType::Llm(llm) = &node.node_type else { let NodeType::Llm(llm) = &node.node_type else {
continue; continue;
@@ -237,6 +246,25 @@ impl GraphValidator {
} }
} }
if let Some(mcp_tools) = &llm.mcp_tools
&& !all_servers_enabled
{
for key in mcp_tools.keys() {
let enabled = enabled_servers.contains(key)
|| expand_alias(key)
.iter()
.any(|id| enabled_servers.contains(id));
if !enabled {
result.error(ValidationError::with_node(
node_id,
format!(
"llm node 'mcp_tools' references MCP server '{key}' not enabled by this graph"
),
));
}
}
}
if let Some(model_id) = &llm.model if let Some(model_id) = &llm.model
&& Model::retrieve_model(ctx.app_config.as_ref(), model_id, ModelType::Chat) && Model::retrieve_model(ctx.app_config.as_ref(), model_id, ModelType::Chat)
.is_err() .is_err()
@@ -1001,6 +1029,7 @@ mod tests {
max_concurrent_jobs: None, max_concurrent_jobs: None,
global_tools: Vec::new(), global_tools: Vec::new(),
mcp_servers: Vec::new(), mcp_servers: Vec::new(),
mcp_tools: None,
skills_enabled: None, skills_enabled: None,
enabled_skills: None, enabled_skills: None,
inject_skill_instructions: None, inject_skill_instructions: None,
@@ -1099,6 +1128,7 @@ mod tests {
instructions: None, instructions: None,
prompt: "p".into(), prompt: "p".into(),
tools: None, tools: None,
mcp_tools: None,
model: None, model: None,
temperature: None, temperature: None,
top_p: None, top_p: None,
@@ -1257,6 +1287,19 @@ mod tests {
node node
} }
fn llm_node_with_mcp_tools(id: &str, servers: &[&str]) -> Node {
let mut node = llm_node(id, None, Some("end"));
if let NodeType::Llm(ref mut n) = node.node_type {
let mut mcp_tools = IndexMap::new();
for server in servers {
mcp_tools.insert(server.to_string(), vec!["get_*".to_string()]);
}
n.mcp_tools = Some(mcp_tools);
}
node
}
#[test] #[test]
fn llm_node_unknown_tool_is_an_error() { fn llm_node_unknown_tool_is_an_error() {
let graph = graph_with( let graph = graph_with(
@@ -1340,6 +1383,103 @@ mod tests {
assert!(result.is_valid()); assert!(result.is_valid());
} }
#[test]
fn llm_node_mcp_tools_enabled_server_passes() {
let graph = graph_with(
vec![
("l", llm_node_with_mcp_tools("l", &["github"])),
("end", end_node("end")),
],
"l",
);
let result = validator()
.with_agent_context(agent_ctx(&[], &["github"]))
.validate(&graph);
assert!(result.is_valid());
}
#[test]
fn llm_node_mcp_tools_unknown_server_is_an_error() {
let graph = graph_with(
vec![
("l", llm_node_with_mcp_tools("l", &["slack"])),
("end", end_node("end")),
],
"l",
);
let result = validator()
.with_agent_context(agent_ctx(&[], &["github"]))
.validate(&graph);
assert!(!result.is_valid());
assert!(
result
.errors
.iter()
.any(|e| e.message.contains("'slack' not enabled"))
);
}
#[test]
fn llm_node_mcp_tools_alias_key_passes() {
let graph = graph_with(
vec![
("l", llm_node_with_mcp_tools("l", &["gh"])),
("end", end_node("end")),
],
"l",
);
let mut ctx = agent_ctx(&[], &["github-mcp"]);
let mut app = AppConfig::default();
app.mapping_mcp_servers
.insert("gh".to_string(), "github-mcp".to_string());
ctx.app_config = Arc::new(app);
let result = validator().with_agent_context(ctx).validate(&graph);
assert!(result.is_valid(), "errors: {:?}", result.errors);
}
#[test]
fn llm_node_mcp_tools_key_matching_alias_expansion_passes() {
let graph = graph_with(
vec![
("l", llm_node_with_mcp_tools("l", &["github-mcp"])),
("end", end_node("end")),
],
"l",
);
let mut ctx = agent_ctx(&[], &["gh"]);
let mut app = AppConfig::default();
app.mapping_mcp_servers
.insert("gh".to_string(), "github-mcp".to_string());
ctx.app_config = Arc::new(app);
let result = validator().with_agent_context(ctx).validate(&graph);
assert!(result.is_valid(), "errors: {:?}", result.errors);
}
#[test]
fn llm_node_mcp_tools_with_all_sentinel_passes() {
let graph = graph_with(
vec![
("l", llm_node_with_mcp_tools("l", &["github"])),
("end", end_node("end")),
],
"l",
);
let result = validator()
.with_agent_context(agent_ctx(&[], &["all"]))
.validate(&graph);
assert!(result.is_valid(), "errors: {:?}", result.errors);
}
#[test] #[test]
fn llm_node_unknown_model_is_an_error() { fn llm_node_unknown_model_is_an_error() {
let graph = graph_with( let graph = graph_with(
+2
View File
@@ -242,6 +242,7 @@ fn build_stdio(cli: &Cli, has_url: bool) -> Result<McpServer> {
url: None, url: None,
headers: None, headers: None,
oauth: None, oauth: None,
allowed_tools: None,
}) })
} }
@@ -300,6 +301,7 @@ fn build_remote(cli: &Cli, transport: McpTransportType, has_command: bool) -> Re
url: Some(url), url: Some(url),
headers: (!headers.is_empty()).then_some(headers), headers: (!headers.is_empty()).then_some(headers),
oauth, oauth,
allowed_tools: None,
}) })
} }
+23 -1
View File
@@ -6,7 +6,7 @@ mod sse_transport;
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::config::paths; use crate::config::paths;
use crate::utils::{AbortSignal, abortable_run_with_spinner}; use crate::utils::{AbortSignal, abortable_run_with_spinner, dimmed_text};
use crate::vault::Vault; use crate::vault::Vault;
use crate::vault::interpolate_secrets; use crate::vault::interpolate_secrets;
use anyhow::Error; use anyhow::Error;
@@ -166,6 +166,8 @@ pub(crate) struct McpServer {
pub headers: Option<IndexMap<String, String>>, pub headers: Option<IndexMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub oauth: Option<McpOAuthConfig>, pub oauth: Option<McpOAuthConfig>,
#[serde(rename = "allowedTools", skip_serializing_if = "Option::is_none")]
pub allowed_tools: Option<Vec<String>>,
} }
impl McpServer { impl McpServer {
@@ -177,6 +179,15 @@ impl McpServer {
} }
pub fn validate(&self, name: &str) -> Result<()> { pub fn validate(&self, name: &str) -> Result<()> {
if let Some(tools) = &self.allowed_tools
&& tools.is_empty()
{
let message = format!(
"MCP server '{name}' has an empty \"allowedTools\" list, so none of its tools will be callable"
);
warn!("{message}");
eprintln!("{}", dimmed_text(&message));
}
if self.is_remote() { if self.is_remote() {
let type_label = match self.transport_type { let type_label = match self.transport_type {
McpTransportType::Http => "http", McpTransportType::Http => "http",
@@ -802,6 +813,7 @@ mod tests {
url: None, url: None,
headers: None, headers: None,
oauth: None, oauth: None,
allowed_tools: None,
} }
} }
@@ -815,6 +827,7 @@ mod tests {
url: Some(url.to_string()), url: Some(url.to_string()),
headers: None, headers: None,
oauth: None, oauth: None,
allowed_tools: None,
} }
} }
@@ -828,6 +841,7 @@ mod tests {
url: Some(url.to_string()), url: Some(url.to_string()),
headers: None, headers: None,
oauth: None, oauth: None,
allowed_tools: None,
} }
} }
@@ -860,6 +874,7 @@ mod tests {
url: None, url: None,
headers: None, headers: None,
oauth: None, oauth: None,
allowed_tools: None,
}; };
let err = spec.validate("test").unwrap_err(); let err = spec.validate("test").unwrap_err();
@@ -878,6 +893,7 @@ mod tests {
url: Some("http://localhost".into()), url: Some("http://localhost".into()),
headers: None, headers: None,
oauth: None, oauth: None,
allowed_tools: None,
}; };
let err = spec.validate("test").unwrap_err(); let err = spec.validate("test").unwrap_err();
@@ -898,6 +914,7 @@ mod tests {
url: None, url: None,
headers: Some(headers), headers: Some(headers),
oauth: None, oauth: None,
allowed_tools: None,
}; };
let err = spec.validate("test").unwrap_err(); let err = spec.validate("test").unwrap_err();
@@ -923,6 +940,7 @@ mod tests {
url: None, url: None,
headers: None, headers: None,
oauth: None, oauth: None,
allowed_tools: None,
}; };
let err = spec.validate("test").unwrap_err(); let err = spec.validate("test").unwrap_err();
@@ -941,6 +959,7 @@ mod tests {
url: Some("http://localhost".into()), url: Some("http://localhost".into()),
headers: None, headers: None,
oauth: None, oauth: None,
allowed_tools: None,
}; };
let err = spec.validate("test").unwrap_err(); let err = spec.validate("test").unwrap_err();
@@ -959,6 +978,7 @@ mod tests {
url: Some("http://localhost".into()), url: Some("http://localhost".into()),
headers: None, headers: None,
oauth: None, oauth: None,
allowed_tools: None,
}; };
let err = spec.validate("test").unwrap_err(); let err = spec.validate("test").unwrap_err();
@@ -977,6 +997,7 @@ mod tests {
url: Some("http://localhost".into()), url: Some("http://localhost".into()),
headers: None, headers: None,
oauth: None, oauth: None,
allowed_tools: None,
}; };
let err = spec.validate("test").unwrap_err(); let err = spec.validate("test").unwrap_err();
@@ -1002,6 +1023,7 @@ mod tests {
url: None, url: None,
headers: None, headers: None,
oauth: None, oauth: None,
allowed_tools: None,
}; };
let err = spec.validate("test").unwrap_err(); let err = spec.validate("test").unwrap_err();
+18 -3
View File
@@ -55,7 +55,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
4. Continue with the next pending item now. Call tools immediately." 4. Continue with the next pending item now. Call tools immediately."
}; };
static REPL_COMMANDS: LazyLock<[ReplCommand; 62]> = LazyLock::new(|| { static REPL_COMMANDS: LazyLock<[ReplCommand; 63]> = LazyLock::new(|| {
[ [
ReplCommand::new(".help", "Show this help guide", AssertState::pass()), ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
ReplCommand::new(".info", "Show system info", AssertState::pass()), ReplCommand::new(".info", "Show system info", AssertState::pass()),
@@ -64,6 +64,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 62]> = LazyLock::new(|| {
"Show the list of enabled tools to be passed to the LLM", "Show the list of enabled tools to be passed to the LLM",
AssertState::True(StateFlags::FUNCTION_CALLING), AssertState::True(StateFlags::FUNCTION_CALLING),
), ),
ReplCommand::new(
".info mcp-server",
"Show an MCP server's tool filters and effective catalog",
AssertState::pass(),
),
ReplCommand::new( ReplCommand::new(
".authenticate", ".authenticate",
"Authenticate the current model client via OAuth (if configured)", "Authenticate the current model client via OAuth (if configured)",
@@ -639,6 +644,16 @@ pub async fn run_repl_command(
let info = ctx.todo_info()?; let info = ctx.todo_info()?;
print!("{info}"); print!("{info}");
} }
Some(arg) if arg.starts_with("mcp-server") => {
let mut parts = arg.splitn(2, char::is_whitespace);
parts.next();
let name = parts.next().map(str::trim).unwrap_or("");
if name.is_empty() {
bail!("Usage: .info mcp-server <server>");
}
let info = ctx.mcp_server_info(name).await?;
print!("{info}");
}
Some(_) => unknown_command()?, Some(_) => unknown_command()?,
None => { None => {
let app = Arc::clone(&ctx.app.config); let app = Arc::clone(&ctx.app.config);
@@ -1959,8 +1974,8 @@ mod tests {
} }
#[test] #[test]
fn repl_commands_has_62_entries() { fn repl_commands_has_63_entries() {
assert_eq!(REPL_COMMANDS.len(), 62); assert_eq!(REPL_COMMANDS.len(), 63);
} }
#[test] #[test]