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"
+64 -54
View File
@@ -11,64 +11,74 @@
# - <agent-name>_AGENT_SESSION # - <agent-name>_AGENT_SESSION
# - <agent-name>_VARIABLES (as JSON array of key-value pairs; e.g. '[{"name": "username", "value": "alex"}]') # - <agent-name>_VARIABLES (as JSON array of key-value pairs; e.g. '[{"name": "username", "value": "alex"}]')
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:
# Only valid when the agent's model declares reasoning_levels. null # Reasoning effort level for models that support it (e.g. low, medium, high).
agent_session: null # Set a session to use when starting the agent. (e.g. temp, default); defaults to globally set agent_session # Only valid when the agent's model declares reasoning_levels.
name: <agent-name> # Name of the agent, used in the UI and logs agent_session: null # Set a session to use when starting the agent. (e.g. temp, default); defaults to globally set agent_session
description: <description> # Description of the agent, used in the UI name: <agent-name> # Name of the agent, used in the UI and logs
version: 1 # Version of the agent description: <description> # Description of the agent, used in the UI
version: 1 # Version of the agent
# Auto-Continue (Todo System) # Auto-Continue (Todo System)
# The auto-continue system provides built-in task tracking for improved reliability. # The auto-continue system provides built-in task tracking for improved reliability.
# When enabled, the model can create todo lists and the system will automatically # When enabled, the model can create todo lists and the system will automatically
# prompt it to continue when incomplete tasks remain. # prompt it to continue when incomplete tasks remain.
# See the [Todo System documentation](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System) for more information # See the [Todo System documentation](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System) for more information
auto_continue: false # Enable automatic continuation when incomplete todos remain auto_continue: false # Enable automatic continuation when incomplete todos remain
max_auto_continues: 10 # Maximum number of automatic continuations before stopping max_auto_continues: 10 # Maximum number of automatic continuations before stopping
inject_todo_instructions: true # Inject the default todo tool usage instructions into the agent's system prompt inject_todo_instructions: true # Inject the default todo tool usage instructions into the agent's system prompt
continuation_prompt: null # Custom prompt used when auto-continuing (optional; uses default if null) continuation_prompt: null # Custom prompt used when auto-continuing (optional; uses default if null)
# Sub-Agent Spawning System # Sub-Agent Spawning System
# Enable this agent to spawn and manage child agents in parallel. # Enable this agent to spawn and manage child agents in parallel.
# See https://github.com/Dark-Alex-17/coyote/wiki/Agents for detailed documentation. # See https://github.com/Dark-Alex-17/coyote/wiki/Agents for detailed documentation.
can_spawn_agents: false # Enable the agent to spawn child agents can_spawn_agents: false # Enable the agent to spawn child agents
# spawnable_agents: # Optional whitelist restricting which agents can be spawned via `agent__spawn`. # spawnable_agents: # Optional whitelist restricting which agents can be spawned via `agent__spawn`.
# - 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:
# (overrides the global setting; 0 disables background jobs for this agent) 5 # Max background jobs (`job__*` tools) running at once for this agent
inject_spawn_instructions: true # Inject the default agent spawning instructions into the agent's system prompt # (overrides the global setting; 0 disables background jobs for this agent)
summarization_model: null # Model to use for summarizing sub-agent output (e.g. 'openai:gpt-4o-mini'); defaults to current model inject_spawn_instructions: true # Inject the default agent spawning instructions into the agent's system prompt
summarization_threshold: 4000 # Character threshold above which sub-agent output is summarized before returning to parent summarization_model: null # Model to use for summarizing sub-agent output (e.g. 'openai:gpt-4o-mini'); defaults to current model
escalation_timeout: 300 # Seconds a sub-agent waits for a user interaction response before timing out (default: 5 minutes) summarization_threshold: 4000 # Character threshold above which sub-agent output is summarized before returning to parent
mcp_servers: # Optional list of MCP servers that the agent utilizes escalation_timeout: 300 # Seconds a sub-agent waits for a user interaction response before timing out (default: 5 minutes)
- github # Corresponds to the name of an MCP server in the `<coyote-config-dir>/mcp.json` file mcp_servers: # Optional list of MCP servers that the agent utilizes
global_tools: # Optional list of additional global tools to enable for the agent; i.e. not tools specific to the agent - 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
- web_search - web_search
- fs - fs
- python - python
skills_enabled: true # Master switch for skills in this agent (default: inherit from global). skills_enabled:
# Skills also require `function_calling_support: true` in the global config. true # Master switch for skills in this agent (default: inherit from global).
enabled_skills: # Optional list of skills available when this agent runs. # Skills also require `function_calling_support: true` in the global config.
# Must be a subset of global `visible_skills`. Omit to inherit the global default. 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.
- 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:
# (default: true). Suppressed automatically when no skills are available. true # Inject a short hint pointing the model at `skill__list` when skills are enabled
skill_instructions: null # Custom text for the skill hint (optional; uses built-in default if null) # (default: true). Suppressed automatically when no skills are available.
enabled_macros: # Optional list of macros invocable when this agent is active in the REPL. skill_instructions: null # Custom text for the skill hint (optional; uses built-in default if null)
- generate-commit-message # An empty list disables all macros. Omit to inherit the role/global default. enabled_macros: # Optional list of macros invocable when this agent is active in the REPL.
memory: null # Per-agent memory override (default: inherit). Set to `false` to disable memory - generate-commit-message # An empty list disables all macros. Omit to inherit the role/global default.
# for this agent regardless of workspace/global presence. See the Memory wiki page. 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.
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>
@@ -88,12 +98,12 @@ instructions: | # Static instructions for the agent; ignored if
<user> <user>
username: {{username}} username: {{username}}
</user> </user>
variables: # Optional variables for the agent variables: # Optional variables for the agent
# The variables defined above like {{__variable_name__}} are automatically available # The variables defined above like {{__variable_name__}} are automatically available
- name: username - name: username
description: Your user name description: Your user name
default: null # A default value for this variable; if null, the variable must be provided when starting the agent default: null # A default value for this variable; if null, the variable must be provided when starting the agent
conversation_starters: # Optional conversation starters for the agent conversation_starters: # Optional conversation starters for the agent
- What is the meaning of life? - What is the meaning of life?
- Tell me a joke. - Tell me a joke.
- What is the capital of France? - What is the capital of France?
@@ -104,15 +114,15 @@ conversation_starters: # Optional conversation starters for the agent
- How do I stay motivated? - How do I stay motivated?
- What is the best way to exercise? - What is the best way to exercise?
- How do I manage my time effectively? - How do I manage my time effectively?
documents: # Optional documents to load for the agent documents: # Optional documents to load for the agent
# To enable graph-based RAG (entity/relationship extraction + knowledge graph retrieval), # To enable graph-based RAG (entity/relationship extraction + knowledge graph retrieval),
# set `rag_extractor_model` in your global config.yaml. # set `rag_extractor_model` in your global config.yaml.
# See https://github.com/Dark-Alex-17/coyote/wiki/RAG#graph-based-rag # See https://github.com/Dark-Alex-17/coyote/wiki/RAG#graph-based-rag
- git:/some/repo # Explicitly tell Coyote to use the 'git' document loader using an absolute path - git:/some/repo # Explicitly tell Coyote to use the 'git' document loader using an absolute path
- pdf:some-pdf-file.pdf # Explicitly tell Coyote to use the 'pdf' document loader using a relative path - pdf:some-pdf-file.pdf # Explicitly tell Coyote to use the 'pdf' document loader using a relative path
- https://some-website.com/some-page - https://some-website.com/some-page
- some-file.pdf # File with relative path to the <coyote-config-dir>/agents/<agent-name> directory; i.e. file in the same directory as this config file - some-file.pdf # File with relative path to the <coyote-config-dir>/agents/<agent-name> directory; i.e. file in the same directory as this config file
- ~/some-file.txt # File in the user's home directory - ~/some-file.txt # File in the user's home directory
- /absolute/path/to/some-file.md # File with absolute path - /absolute/path/to/some-file.md # File with absolute path
- /absolute/path/**/NAME.txt # Find all NAME.txt files in the specified directory and all its subdirectories - /absolute/path/**/NAME.txt # Find all NAME.txt files in the specified directory and all its subdirectories
- /absolute/path/to/*/README.md # Find all README.md files in all immediate subdirectories of the specified directory (depth=1) - /absolute/path/to/*/README.md # Find all README.md files in all immediate subdirectories of the specified directory (depth=1)
+195 -168
View File
@@ -1,40 +1,39 @@
# ---- LLM ---- # ---- LLM ----
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:
# Only valid when the active model declares reasoning_levels. See the Clients docs. 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.
# ---- Behavior ---- # ---- Behavior ----
stream: true # Controls whether to use the stream-style APIs when querying for completions from LLM clients. stream: true # Controls whether to use the stream-style APIs when querying for completions from LLM clients.
save: true # Indicates whether to persist the conversation to messages.md for posterity save: true # Indicates whether to persist the conversation to messages.md for posterity
keybindings: emacs # Choose keybinding style (emacs, vi) keybindings: emacs # Choose keybinding style (emacs, vi)
editor: null # Specifies the editor used to edit the input buffer or session. (e.g. vim, emacs, nano, hx). Defaults to $EDITOR editor: null # Specifies the editor used to edit the input buffer or session. (e.g. vim, emacs, nano, hx). Defaults to $EDITOR
wrap: no # Controls text wrapping (no, auto, <max-width>) wrap: no # Controls text wrapping (no, auto, <max-width>)
wrap_code: false # Enables or disables the wrapping of code blocks wrap_code: false # Enables or disables the wrapping of code blocks
# ---- Prelude ---- # ---- Prelude ----
repl_prelude: null # Set a default session or role for REPL mode to use (e.g. role:<name>, session:<name>, <session>:<role>) repl_prelude: null # Set a default session or role for REPL mode to use (e.g. role:<name>, session:<name>, <session>:<role>)
cmd_prelude: null # Set a default session or role for CMD mode to use (e.g. role:<name>, session:<name>, <session>:<role>) cmd_prelude: null # Set a default session or role for CMD mode to use (e.g. role:<name>, session:<name>, <session>:<role>)
agent_session: null # Set a session to use when starting an agent (e.g. temp, default) agent_session: null # Set a session to use when starting an agent (e.g. temp, default)
# ---- Appearance ---- # ---- Appearance ----
highlight: true # Controls syntax highlighting highlight: true # Controls syntax highlighting
raw_markdown: false # When true, render markdown as raw text with syntax highlighting only. When false (default), transforms markdown syntax (headings, bold, lists, etc.) into styled terminal output raw_markdown: false # When true, render markdown as raw text with syntax highlighting only. When false (default), transforms markdown syntax (headings, bold, lists, etc.) into styled terminal output
light_theme: false # Activates a light color theme when true. env: COYOTE_LIGHT_THEME light_theme: false # Activates a light color theme when true. env: COYOTE_LIGHT_THEME
# ---- Miscellaneous ---- # ---- Miscellaneous ----
user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version> user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version>
save_shell_history: true # Whether to save shell execution command to the history file save_shell_history: true # Whether to save shell execution command to the history file
sync_models_url: > # URL to sync model changes from sync_models_url: > # URL to sync model changes from
https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml
# ---- 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.
@@ -42,7 +41,7 @@ right_prompt:
# The secrets_provider tells Coyote where to read and write secrets referenced via {{SECRET_NAME}} syntax. # The secrets_provider tells Coyote where to read and write secrets referenced via {{SECRET_NAME}} syntax.
# #
# Shorthand: set vault_password_file to enable the local provider with that password file. # Shorthand: set vault_password_file to enable the local provider with that password file.
vault_password_file: null # Path to a file containing the password for the Coyote vault (cannot be a secret template) vault_password_file: null # Path to a file containing the password for the Coyote vault (cannot be a secret template)
# #
# Explicit: set secrets_provider to one of the supported types below. When secrets_provider is set, # Explicit: set secrets_provider to one of the supported types below. When secrets_provider is set,
# vault_password_file is ignored. Note: secrets_provider itself cannot use {{SECRET}} template syntax. # vault_password_file is ignored. Note: secrets_provider itself cannot use {{SECRET}} template syntax.
@@ -82,38 +81,39 @@ vault_password_file: null # Path to a file containing the password for th
# ---- Function Calling ---- # ---- Function Calling ----
# See the [Tools documentation](https://github.com/Dark-Alex-17/coyote/wiki/Tools) for more details # See the [Tools documentation](https://github.com/Dark-Alex-17/coyote/wiki/Tools) for more details
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:
# Accepts either a YAML list or a comma-separated string. Use 'all' to enable everything. null # Which tools to enable by default.
# Example (list form): # Accepts either a YAML list or a comma-separated string. Use 'all' to enable everything.
# enabled_tools: # Example (list form):
# - fs # enabled_tools:
# - web_search_coyote # - fs
# Example (comma-separated form): # - web_search_coyote
# enabled_tools: fs,web_search_coyote # Example (comma-separated form):
visible_tools: # Which tools are visible to be compiled (and are thus able to be defined in 'enabled_tools') # enabled_tools: fs,web_search_coyote
# - ast_grep.sh visible_tools: # Which tools are visible to be compiled (and are thus able to be defined in 'enabled_tools')
# - demo_py.py # - ast_grep.sh
# - demo_sh.sh # - demo_py.py
# - demo_ts.ts # - demo_sh.sh
# - 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
@@ -126,85 +126,106 @@ visible_tools: # Which tools are visible to be compiled (and a
# ---- MCP Servers ---- # ---- MCP Servers ----
# See the [MCP Servers documentation](https://github.com/Dark-Alex-17/coyote/wiki/MCP-Servers) for more details # See the [MCP Servers documentation](https://github.com/Dark-Alex-17/coyote/wiki/MCP-Servers) for more details
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:
# Accepts either a YAML list or a comma-separated string. Use 'all' to enable everything. null # Which MCP servers to enable by default.
# Example (list form): # Accepts either a YAML list or a comma-separated string. Use 'all' to enable everything.
# enabled_mcp_servers: # Example (list form):
# - github # enabled_mcp_servers:
# - slack # - github
# Example (comma-separated form): # - slack
# enabled_mcp_servers: github,slack,ddg-search # Example (comma-separated form):
no_workspace_mcp: false # Disable loading workspace-local MCP servers (default: false). # enabled_mcp_servers: github,slack,ddg-search
# When false (the default), Coyote merges the first workspace MCP config it finds mcp_tools:
# into the global MCP registry at startup, checking in order: null # Per-server MCP tool allowlists (glob patterns: * and ? supported).
# 1. .coyote/mcp.json # Tools that match no pattern are hidden from the model as if they
# 2. .coyote/.mcp.json (Claude-style file name) # don't exist. Stacks with the other allowlist layers (mcp.json
# 3. .mcp.json (project root; Claude Code convention) # `allowedTools`, role, agent, session, skill, graph node). Every
# Workspace entries shadow global ones on name collision. # configured layer must allow a tool, so layers only ever narrow.
# Set to true (or pass --no-workspace-mcp) to skip this entirely. # 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
# into the global MCP registry at startup, checking in order:
# 1. .coyote/mcp.json
# 2. .coyote/.mcp.json (Claude-style file name)
# 3. .mcp.json (project root; Claude Code convention)
# Workspace entries shadow global ones on name collision.
# Set to true (or pass --no-workspace-mcp) to skip this entirely.
# ---- 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:
# Skills also require `function_calling_support: true` above to work at all. true # Master switch. Set to false to hide all skill management tools from the model.
visible_skills: # The universe of skills allowed to be enabled in any context. Omit (null) for "all installed". # 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".
- 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:
# Accepts either a YAML list or a comma-separated string. null # Which skills are available by default (no role/agent/session active). null = all visible.
# Example (list form): # Accepts either a YAML list or a comma-separated string.
# enabled_skills: # Example (list form):
# - git-master # enabled_skills:
# - ai-slop-remover # - git-master
# Example (comma-separated form): # - ai-slop-remover
# enabled_skills: git-master,ai-slop-remover # Example (comma-separated form):
inject_skill_instructions: true # Inject a short hint pointing the model at `skill__list` when skills are enabled in # enabled_skills: git-master,ai-slop-remover
# this context. Only injected if `function_calling_support`, `skills_enabled`, and the inject_skill_instructions:
# effective enabled skill set is non-empty (default: true). true # Inject a short hint pointing the model at `skill__list` when skills are enabled in
skill_instructions: null # Custom text used for the skill hint when injected. If null, uses built-in default. # this context. Only injected if `function_calling_support`, `skills_enabled`, and the
# 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.
# ---- Macros ---- # ---- Macros ----
# Macros are Coyote's custom commands: named sequences of REPL commands and prompts, invoked directly by name # Macros are Coyote's custom commands: named sequences of REPL commands and prompts, invoked directly by name
# (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:
# An empty list means NO macros are invocable. Accepts either a YAML list or a null # Which macros are invocable by default (no role/agent/session active). null = all visible.
# comma-separated string. Roles, agents, and sessions may define their own # An empty list means NO macros are invocable. Accepts either a YAML list or a
# `enabled_macros`; the most specific active one wins (session > agent > role > global). # comma-separated string. Roles, agents, and sessions may define their own
# Example (list form): # `enabled_macros`; the most specific active one wins (session > agent > role > global).
# enabled_macros: # Example (list form):
# - generate-commit-message # enabled_macros:
# Example (comma-separated form): # - generate-commit-message
# enabled_macros: generate-commit-message,review # Example (comma-separated form):
# enabled_macros: generate-commit-message,review
# ---- Auto-Continue (Todo System) ---- # ---- Auto-Continue (Todo System) ----
# The auto-continue system provides built-in task tracking for improved reliability. # The auto-continue system provides built-in task tracking for improved reliability.
# When enabled, the model can create todo lists and the system will automatically # When enabled, the model can create todo lists and the system will automatically
# prompt it to continue when incomplete tasks remain. # prompt it to continue when incomplete tasks remain.
# See the [Todo System documentation](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System) for more information # See the [Todo System documentation](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System) for more information
auto_continue: false # Enable automatic continuation when incomplete todos remain (default: false) auto_continue: false # Enable automatic continuation when incomplete todos remain (default: false)
max_auto_continues: 10 # Maximum number of automatic continuations before stopping (default: 10) max_auto_continues: 10 # Maximum number of automatic continuations before stopping (default: 10)
inject_todo_instructions: true # Inject default todo usage instructions into the system prompt (default: true) inject_todo_instructions: true # Inject default todo usage instructions into the system prompt (default: true)
continuation_prompt: null # Custom prompt used when auto-continuing. If null, uses built-in default continuation_prompt: null # Custom prompt used when auto-continuing. If null, uses built-in default
# ---- Session ---- # ---- Session ----
# 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)
max_concurrent_jobs: 5 # Max background jobs (`job__*` tools) running at once per context (default: 5; 0 disables background jobs entirely) max_concurrent_jobs: 5 # Max background jobs (`job__*` tools) running at once per context (default: 5; 0 disables background jobs entirely)
# ---- Memory ---- # ---- Memory ----
# See the [Memory documentation](https://github.com/Dark-Alex-17/coyote/wiki/Memory) for more information. # See the [Memory documentation](https://github.com/Dark-Alex-17/coyote/wiki/Memory) for more information.
@@ -213,11 +234,13 @@ max_concurrent_jobs: 5 # Max background jobs (`job__*` tools) running
# even when memory files exist. The cascade is: agent > session > role > app. # even when memory files exist. The cascade is: agent > session > role > app.
# 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:
# Only MEMORY.md indexes are injected; the LLM uses memory__read to fetch drill files. null # Char cap for injected memory when function calling is available (default: 6000).
memory_cap_without_tools: null # Char cap when function calling is unavailable (default: 12000). # Only MEMORY.md indexes are injected; the LLM uses memory__read to fetch drill files.
# Indexes plus drill file bodies are injected up to this cap. 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.
# ---- Workspace Instructions ---- # ---- Workspace Instructions ----
# Human-curated project instructions injected read-only into the system prompt, in full. # Human-curated project instructions injected read-only into the system prompt, in full.
@@ -225,22 +248,23 @@ memory_cap_without_tools: null # Char cap when function calling is unavailable
# chain below (per directory, in order). Scaffold with `coyote --init-instructions`. # chain below (per directory, in order). Scaffold with `coyote --init-instructions`.
# 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:
# Default: [COYOTE.md, AGENTS.md, CLAUDE.md, GEMINI.md] null # File name chain to search, in priority order.
# Set to a custom list to reorder or drop fallbacks, e.g.: # Default: [COYOTE.md, AGENTS.md, CLAUDE.md, GEMINI.md]
# workspace_instructions_files: [COYOTE.md] # Set to a custom list to reorder or drop fallbacks, e.g.:
# workspace_instructions_files: [COYOTE.md]
# ---- RAG ---- # ---- RAG ----
# See the [RAG Docs](https://github.com/Dark-Alex-17/coyote/wiki/RAG) for more details. # See the [RAG Docs](https://github.com/Dark-Alex-17/coyote/wiki/RAG) for more details.
rag_embedding_model: null # Specifies the embedding model used for context retrieval rag_embedding_model: null # Specifies the embedding model used for context retrieval
rag_reranker_model: null # Specifies the reranker model used for sorting retrieved documents; Coyote uses Reciprocal Rank Fusion by default rag_reranker_model: null # Specifies the reranker model used for sorting retrieved documents; Coyote uses Reciprocal Rank Fusion by default
rag_top_k: 5 # Specifies the number of documents to retrieve for answering queries rag_top_k: 5 # Specifies the number of documents to retrieve for answering queries
rag_chunk_size: null # Defines the size of chunks for document processing in characters rag_chunk_size: null # Defines the size of chunks for document processing in characters
rag_chunk_overlap: null # Defines the overlap between chunks rag_chunk_overlap: null # Defines the overlap between chunks
rag_extractor_model: null # LLM model for graph-based entity/relationship extraction; when set, enables a graph RAG signal alongside vector and BM25 rag_extractor_model: null # LLM model for graph-based entity/relationship extraction; when set, enables a graph RAG signal alongside vector and BM25
rag_extractor_prompt: null # Custom extraction prompt template; must contain __CHUNK__ placeholder; defaults to built-in prompt when null rag_extractor_prompt: null # Custom extraction prompt template; must contain __CHUNK__ placeholder; defaults to built-in prompt when null
rag_graph_hops: 1 # Number of hops to expand from matched entities at query time (0 = seed nodes only; 1 = direct neighbors; increase for denser graphs) rag_graph_hops: 1 # Number of hops to expand from matched entities at query time (0 = seed nodes only; 1 = direct neighbors; increase for denser graphs)
# Defines the query structure using variables like __CONTEXT__, __SOURCES__, and __INPUT__ to tailor searches to specific needs # Defines the query structure using variables like __CONTEXT__, __SOURCES__, and __INPUT__ to tailor searches to specific needs
rag_template: | rag_template: |
Answer the query based on the context while respecting the rules. (user query, some textual context and rules, all inside xml tags) Answer the query based on the context while respecting the rules. (user query, some textual context and rules, all inside xml tags)
@@ -271,13 +295,14 @@ document_loaders:
# You can add custom loaders using the following syntax: # You can add custom loaders using the following syntax:
# <file-extension>: <command-to-load-the-file> # <file-extension>: <command-to-load-the-file>
# Note: Use `$1` for input file and `$2` for output file. If `$2` is omitted, use stdout as output. # Note: Use `$1` for input file and `$2` for output file. If `$2` is omitted, use stdout as output.
pdf: 'pdftotext $1 -' # Use pdftotext to convert a PDF file to text pdf: 'pdftotext $1 -' # Use pdftotext to convert a PDF file to text
# (see https://poppler.freedesktop.org for details on how to install pdftotext) # (see https://poppler.freedesktop.org for details on how to install pdftotext)
docx: 'pandoc --to plain $1' # Use pandoc to convert a .docx file to text docx: 'pandoc --to plain $1' # Use pandoc to convert a .docx file to text
# (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 ----
@@ -312,15 +337,15 @@ clients:
# See https://platform.openai.com/docs/quickstart # See https://platform.openai.com/docs/quickstart
- type: openai - type: openai
api_base: https://api.openai.com/v1 # Optional api_base: https://api.openai.com/v1 # Optional
api_key: '{{OPENAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{OPENAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
organization_id: org-xxx # Optional organization_id: org-xxx # Optional
# For any platform compatible with OpenAI's API # For any platform compatible with OpenAI's API
- type: openai-compatible - type: openai-compatible
name: ollama name: ollama
api_base: http://localhost:11434/v1 api_base: http://localhost:11434/v1
api_key: '{{OLLAMA_API_KEY}}' # Optional; You can either hard-code or inject secrets from the Coyote vault api_key: '{{OLLAMA_API_KEY}}' # Optional; You can either hard-code or inject secrets from the Coyote vault
models: models:
- name: deepseek-r1 - name: deepseek-r1
max_input_tokens: 131072 max_input_tokens: 131072
@@ -338,9 +363,10 @@ clients:
# See https://ai.google.dev/docs # See https://ai.google.dev/docs
- 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:
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL null # When set to 'oauth', Coyote will use OAuth instead of an API key
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
patch: patch:
chat_completions: chat_completions:
'.*': '.*':
@@ -357,25 +383,27 @@ clients:
# See https://docs.anthropic.com/claude/reference/getting-started-with-the-api # See https://docs.anthropic.com/claude/reference/getting-started-with-the-api
- 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:
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL null # When set to 'oauth', Coyote will use OAuth instead of an API key
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
# See https://docs.mistral.ai/ # See https://docs.mistral.ai/
- type: openai-compatible - type: openai-compatible
name: mistral name: mistral
api_base: https://api.mistral.ai/v1 api_base: https://api.mistral.ai/v1
api_key: '{{MISTRAL_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{MISTRAL_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
# See https://docs.x.ai/docs - OAuth via SuperGrok / X Premium+ subscription # See https://docs.x.ai/docs - OAuth via SuperGrok / X Premium+ subscription
- type: openai-compatible - type: openai-compatible
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:
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL null # When set to 'oauth', Coyote will use OAuth instead of an API key
# Note: Oauth requires SuperGrok/X Premium+ subscription # Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
# Note: Oauth requires SuperGrok/X Premium+ subscription
# Example: private OpenAI-compatible gateway with client_credentials OAuth # Example: private OpenAI-compatible gateway with client_credentials OAuth
# - type: openai-compatible # - type: openai-compatible
@@ -405,31 +433,31 @@ clients:
- type: openai-compatible - type: openai-compatible
name: ai12 name: ai12
api_base: https://api.ai21.com/studio/v1 api_base: https://api.ai21.com/studio/v1
api_key: '{{AI21_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{AI21_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
# See https://docs.cohere.com/docs/the-cohere-platform # See https://docs.cohere.com/docs/the-cohere-platform
- type: cohere - type: cohere
api_base: https://api.cohere.ai/v2 # Optional api_base: https://api.cohere.ai/v2 # Optional
api_key: '{{COHERE_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{COHERE_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
# See https://docs.perplexity.ai/getting-started/overview # See https://docs.perplexity.ai/getting-started/overview
- type: openai-compatible - type: openai-compatible
name: perplexity name: perplexity
api_base: https://api.perplexity.ai api_base: https://api.perplexity.ai
api_key: '{{PERPLEXITY_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{PERPLEXITY_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
# See https://console.groq.com/docs/quickstart # See https://console.groq.com/docs/quickstart
- type: openai-compatible - type: openai-compatible
name: groq name: groq
api_base: https://api.groq.com/openai/v1 api_base: https://api.groq.com/openai/v1
api_key: '{{GROQ_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{GROQ_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
# See https://learn.microsoft.com/en-us/azure/ai-services/openai/chatgpt-quickstart # See https://learn.microsoft.com/en-us/azure/ai-services/openai/chatgpt-quickstart
- type: azure-openai - type: azure-openai
api_base: https://{RESOURCE}.openai.azure.com api_base: https://{RESOURCE}.openai.azure.com
api_key: '{{AZURE_OPENAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{AZURE_OPENAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
models: models:
- name: gpt-4o # Model deployment name - name: gpt-4o # Model deployment name
max_input_tokens: 128000 max_input_tokens: 128000
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
@@ -441,7 +469,7 @@ clients:
# Specifies an application default credentials (adc) file # Specifies an application default credentials (adc) file
# Run `gcloud auth application-default login` to initialize the ADC file # Run `gcloud auth application-default login` to initialize the ADC file
# see https://cloud.google.com/docs/authentication/external/set-up-adc for more information # see https://cloud.google.com/docs/authentication/external/set-up-adc for more information
adc_file: <gcloud-config-dir>/application_default_credentials.json # Optional adc_file: <gcloud-config-dir>/application_default_credentials.json # Optional
patch: patch:
chat_completions: chat_completions:
'gemini-.*': 'gemini-.*':
@@ -458,77 +486,76 @@ clients:
# See https://docs.aws.amazon.com/bedrock/latest/userguide/ # See https://docs.aws.amazon.com/bedrock/latest/userguide/
- type: bedrock - type: bedrock
access_key_id: '{{AWS_ACCESS_KEY_ID}}' # You can either hard-code or inject secrets from the Coyote vault access_key_id: '{{AWS_ACCESS_KEY_ID}}' # You can either hard-code or inject secrets from the Coyote vault
secret_access_key: '{{AWS_SECRET_ACCESS_KEY}}' # You can either hard-code or inject secrets from the Coyote vault secret_access_key: '{{AWS_SECRET_ACCESS_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
region: xxx region: xxx
session_token: xxx # Optional, only needed for temporary credentials session_token: xxx # Optional, only needed for temporary credentials
# See https://developers.cloudflare.com/workers-ai/ # See https://developers.cloudflare.com/workers-ai/
- type: openai-compatible - type: openai-compatible
name: cloudflare name: cloudflare
api_base: https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/v1 api_base: https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/v1
api_key: '{{CLOUDFLARE_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{CLOUDFLARE_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
# See https://cloud.baidu.com/doc/WENXINWORKSHOP/index.html # See https://cloud.baidu.com/doc/WENXINWORKSHOP/index.html
- type: openai-compatible - type: openai-compatible
name: ernie name: ernie
api_base: https://qianfan.baidubce.com/v2 api_base: https://qianfan.baidubce.com/v2
api_key: '{{BAIDU_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{BAIDU_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
# See https://dashscope.aliyun.com/ # See https://dashscope.aliyun.com/
- type: openai-compatible - type: openai-compatible
name: qianwen name: qianwen
api_base: https://dashscope.aliyuncs.com/compatible-mode/v1 api_base: https://dashscope.aliyuncs.com/compatible-mode/v1
api_key: '{{ALIYUN_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{ALIYUN_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
# See https://cloud.tencent.com/product/hunyuan # See https://cloud.tencent.com/product/hunyuan
- type: openai-compatible - type: openai-compatible
name: hunyuan name: hunyuan
api_base: https://api.hunyuan.cloud.tencent.com/v1 api_base: https://api.hunyuan.cloud.tencent.com/v1
api_key: '{{TENCENT_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{TENCENT_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
# See https://platform.moonshot.cn/docs/intro # See https://platform.moonshot.cn/docs/intro
- type: openai-compatible - type: openai-compatible
name: moonshot name: moonshot
api_base: https://api.moonshot.cn/v1 api_base: https://api.moonshot.cn/v1
api_key: '{{MOONSHOT_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{MOONSHOT_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
# See https://platform.deepseek.com/api-docs/ # See https://platform.deepseek.com/api-docs/
- type: openai-compatible - type: openai-compatible
name: deepseek name: deepseek
api_base: https://api.deepseek.com api_base: https://api.deepseek.com
api_key: '{{DEEPSEEK_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{DEEPSEEK_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
# See https://open.bigmodel.cn/dev/howuse/introduction # See https://open.bigmodel.cn/dev/howuse/introduction
- type: openai-compatible - type: openai-compatible
name: zhipuai name: zhipuai
api_base: https://open.bigmodel.cn/api/paas/v4 api_base: https://open.bigmodel.cn/api/paas/v4
api_key: '{{ZHIPUAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{ZHIPUAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
# See https://platform.minimaxi.com/document/Fast%20access # See https://platform.minimaxi.com/document/Fast%20access
- type: openai-compatible - type: openai-compatible
name: minimax name: minimax
api_base: https://api.minimax.chat/v1 api_base: https://api.minimax.chat/v1
api_key: '{{MINIMAX_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{MINIMAX_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
# See https://openrouter.ai/docs#quick-start # See https://openrouter.ai/docs#quick-start
- type: openai-compatible - type: openai-compatible
name: openrouter name: openrouter
api_base: https://openrouter.ai/api/v1 api_base: https://openrouter.ai/api/v1
api_key: '{{OPENROUTER_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{OPENROUTER_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
# See https://github.com/marketplace/models # See https://github.com/marketplace/models
- type: openai-compatible - type: openai-compatible
name: github name: github
api_base: https://models.inference.ai.azure.com api_base: https://models.inference.ai.azure.com
api_key: '{{GITHUB_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{GITHUB_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
# See https://deepinfra.com/docs # See https://deepinfra.com/docs
- type: openai-compatible - type: openai-compatible
name: deepinfra name: deepinfra
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 -----
@@ -536,10 +563,10 @@ clients:
- type: openai-compatible - type: openai-compatible
name: jina name: jina
api_base: https://api.jina.ai/v1 api_base: https://api.jina.ai/v1
api_key: '{{JINA_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{JINA_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
# See https://docs.voyageai.com/docs/introduction # See https://docs.voyageai.com/docs/introduction
- type: openai-compatible - type: openai-compatible
name: voyageai name: voyageai
api_base: https://api.voyageai.com/v1 api_base: https://api.voyageai.com/v1
api_key: '{{VOYAGEAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{VOYAGEAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
+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() {
+213 -12
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) => {
&mut items, if let Some(filter) = self.tool_filters.get(server) {
tools let advertised: Vec<String> =
.into_iter() tools.iter().map(|tool| tool.name.to_string()).collect();
.map(|tool| tool_catalog_item(server, tool)),
), 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,
tools
.into_iter()
.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,
+5 -3
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) => {
.await LlmNodeExecutor::execute(&branch_clone.id, n, &mut state, &mut ctx)
.map(|_| ()), .await
.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]