Compare commits
13
Commits
3d24719a71
...
08dddb09fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08dddb09fb | ||
|
|
f323c919f5 | ||
|
|
544a6bbbbc | ||
|
|
772c19f2bd | ||
|
|
1e15bc31d5 | ||
|
|
c170d08654 | ||
|
|
c707aaf3ee | ||
|
|
2d9829106d | ||
|
|
e7811a6b87 | ||
|
|
c0224e20cb | ||
|
|
92d9464b38 | ||
|
|
a92ebb5b94 | ||
|
|
5711432ac4 |
Generated
+23
@@ -1685,6 +1685,7 @@ dependencies = [
|
||||
"colored",
|
||||
"comfy-table",
|
||||
"crossterm 0.29.0",
|
||||
"ctor",
|
||||
"dirs",
|
||||
"duckdb",
|
||||
"duct",
|
||||
@@ -1907,6 +1908,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
@@ -3833,6 +3844,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
|
||||
@@ -142,6 +142,7 @@ arboard = { version = "3.3.0", default-features = false }
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
ctor = "1.0.13"
|
||||
pretty_assertions = "1.4.0"
|
||||
rmcp = { version = "3.1.2", features = ["server"] }
|
||||
serial_test = "3"
|
||||
|
||||
+64
-54
@@ -11,64 +11,74 @@
|
||||
# - <agent-name>_AGENT_SESSION
|
||||
# - <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
|
||||
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
|
||||
reasoning_effort: null # Reasoning effort level for models that support it (e.g. low, medium, high).
|
||||
# Only valid when the agent's model declares reasoning_levels.
|
||||
agent_session: null # Set a session to use when starting the agent. (e.g. temp, default); defaults to globally set agent_session
|
||||
name: <agent-name> # Name of the agent, used in the UI and logs
|
||||
description: <description> # Description of the agent, used in the UI
|
||||
version: 1 # Version of the agent
|
||||
model: openai:gpt-4o # Specify the LLM to use
|
||||
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
|
||||
reasoning_effort:
|
||||
null # Reasoning effort level for models that support it (e.g. low, medium, high).
|
||||
# Only valid when the agent's model declares reasoning_levels.
|
||||
agent_session: null # Set a session to use when starting the agent. (e.g. temp, default); defaults to globally set agent_session
|
||||
name: <agent-name> # Name of the agent, used in the UI and logs
|
||||
description: <description> # Description of the agent, used in the UI
|
||||
version: 1 # Version of the agent
|
||||
# Auto-Continue (Todo System)
|
||||
# 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
|
||||
# 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
|
||||
auto_continue: false # Enable automatic continuation when incomplete todos remain
|
||||
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
|
||||
continuation_prompt: null # Custom prompt used when auto-continuing (optional; uses default if null)
|
||||
auto_continue: false # Enable automatic continuation when incomplete todos remain
|
||||
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
|
||||
continuation_prompt: null # Custom prompt used when auto-continuing (optional; uses default if null)
|
||||
# Sub-Agent Spawning System
|
||||
# Enable this agent to spawn and manage child agents in parallel.
|
||||
# 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`.
|
||||
# - 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).
|
||||
# - oracle # An empty list ([]) means literally nothing spawnable.
|
||||
# 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.
|
||||
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_concurrent_jobs: 5 # Max background jobs (`job__*` tools) running at once for this agent
|
||||
# (overrides the global setting; 0 disables background jobs for this agent)
|
||||
inject_spawn_instructions: true # Inject the default agent spawning instructions into the agent's system prompt
|
||||
summarization_model: null # Model to use for summarizing sub-agent output (e.g. 'openai:gpt-4o-mini'); defaults to current model
|
||||
summarization_threshold: 4000 # Character threshold above which sub-agent output is summarized before returning to parent
|
||||
escalation_timeout: 300 # Seconds a sub-agent waits for a user interaction response before timing out (default: 5 minutes)
|
||||
mcp_servers: # Optional list of MCP servers that the agent utilizes
|
||||
- github # Corresponds to the name of an MCP server in the `<coyote-config-dir>/mcp.json` file
|
||||
global_tools: # Optional list of additional global tools to enable for the agent; i.e. not tools specific to the agent
|
||||
# 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.
|
||||
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_concurrent_jobs:
|
||||
5 # Max background jobs (`job__*` tools) running at once for this agent
|
||||
# (overrides the global setting; 0 disables background jobs for this agent)
|
||||
inject_spawn_instructions: true # Inject the default agent spawning instructions into the agent's system prompt
|
||||
summarization_model: null # Model to use for summarizing sub-agent output (e.g. 'openai:gpt-4o-mini'); defaults to current model
|
||||
summarization_threshold: 4000 # Character threshold above which sub-agent output is summarized before returning to parent
|
||||
escalation_timeout: 300 # Seconds a sub-agent waits for a user interaction response before timing out (default: 5 minutes)
|
||||
mcp_servers: # Optional list of MCP servers that the agent utilizes
|
||||
- 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
|
||||
- fs
|
||||
- python
|
||||
skills_enabled: true # Master switch for skills in this agent (default: inherit from global).
|
||||
# Skills also require `function_calling_support: true` in the global config.
|
||||
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.
|
||||
skills_enabled:
|
||||
true # Master switch for skills in this agent (default: inherit from global).
|
||||
# Skills also require `function_calling_support: true` in the global config.
|
||||
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
|
||||
- ai-slop-remover
|
||||
inject_skill_instructions: true # Inject a short hint pointing the model at `skill__list` when skills are enabled
|
||||
# (default: true). Suppressed automatically when no skills are available.
|
||||
skill_instructions: null # Custom text for the skill hint (optional; uses built-in default if null)
|
||||
enabled_macros: # Optional list of macros invocable when this agent is active in the REPL.
|
||||
- generate-commit-message # An empty list disables all macros. Omit to inherit the role/global default.
|
||||
memory: null # Per-agent memory override (default: inherit). Set to `false` to disable memory
|
||||
# for this agent regardless of workspace/global presence. See the Memory wiki page.
|
||||
inject_skill_instructions:
|
||||
true # Inject a short hint pointing the model at `skill__list` when skills are enabled
|
||||
# (default: true). Suppressed automatically when no skills are available.
|
||||
skill_instructions: null # Custom text for the skill hint (optional; uses built-in default if null)
|
||||
enabled_macros: # Optional list of macros invocable when this agent is active in the REPL.
|
||||
- generate-commit-message # An empty list disables all macros. Omit to inherit the role/global default.
|
||||
memory:
|
||||
null # Per-agent memory override (default: inherit). Set to `false` to disable memory
|
||||
# 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
|
||||
instructions: | # Static instructions for the agent; ignored if dynamic 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
|
||||
You are a AI agent designed to demonstrate agent capabilities.
|
||||
|
||||
<tools>
|
||||
@@ -88,12 +98,12 @@ instructions: | # Static instructions for the agent; ignored if
|
||||
<user>
|
||||
username: {{username}}
|
||||
</user>
|
||||
variables: # Optional variables for the agent
|
||||
# The variables defined above like {{__variable_name__}} are automatically available
|
||||
variables: # Optional variables for the agent
|
||||
# The variables defined above like {{__variable_name__}} are automatically available
|
||||
- name: username
|
||||
description: Your user name
|
||||
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
|
||||
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
|
||||
- What is the meaning of life?
|
||||
- Tell me a joke.
|
||||
- What is the capital of France?
|
||||
@@ -104,15 +114,15 @@ conversation_starters: # Optional conversation starters for the agent
|
||||
- How do I stay motivated?
|
||||
- What is the best way to exercise?
|
||||
- How do I manage my time effectively?
|
||||
documents: # Optional documents to load for the agent
|
||||
# To enable graph-based RAG (entity/relationship extraction + knowledge graph retrieval),
|
||||
# set `rag_extractor_model` in your global config.yaml.
|
||||
# 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
|
||||
- pdf:some-pdf-file.pdf # Explicitly tell Coyote to use the 'pdf' document loader using a relative path
|
||||
documents: # Optional documents to load for the agent
|
||||
# To enable graph-based RAG (entity/relationship extraction + knowledge graph retrieval),
|
||||
# set `rag_extractor_model` in your global config.yaml.
|
||||
# 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
|
||||
- pdf:some-pdf-file.pdf # Explicitly tell Coyote to use the 'pdf' document loader using a relative path
|
||||
- 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.txt # File in the user's home directory
|
||||
- /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
|
||||
- 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
|
||||
- /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/to/*/README.md # Find all README.md files in all immediate subdirectories of the specified directory (depth=1)
|
||||
|
||||
+197
-170
@@ -1,40 +1,39 @@
|
||||
# ---- LLM ----
|
||||
model: openai:gpt-4o # Specify the LLM to use
|
||||
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
|
||||
reasoning_effort: null # Reasoning effort level for models that support it (e.g. low, medium, high).
|
||||
# Only valid when the active model declares reasoning_levels. See the Clients docs.
|
||||
model: openai:gpt-4o # Specify the LLM to use
|
||||
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
|
||||
reasoning_effort:
|
||||
null # Reasoning effort level for models that support it (e.g. low, medium, high).
|
||||
# Only valid when the active model declares reasoning_levels. See the Clients docs.
|
||||
|
||||
# ---- Behavior ----
|
||||
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
|
||||
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
|
||||
wrap: no # Controls text wrapping (no, auto, <max-width>)
|
||||
wrap_code: false # Enables or disables the wrapping of code blocks
|
||||
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
|
||||
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
|
||||
wrap: no # Controls text wrapping (no, auto, <max-width>)
|
||||
wrap_code: false # Enables or disables the wrapping of code blocks
|
||||
|
||||
# ---- Prelude ----
|
||||
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>)
|
||||
agent_session: null # Set a session to use when starting an agent (e.g. temp, default)
|
||||
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>)
|
||||
agent_session: null # Set a session to use when starting an agent (e.g. temp, default)
|
||||
|
||||
# ---- Appearance ----
|
||||
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
|
||||
light_theme: false # Activates a light color theme when true. env: COYOTE_LIGHT_THEME
|
||||
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
|
||||
light_theme: false # Activates a light color theme when true. env: COYOTE_LIGHT_THEME
|
||||
|
||||
# ---- Miscellaneous ----
|
||||
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
|
||||
sync_models_url: > # URL to sync model changes from
|
||||
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
|
||||
sync_models_url: > # URL to sync model changes from
|
||||
https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml
|
||||
|
||||
# ---- 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
|
||||
left_prompt:
|
||||
'{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}'
|
||||
left_prompt: '{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}'
|
||||
|
||||
# ---- 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.
|
||||
#
|
||||
# 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,
|
||||
# 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 ----
|
||||
# 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).
|
||||
mapping_tools: # Alias for a tool or toolset
|
||||
function_calling_support: true # Enables or disables function calling (Globally).
|
||||
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'
|
||||
enabled_tools: null # Which tools to enable by default.
|
||||
# Accepts either a YAML list or a comma-separated string. Use 'all' to enable everything.
|
||||
# Example (list form):
|
||||
# enabled_tools:
|
||||
# - fs
|
||||
# - web_search_coyote
|
||||
# Example (comma-separated form):
|
||||
# enabled_tools: fs,web_search_coyote
|
||||
visible_tools: # Which tools are visible to be compiled (and are thus able to be defined in 'enabled_tools')
|
||||
# - ast_grep.sh
|
||||
# - demo_py.py
|
||||
# - demo_sh.sh
|
||||
# - demo_ts.ts
|
||||
enabled_tools:
|
||||
null # Which tools to enable by default.
|
||||
# Accepts either a YAML list or a comma-separated string. Use 'all' to enable everything.
|
||||
# Example (list form):
|
||||
# enabled_tools:
|
||||
# - fs
|
||||
# - web_search_coyote
|
||||
# Example (comma-separated form):
|
||||
# enabled_tools: fs,web_search_coyote
|
||||
visible_tools: # Which tools are visible to be compiled (and are thus able to be defined in 'enabled_tools')
|
||||
# - ast_grep.sh
|
||||
# - demo_py.py
|
||||
# - demo_sh.sh
|
||||
# - demo_ts.ts
|
||||
- execute_command.sh
|
||||
# - execute_py_code.py
|
||||
# - execute_sql_code.sh
|
||||
# - fetch_url_via_curl.sh
|
||||
# - fetch_url_via_jina.sh
|
||||
# - execute_py_code.py
|
||||
# - execute_sql_code.sh
|
||||
# - fetch_url_via_curl.sh
|
||||
# - fetch_url_via_jina.sh
|
||||
- fs_cat.sh
|
||||
- fs_ls.sh
|
||||
# - fs_read.sh
|
||||
# - fs_glob.sh
|
||||
# - fs_grep.sh
|
||||
# - fs_mkdir.sh
|
||||
# - fs_patch.sh
|
||||
# - fs_write.sh
|
||||
# - fs_read.sh
|
||||
# - fs_glob.sh
|
||||
# - fs_grep.sh
|
||||
# - fs_mkdir.sh
|
||||
# - fs_patch.sh
|
||||
# - fs_write.sh
|
||||
- get_current_time.sh
|
||||
# - get_current_weather.py
|
||||
# - get_current_weather.ts
|
||||
# - get_current_weather.py
|
||||
# - get_current_weather.ts
|
||||
- get_current_weather.sh
|
||||
# - search_arxiv.sh
|
||||
# - search_wikipedia.sh
|
||||
@@ -126,85 +126,106 @@ visible_tools: # Which tools are visible to be compiled (and a
|
||||
|
||||
# ---- MCP Servers ----
|
||||
# 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).
|
||||
mapping_mcp_servers: # Alias for an MCP server or set of servers
|
||||
mcp_server_support: true # Enables or disables MCP servers (globally).
|
||||
mapping_mcp_servers: # Alias for an MCP server or set of servers
|
||||
git: github,gitmcp
|
||||
enabled_mcp_servers: null # Which MCP servers to enable by default.
|
||||
# Accepts either a YAML list or a comma-separated string. Use 'all' to enable everything.
|
||||
# Example (list form):
|
||||
# enabled_mcp_servers:
|
||||
# - github
|
||||
# - slack
|
||||
# Example (comma-separated form):
|
||||
# enabled_mcp_servers: github,slack,ddg-search
|
||||
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.
|
||||
enabled_mcp_servers:
|
||||
null # Which MCP servers to enable by default.
|
||||
# Accepts either a YAML list or a comma-separated string. Use 'all' to enable everything.
|
||||
# Example (list form):
|
||||
# enabled_mcp_servers:
|
||||
# - github
|
||||
# - slack
|
||||
# Example (comma-separated form):
|
||||
# enabled_mcp_servers: github,slack,ddg-search
|
||||
mcp_tools:
|
||||
null # Per-server MCP tool allowlists (glob patterns: * and ? supported).
|
||||
# Tools that match no pattern are hidden from the model as if they
|
||||
# don't exist. Stacks with the other allowlist layers (mcp.json
|
||||
# `allowedTools`, role, agent, session, skill, graph node). Every
|
||||
# configured layer must allow a tool, so layers only ever narrow.
|
||||
# An empty list blocks all of a server's tools.
|
||||
# Example:
|
||||
# mcp_tools:
|
||||
# github:
|
||||
# - get_*
|
||||
# - list_*
|
||||
# slack: []
|
||||
no_workspace_mcp:
|
||||
false # Disable loading workspace-local MCP servers (default: false).
|
||||
# When false (the default), Coyote merges the first workspace MCP config it finds
|
||||
# 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 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.
|
||||
skills_enabled: true # Master switch. Set to false to hide all skill management tools from the model.
|
||||
# Skills also require `function_calling_support: true` above to work at all.
|
||||
visible_skills: # The universe of skills allowed to be enabled in any context. Omit (null) for "all installed".
|
||||
skills_enabled:
|
||||
true # Master switch. Set to false to hide all skill management tools from the model.
|
||||
# Skills also require `function_calling_support: true` above to work at all.
|
||||
visible_skills: # The universe of skills allowed to be enabled in any context. Omit (null) for "all installed".
|
||||
- ai-slop-remover
|
||||
- code-review
|
||||
- frontend-ui-ux
|
||||
- git-master
|
||||
enabled_skills: null # Which skills are available by default (no role/agent/session active). null = all visible.
|
||||
# Accepts either a YAML list or a comma-separated string.
|
||||
# Example (list form):
|
||||
# enabled_skills:
|
||||
# - git-master
|
||||
# - ai-slop-remover
|
||||
# Example (comma-separated form):
|
||||
# enabled_skills: git-master,ai-slop-remover
|
||||
inject_skill_instructions: true # Inject a short hint pointing the model at `skill__list` when skills are enabled in
|
||||
# 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.
|
||||
enabled_skills:
|
||||
null # Which skills are available by default (no role/agent/session active). null = all visible.
|
||||
# Accepts either a YAML list or a comma-separated string.
|
||||
# Example (list form):
|
||||
# enabled_skills:
|
||||
# - git-master
|
||||
# - ai-slop-remover
|
||||
# Example (comma-separated form):
|
||||
# enabled_skills: git-master,ai-slop-remover
|
||||
inject_skill_instructions:
|
||||
true # Inject a short hint pointing the model at `skill__list` when skills are enabled in
|
||||
# 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 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).
|
||||
# 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.
|
||||
enabled_macros: null # Which macros are invocable by default (no role/agent/session active). null = all visible.
|
||||
# An empty list means NO macros are invocable. Accepts either a YAML list or a
|
||||
# comma-separated string. Roles, agents, and sessions may define their own
|
||||
# `enabled_macros`; the most specific active one wins (session > agent > role > global).
|
||||
# Example (list form):
|
||||
# enabled_macros:
|
||||
# - generate-commit-message
|
||||
# Example (comma-separated form):
|
||||
# enabled_macros: generate-commit-message,review
|
||||
enabled_macros:
|
||||
null # Which macros are invocable by default (no role/agent/session active). null = all visible.
|
||||
# An empty list means NO macros are invocable. Accepts either a YAML list or a
|
||||
# comma-separated string. Roles, agents, and sessions may define their own
|
||||
# `enabled_macros`; the most specific active one wins (session > agent > role > global).
|
||||
# Example (list form):
|
||||
# enabled_macros:
|
||||
# - generate-commit-message
|
||||
# Example (comma-separated form):
|
||||
# enabled_macros: generate-commit-message,review
|
||||
|
||||
# ---- Auto-Continue (Todo System) ----
|
||||
# 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
|
||||
# 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
|
||||
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)
|
||||
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
|
||||
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)
|
||||
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
|
||||
|
||||
# ---- Session ----
|
||||
# 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
|
||||
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
|
||||
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
|
||||
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.'
|
||||
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: '
|
||||
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_concurrent_jobs: 5 # Max background jobs (`job__*` tools) running at once per context (default: 5; 0 disables background jobs entirely)
|
||||
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_concurrent_jobs: 5 # Max background jobs (`job__*` tools) running at once per context (default: 5; 0 disables background jobs entirely)
|
||||
|
||||
# ---- Memory ----
|
||||
# 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.
|
||||
# Bootstrap with `coyote --init-memory [global|workspace]` to create the marker file
|
||||
# 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_cap_with_tools: null # Char cap for injected memory when function calling is available (default: 6000).
|
||||
# Only MEMORY.md indexes are injected; the LLM uses memory__read to fetch drill files.
|
||||
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.
|
||||
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).
|
||||
# Only MEMORY.md indexes are injected; the LLM uses memory__read to fetch drill files.
|
||||
memory_cap_without_tools:
|
||||
null # Char cap when function calling is unavailable (default: 12000).
|
||||
# Indexes plus drill file bodies are injected up to this cap.
|
||||
|
||||
# ---- Workspace Instructions ----
|
||||
# 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`.
|
||||
# Disable per-invocation with --no-workspace-instructions, or override the chain with
|
||||
# repeatable --workspace-instructions-file flags.
|
||||
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.
|
||||
# Default: [COYOTE.md, AGENTS.md, CLAUDE.md, GEMINI.md]
|
||||
# Set to a custom list to reorder or drop fallbacks, e.g.:
|
||||
# workspace_instructions_files: [COYOTE.md]
|
||||
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.
|
||||
# Default: [COYOTE.md, AGENTS.md, CLAUDE.md, GEMINI.md]
|
||||
# Set to a custom list to reorder or drop fallbacks, e.g.:
|
||||
# workspace_instructions_files: [COYOTE.md]
|
||||
|
||||
# ---- RAG ----
|
||||
# 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_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_chunk_size: null # Defines the size of chunks for document processing in characters
|
||||
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_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_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_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_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_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)
|
||||
# Defines the query structure using variables like __CONTEXT__, __SOURCES__, and __INPUT__ to tailor searches to specific needs
|
||||
rag_template: |
|
||||
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:
|
||||
# <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.
|
||||
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)
|
||||
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)
|
||||
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
|
||||
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 })'"
|
||||
|
||||
# ---- Clients ----
|
||||
@@ -293,10 +318,10 @@ clients:
|
||||
# supports_function_calling: true
|
||||
# - name: xxxx # Embedding model
|
||||
# type: embedding
|
||||
# default_chunk_size: 1500
|
||||
# default_chunk_size: 1500
|
||||
# max_batch_size: 100
|
||||
# - name: xxxx # Reranker model
|
||||
# type: reranker
|
||||
# type: reranker
|
||||
# patch: # Patch API calls
|
||||
# chat_completions: # API type; Possible values: chat_completions, embeddings, and rerank
|
||||
# <regex>: # The regex to match model names, e.g. '.*' 'gpt-4o' 'gpt-4o|gpt-4-.*'
|
||||
@@ -312,15 +337,15 @@ clients:
|
||||
|
||||
# See https://platform.openai.com/docs/quickstart
|
||||
- type: openai
|
||||
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
|
||||
organization_id: org-xxx # 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
|
||||
organization_id: org-xxx # Optional
|
||||
|
||||
# For any platform compatible with OpenAI's API
|
||||
- type: openai-compatible
|
||||
name: ollama
|
||||
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:
|
||||
- name: deepseek-r1
|
||||
max_input_tokens: 131072
|
||||
@@ -338,9 +363,10 @@ clients:
|
||||
# See https://ai.google.dev/docs
|
||||
- type: gemini
|
||||
api_base: https://generativelanguage.googleapis.com/v1beta
|
||||
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
|
||||
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
|
||||
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
|
||||
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
|
||||
patch:
|
||||
chat_completions:
|
||||
'.*':
|
||||
@@ -357,25 +383,27 @@ clients:
|
||||
|
||||
# See https://docs.anthropic.com/claude/reference/getting-started-with-the-api
|
||||
- type: claude
|
||||
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
|
||||
auth: null # When set to 'oauth', Coyote will use OAuth instead of an API key
|
||||
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
|
||||
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
|
||||
auth:
|
||||
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/
|
||||
- type: openai-compatible
|
||||
name: mistral
|
||||
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
|
||||
- type: openai-compatible
|
||||
name: xai
|
||||
api_base: https://api.x.ai/v1
|
||||
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
|
||||
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
|
||||
# Note: Oauth requires SuperGrok/X Premium+ subscription
|
||||
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
|
||||
# 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
|
||||
# - type: openai-compatible
|
||||
@@ -405,31 +433,31 @@ clients:
|
||||
- type: openai-compatible
|
||||
name: ai12
|
||||
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
|
||||
- type: cohere
|
||||
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_base: https://api.cohere.ai/v2 # Optional
|
||||
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
|
||||
- type: openai-compatible
|
||||
name: perplexity
|
||||
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
|
||||
- type: openai-compatible
|
||||
name: groq
|
||||
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
|
||||
- type: azure-openai
|
||||
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:
|
||||
- name: gpt-4o # Model deployment name
|
||||
- name: gpt-4o # Model deployment name
|
||||
max_input_tokens: 128000
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
@@ -441,7 +469,7 @@ clients:
|
||||
# Specifies an application default credentials (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
|
||||
adc_file: <gcloud-config-dir>/application_default_credentials.json # Optional
|
||||
adc_file: <gcloud-config-dir>/application_default_credentials.json # Optional
|
||||
patch:
|
||||
chat_completions:
|
||||
'gemini-.*':
|
||||
@@ -458,77 +486,76 @@ clients:
|
||||
|
||||
# See https://docs.aws.amazon.com/bedrock/latest/userguide/
|
||||
- type: bedrock
|
||||
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
|
||||
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
|
||||
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/
|
||||
- type: openai-compatible
|
||||
name: cloudflare
|
||||
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
|
||||
- type: openai-compatible
|
||||
name: ernie
|
||||
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/
|
||||
- type: openai-compatible
|
||||
name: qianwen
|
||||
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
|
||||
- type: openai-compatible
|
||||
name: hunyuan
|
||||
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
|
||||
- type: openai-compatible
|
||||
name: moonshot
|
||||
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/
|
||||
- type: openai-compatible
|
||||
name: deepseek
|
||||
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
|
||||
- type: openai-compatible
|
||||
name: zhipuai
|
||||
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
|
||||
- type: openai-compatible
|
||||
name: minimax
|
||||
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
|
||||
- type: openai-compatible
|
||||
name: openrouter
|
||||
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
|
||||
- type: openai-compatible
|
||||
name: github
|
||||
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
|
||||
- type: openai-compatible
|
||||
name: deepinfra
|
||||
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 -----
|
||||
|
||||
@@ -536,10 +563,10 @@ clients:
|
||||
- type: openai-compatible
|
||||
name: jina
|
||||
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
|
||||
- type: openai-compatible
|
||||
name: voyageai
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
- github # or a comma-separated string (e.g. `enabled_mcp_servers: github,gitmcp`).
|
||||
- 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 also require `function_calling_support: true` in the global config.
|
||||
enabled_skills: # Skills available when this role is active. Accepts a YAML list (preferred)
|
||||
|
||||
@@ -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>`
|
||||
- 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 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
|
||||
- web_search_coyote # an exact global-tool / custom-tool name
|
||||
- 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
|
||||
temperature: 0.3 # Optional per-node sampling override
|
||||
reasoning_effort: null # Optional per-node reasoning effort override (e.g. low, medium, high)
|
||||
|
||||
@@ -590,12 +590,33 @@ mod tests {
|
||||
parse(&["--install-builtins", "agents"]).install_builtins,
|
||||
Some(AssetCategory::Agents)
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["--install-builtins", "mcp-config"]).install_builtins,
|
||||
Some(AssetCategory::McpConfig)
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["--install-builtins", "mcp_config"]).install_builtins,
|
||||
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]
|
||||
fn parse_install_builtins_conflicts_with_install() {
|
||||
assert!(
|
||||
@@ -633,6 +654,26 @@ mod tests {
|
||||
parse(&["--install", "https://github.com/x/y", "--filter", "agents"]).filter,
|
||||
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]
|
||||
|
||||
@@ -682,6 +682,10 @@ impl RoleLike for Agent {
|
||||
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) {
|
||||
self.config.model_id = Some(model.id());
|
||||
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)]
|
||||
@@ -774,6 +782,8 @@ pub struct AgentConfig {
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub mcp_servers: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
||||
#[serde(default)]
|
||||
pub global_tools: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -846,6 +856,7 @@ impl AgentConfig {
|
||||
description: graph.description.clone(),
|
||||
global_tools: graph.global_tools.clone(),
|
||||
mcp_servers: graph.mcp_servers.clone(),
|
||||
mcp_tools: graph.mcp_tools.clone(),
|
||||
skills_enabled: graph.skills_enabled,
|
||||
enabled_skills: graph.enabled_skills.clone(),
|
||||
inject_skill_instructions: graph.inject_skill_instructions.unwrap_or(true),
|
||||
@@ -1285,6 +1296,33 @@ variables:
|
||||
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]
|
||||
fn agent_config_enabled_macros_empty_list_is_some_empty() {
|
||||
let yaml = "name: minimal\ninstructions: hi\nenabled_macros: []\n";
|
||||
|
||||
@@ -50,6 +50,7 @@ pub struct AppConfig {
|
||||
pub mapping_mcp_servers: IndexMap<String, String>,
|
||||
#[serde(default, deserialize_with = "super::deserialize_csv_or_vec")]
|
||||
pub enabled_mcp_servers: Option<Vec<String>>,
|
||||
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
||||
|
||||
pub auto_continue: bool,
|
||||
pub max_auto_continues: usize,
|
||||
@@ -136,6 +137,7 @@ impl Default for AppConfig {
|
||||
mcp_server_support: true,
|
||||
mapping_mcp_servers: Default::default(),
|
||||
enabled_mcp_servers: None,
|
||||
mcp_tools: None,
|
||||
|
||||
auto_continue: false,
|
||||
max_auto_continues: 10,
|
||||
@@ -223,6 +225,7 @@ impl AppConfig {
|
||||
mcp_server_support: config.mcp_server_support,
|
||||
mapping_mcp_servers: config.mapping_mcp_servers,
|
||||
enabled_mcp_servers: config.enabled_mcp_servers,
|
||||
mcp_tools: config.mcp_tools,
|
||||
|
||||
auto_continue: config.auto_continue,
|
||||
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]
|
||||
#[serial_test::serial]
|
||||
fn from_config_copies_enabled_macros() {
|
||||
|
||||
@@ -4158,6 +4158,10 @@ mod tests {
|
||||
classify_install_target("agents", &owned_names(&["agents"])),
|
||||
InstallTarget::Category(AssetCategory::Agents)
|
||||
);
|
||||
assert_eq!(
|
||||
classify_install_target("mcp-config", &[]),
|
||||
InstallTarget::Category(AssetCategory::McpConfig)
|
||||
);
|
||||
assert_eq!(
|
||||
classify_install_target("mcp_config", &[]),
|
||||
InstallTarget::Category(AssetCategory::McpConfig)
|
||||
@@ -4790,6 +4794,49 @@ mod tests {
|
||||
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]
|
||||
fn uninstall_mcp_reports_referenced_secrets_without_removing_them() {
|
||||
let dir = fresh_temp_dir("uninst-mcp-secrets-");
|
||||
|
||||
@@ -139,6 +139,7 @@ mod tests {
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +157,7 @@ mod tests {
|
||||
url: Some(url.to_string()),
|
||||
headers,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -8,6 +8,7 @@ pub(crate) mod instructions;
|
||||
mod macro_policy;
|
||||
mod macros;
|
||||
mod mcp_factory;
|
||||
mod mcp_tool_policy;
|
||||
pub(crate) mod memory;
|
||||
pub(crate) mod paths;
|
||||
pub(crate) mod prompts;
|
||||
@@ -41,6 +42,9 @@ pub use self::install_remote::{
|
||||
pub use self::macro_policy::{
|
||||
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)]
|
||||
pub use self::request_context::{
|
||||
RenderMode, RequestContext, effective_max_concurrent_jobs, jobs_enabled,
|
||||
@@ -250,6 +254,7 @@ pub struct Config {
|
||||
pub mapping_mcp_servers: IndexMap<String, String>,
|
||||
#[serde(default, deserialize_with = "deserialize_csv_or_vec")]
|
||||
pub enabled_mcp_servers: Option<Vec<String>>,
|
||||
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
||||
|
||||
pub auto_continue: bool,
|
||||
pub max_auto_continues: usize,
|
||||
@@ -333,6 +338,7 @@ impl Default for Config {
|
||||
mcp_server_support: true,
|
||||
mapping_mcp_servers: Default::default(),
|
||||
enabled_mcp_servers: None,
|
||||
mcp_tools: None,
|
||||
|
||||
auto_continue: false,
|
||||
max_auto_continues: 10,
|
||||
@@ -401,12 +407,12 @@ pub enum AssetCategory {
|
||||
Macros,
|
||||
Functions,
|
||||
Skills,
|
||||
#[value(name = "mcp_config")]
|
||||
#[value(name = "mcp-config", alias = "mcp_config")]
|
||||
McpConfig,
|
||||
}
|
||||
|
||||
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> {
|
||||
match name {
|
||||
@@ -414,7 +420,7 @@ impl AssetCategory {
|
||||
"macros" => Some(Self::Macros),
|
||||
"functions" => Some(Self::Functions),
|
||||
"skills" => Some(Self::Skills),
|
||||
"mcp_config" => Some(Self::McpConfig),
|
||||
"mcp-config" | "mcp_config" => Some(Self::McpConfig),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -433,7 +439,7 @@ pub enum InstallFilter {
|
||||
Skills,
|
||||
Macros,
|
||||
Functions,
|
||||
#[value(name = "mcp_config")]
|
||||
#[value(name = "mcp-config", alias = "mcp_config")]
|
||||
McpConfig,
|
||||
}
|
||||
|
||||
@@ -444,7 +450,7 @@ impl InstallFilter {
|
||||
"skills",
|
||||
"macros",
|
||||
"functions",
|
||||
"mcp_config",
|
||||
"mcp-config",
|
||||
];
|
||||
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
@@ -454,7 +460,7 @@ impl InstallFilter {
|
||||
"skills" => Some(Self::Skills),
|
||||
"macros" => Some(Self::Macros),
|
||||
"functions" => Some(Self::Functions),
|
||||
"mcp_config" => Some(Self::McpConfig),
|
||||
"mcp-config" | "mcp_config" => Some(Self::McpConfig),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1128,6 +1134,17 @@ clients:
|
||||
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]
|
||||
fn config_defaults_match_expected() {
|
||||
let cfg = Config::default();
|
||||
|
||||
+1119
-50
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ pub trait RoleLike {
|
||||
fn top_p(&self) -> Option<f64>;
|
||||
fn enabled_tools(&self) -> Option<Vec<String>>;
|
||||
fn enabled_mcp_servers(&self) -> Option<Vec<String>>;
|
||||
fn mcp_tools(&self) -> Option<IndexMap<String, Vec<String>>>;
|
||||
fn set_model(&mut self, model: Model);
|
||||
fn set_temperature(&mut self, value: Option<f64>);
|
||||
fn reasoning_effort(&self) -> Option<String>;
|
||||
@@ -37,6 +38,7 @@ pub trait RoleLike {
|
||||
fn set_reasoning_effort(&mut self, value: Option<String>);
|
||||
fn set_enabled_tools(&mut self, value: Option<Vec<String>>);
|
||||
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>);
|
||||
fn set_mcp_tools(&mut self, value: Option<IndexMap<String, Vec<String>>>);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
@@ -67,6 +69,8 @@ pub struct Role {
|
||||
deserialize_with = "super::deserialize_csv_or_vec"
|
||||
)]
|
||||
enabled_mcp_servers: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
skills_enabled: Option<bool>,
|
||||
#[serde(
|
||||
@@ -133,6 +137,7 @@ impl Role {
|
||||
"enabled_mcp_servers" => {
|
||||
role.enabled_mcp_servers = parse_string_or_array(value)
|
||||
}
|
||||
"mcp_tools" => role.mcp_tools = parse_mcp_tools_map(value),
|
||||
"skills_enabled" => role.skills_enabled = value.as_bool(),
|
||||
"enabled_skills" => role.enabled_skills = parse_string_or_array(value),
|
||||
"enabled_macros" => role.enabled_macros = parse_string_or_array(value),
|
||||
@@ -196,6 +201,10 @@ impl Role {
|
||||
serde_json::to_string(enabled_mcp_servers).unwrap_or_else(|_| "[]".to_string());
|
||||
metadata.push(format!("enabled_mcp_servers: {inline}"));
|
||||
}
|
||||
if let Some(mcp_tools) = &self.mcp_tools {
|
||||
let inline = serde_json::to_string(mcp_tools).unwrap_or_else(|_| "{}".to_string());
|
||||
metadata.push(format!("mcp_tools: {inline}"));
|
||||
}
|
||||
if let Some(skills_enabled) = self.skills_enabled {
|
||||
metadata.push(format!("skills_enabled: {skills_enabled}"));
|
||||
}
|
||||
@@ -279,6 +288,10 @@ impl Role {
|
||||
enabled_tools,
|
||||
enabled_mcp_servers,
|
||||
);
|
||||
let mcp_tools = role_like.mcp_tools();
|
||||
if mcp_tools.is_some() {
|
||||
self.set_mcp_tools(mcp_tools);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn batch_set(
|
||||
@@ -453,6 +466,10 @@ impl RoleLike for Role {
|
||||
self.enabled_mcp_servers.clone()
|
||||
}
|
||||
|
||||
fn mcp_tools(&self) -> Option<IndexMap<String, Vec<String>>> {
|
||||
self.mcp_tools.clone()
|
||||
}
|
||||
|
||||
fn set_model(&mut self, model: Model) {
|
||||
if !self.model().id().is_empty() {
|
||||
self.model_id = Some(model.id().to_string());
|
||||
@@ -479,6 +496,10 @@ impl RoleLike for Role {
|
||||
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>) {
|
||||
self.enabled_mcp_servers = value;
|
||||
}
|
||||
|
||||
fn set_mcp_tools(&mut self, value: Option<IndexMap<String, Vec<String>>>) {
|
||||
self.mcp_tools = value;
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_string_or_array(value: &Value) -> Option<Vec<String>> {
|
||||
@@ -503,6 +524,19 @@ fn parse_string_or_array(value: &Value) -> Option<Vec<String>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_mcp_tools_map(value: &Value) -> Option<IndexMap<String, Vec<String>>> {
|
||||
let map = value.as_object()?;
|
||||
let mut mcp_tools = IndexMap::new();
|
||||
for (server, tools) in map {
|
||||
if tools.is_null() {
|
||||
mcp_tools.insert(server.clone(), Vec::new());
|
||||
} else if let Some(tools) = parse_string_or_array(tools) {
|
||||
mcp_tools.insert(server.clone(), tools);
|
||||
}
|
||||
}
|
||||
Some(mcp_tools)
|
||||
}
|
||||
|
||||
fn parse_structure_prompt(prompt: &str) -> (&str, Vec<(&str, &str)>) {
|
||||
let mut text = prompt;
|
||||
let mut search_input = true;
|
||||
@@ -652,6 +686,69 @@ mod tests {
|
||||
assert_eq!(role.enabled_macros, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_new_parses_mcp_tools_list_and_csv_values() {
|
||||
let content = "---\nmcp_tools:\n github: [get_*, list_*, search_code]\n slack: conversations_history,conversations_replies\n---\nPrompt";
|
||||
|
||||
let role = Role::new("test", content);
|
||||
|
||||
let mcp_tools = role.mcp_tools().unwrap();
|
||||
assert_eq!(
|
||||
mcp_tools.get("github"),
|
||||
Some(&vec![
|
||||
"get_*".to_string(),
|
||||
"list_*".to_string(),
|
||||
"search_code".to_string()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
mcp_tools.get("slack"),
|
||||
Some(&vec![
|
||||
"conversations_history".to_string(),
|
||||
"conversations_replies".to_string()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_new_mcp_tools_empty_list_server_is_some_empty() {
|
||||
let role = Role::new("test", "---\nmcp_tools:\n github: []\n---\nPrompt");
|
||||
|
||||
assert_eq!(role.mcp_tools().unwrap().get("github"), Some(&vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_new_mcp_tools_per_server_null_is_some_empty() {
|
||||
let role = Role::new("test", "---\nmcp_tools:\n github:\n---\nPrompt");
|
||||
|
||||
assert_eq!(role.mcp_tools().unwrap().get("github"), Some(&vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_new_mcp_tools_absent_is_none() {
|
||||
let role = Role::new("test", "---\ntemperature: 0.5\n---\nPrompt");
|
||||
|
||||
assert_eq!(role.mcp_tools(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_new_mcp_tools_null_is_none() {
|
||||
let role = Role::new("test", "---\nmcp_tools: null\n---\nPrompt");
|
||||
|
||||
assert_eq!(role.mcp_tools(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_export_mcp_tools_round_trips() {
|
||||
let content = "---\nmcp_tools:\n github: [get_issue]\n slack: a,b\n---\nPrompt";
|
||||
let role = Role::new("test", content);
|
||||
|
||||
let reparsed = Role::new("test", &role.export());
|
||||
|
||||
assert_eq!(reparsed.mcp_tools(), role.mcp_tools());
|
||||
assert!(role.mcp_tools().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_export_includes_enabled_macros() {
|
||||
let role = Role::new("test", "---\nenabled_macros: [a]\n---\nPrompt");
|
||||
|
||||
@@ -40,6 +40,8 @@ pub struct Session {
|
||||
deserialize_with = "super::deserialize_csv_or_vec"
|
||||
)]
|
||||
enabled_mcp_servers: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
skills_enabled: Option<bool>,
|
||||
#[serde(
|
||||
@@ -249,6 +251,9 @@ impl Session {
|
||||
if let Some(enabled_mcp_servers) = self.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() {
|
||||
data["skills_enabled"] = skills_enabled.into();
|
||||
}
|
||||
@@ -329,6 +334,13 @@ impl Session {
|
||||
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() {
|
||||
items.push(("skills_enabled", skills_enabled.to_string()));
|
||||
}
|
||||
@@ -870,6 +882,10 @@ impl RoleLike for Session {
|
||||
self.enabled_mcp_servers.clone()
|
||||
}
|
||||
|
||||
fn mcp_tools(&self) -> Option<IndexMap<String, Vec<String>>> {
|
||||
self.mcp_tools.clone()
|
||||
}
|
||||
|
||||
fn set_model(&mut self, model: Model) {
|
||||
if self.model().id() != model.id() {
|
||||
self.model_id = model.id();
|
||||
@@ -913,6 +929,13 @@ impl RoleLike for Session {
|
||||
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)]
|
||||
@@ -1044,6 +1067,45 @@ mod tests {
|
||||
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]
|
||||
fn session_enabled_macros_empty_list_is_some_empty() {
|
||||
let session: Session =
|
||||
|
||||
@@ -37,6 +37,8 @@ pub struct Skill {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enabled_mcp_servers: Option<Vec<String>>,
|
||||
#[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>,
|
||||
}
|
||||
|
||||
@@ -74,6 +76,9 @@ impl Skill {
|
||||
"enabled_mcp_servers" => {
|
||||
skill.enabled_mcp_servers = parse_skill_string_or_array(value);
|
||||
}
|
||||
"mcp_tools" => {
|
||||
skill.mcp_tools = parse_skill_mcp_tools_map(value);
|
||||
}
|
||||
"auto_unload" => {
|
||||
skill.auto_unload = value.as_bool();
|
||||
}
|
||||
@@ -147,6 +152,10 @@ impl Skill {
|
||||
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 {
|
||||
self.auto_unload.unwrap_or(false)
|
||||
}
|
||||
@@ -185,6 +194,21 @@ fn parse_skill_string_or_array(value: &Value) -> Option<Vec<String>> {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -198,6 +222,44 @@ mod tests {
|
||||
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]
|
||||
fn skill_new_parses_full_metadata() {
|
||||
let content = "---\n\
|
||||
|
||||
@@ -34,6 +34,10 @@ impl SkillRegistry {
|
||||
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> {
|
||||
let mut out = BTreeSet::new();
|
||||
for skill in self.loaded.values() {
|
||||
|
||||
+213
-12
@@ -1,3 +1,4 @@
|
||||
use super::mcp_tool_policy::ToolFilter;
|
||||
use crate::function::{Functions, ToolCallTracker};
|
||||
use crate::mcp::{CatalogItem, CatalogItemKind, ConnectedServer, McpRegistry, McpServerFeatures};
|
||||
|
||||
@@ -44,6 +45,8 @@ pub enum McpPromptCompletion {
|
||||
#[derive(Default, Clone)]
|
||||
pub struct McpRuntime {
|
||||
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 {
|
||||
@@ -100,12 +103,27 @@ impl McpRuntime {
|
||||
|
||||
if features.tools {
|
||||
match server_handle.list_all_tools().await {
|
||||
Ok(tools) => merge_catalog_items(
|
||||
&mut items,
|
||||
tools
|
||||
.into_iter()
|
||||
.map(|tool| tool_catalog_item(server, tool)),
|
||||
),
|
||||
Ok(mut tools) => {
|
||||
if let Some(filter) = self.tool_filters.get(server) {
|
||||
let advertised: Vec<String> =
|
||||
tools.iter().map(|tool| tool.name.to_string()).collect();
|
||||
|
||||
for (source, pattern) in filter.dead_context_patterns(&advertised) {
|
||||
warn!(
|
||||
"MCP tool pattern '{pattern}' from {source} matches no allowed tools on server '{server}'"
|
||||
);
|
||||
}
|
||||
|
||||
tools.retain(|tool| filter.allows(&tool.name));
|
||||
}
|
||||
|
||||
merge_catalog_items(
|
||||
&mut items,
|
||||
tools
|
||||
.into_iter()
|
||||
.map(|tool| tool_catalog_item(server, tool)),
|
||||
)
|
||||
}
|
||||
Err(e) => warn!("Failed to list tools on MCP server {server}: {e}"),
|
||||
}
|
||||
}
|
||||
@@ -195,6 +213,11 @@ impl McpRuntime {
|
||||
|
||||
match kind {
|
||||
"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
|
||||
.list_all_tools()
|
||||
.await?
|
||||
@@ -297,6 +320,12 @@ impl McpRuntime {
|
||||
.cloned()
|
||||
.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());
|
||||
request.arguments = arguments.as_object().cloned();
|
||||
|
||||
@@ -614,11 +643,15 @@ pub(crate) mod test_fixtures {
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FixtureServer {
|
||||
pub(crate) tools_capability: bool,
|
||||
pub(crate) tool_names: Vec<&'static str>,
|
||||
pub(crate) resources_capability: bool,
|
||||
pub(crate) prompts_capability: bool,
|
||||
pub(crate) hostile_prompt: bool,
|
||||
pub(crate) fail_resource_listings: bool,
|
||||
pub(crate) fail_template_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) prompt_delay: Option<Duration>,
|
||||
pub(crate) tool_result: Option<CallToolResult>,
|
||||
@@ -632,11 +665,15 @@ pub(crate) mod test_fixtures {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tools_capability: true,
|
||||
tool_names: vec!["dup"],
|
||||
resources_capability: false,
|
||||
prompts_capability: false,
|
||||
hostile_prompt: false,
|
||||
fail_resource_listings: false,
|
||||
fail_template_listings: false,
|
||||
fail_prompt_listings: false,
|
||||
empty_resource_listings: false,
|
||||
empty_prompt_listings: false,
|
||||
fail_get_prompt: false,
|
||||
prompt_delay: None,
|
||||
tool_result: None,
|
||||
@@ -672,11 +709,12 @@ pub(crate) mod test_fixtures {
|
||||
.as_object()
|
||||
.cloned()
|
||||
.unwrap();
|
||||
Ok(ListToolsResult::with_all_items(vec![Tool::new(
|
||||
"dup",
|
||||
"Duplicate-named tool",
|
||||
schema,
|
||||
)]))
|
||||
Ok(ListToolsResult::with_all_items(
|
||||
self.tool_names
|
||||
.iter()
|
||||
.map(|name| Tool::new(*name, "Fixture tool", schema.clone()))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
@@ -703,6 +741,9 @@ pub(crate) mod test_fixtures {
|
||||
if self.fail_resource_listings {
|
||||
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![
|
||||
Resource::new("dup", "dup-resource")
|
||||
.with_description("Duplicate-named resource")
|
||||
@@ -720,9 +761,12 @@ pub(crate) mod test_fixtures {
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> 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));
|
||||
}
|
||||
if self.empty_resource_listings {
|
||||
return Ok(ListResourceTemplatesResult::with_all_items(vec![]));
|
||||
}
|
||||
Ok(ListResourceTemplatesResult::with_all_items(vec![
|
||||
ResourceTemplate::new("file:///{path}/{name}", "file-template")
|
||||
.with_description("Read a file")
|
||||
@@ -778,6 +822,9 @@ pub(crate) mod test_fixtures {
|
||||
if self.fail_prompt_listings {
|
||||
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(
|
||||
"summarize",
|
||||
Some("Summarize a document"),
|
||||
@@ -869,6 +916,7 @@ mod tests {
|
||||
FIXTURE_ANNOTATED_URI, FixtureServer, add_fixture_server, fixture_runtime,
|
||||
};
|
||||
use super::*;
|
||||
use crate::config::mcp_tool_policy::LayerSource;
|
||||
use crate::function::ToolCall;
|
||||
use log::{Level, LevelFilter, Log, Metadata, Record};
|
||||
use std::sync::atomic::Ordering;
|
||||
@@ -1643,4 +1691,157 @@ mod tests {
|
||||
assert_eq!(prompt, "summarize");
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,6 +686,7 @@ pub async fn run_agent_for_graph(
|
||||
sync_agent_functions_to_ctx(&mut child_ctx)?;
|
||||
} else {
|
||||
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)?;
|
||||
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)?;
|
||||
} else {
|
||||
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)?;
|
||||
child_ctx.init_agent_shared_variables()?;
|
||||
}
|
||||
@@ -1604,6 +1606,7 @@ mod tests {
|
||||
use crate::config::test_fixtures::{FixtureServer, fixture_runtime};
|
||||
use crate::config::{AgentConfig, AppState, WorkingMode};
|
||||
use crate::function::jobs::RingBuf;
|
||||
use crate::mcp::{McpServer, McpServersConfig, McpTransportType};
|
||||
use crate::supervisor::escalation::{EscalationQueue, EscalationRequest};
|
||||
use crate::supervisor::{JobHandle, JobResult, JobState, JobStatus};
|
||||
use parking_lot::Mutex;
|
||||
@@ -1791,6 +1794,61 @@ mod tests {
|
||||
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]
|
||||
fn handle_list_running_empty_supervisor() {
|
||||
let mut ctx = ctx_with_supervisor(4, 3);
|
||||
|
||||
+44
-3
@@ -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 {
|
||||
whitelist_rejection(tool).is_none()
|
||||
}
|
||||
@@ -457,6 +454,11 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let mut mcp_runtime = McpRuntime::new();
|
||||
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 {
|
||||
mcp_runtime,
|
||||
current_depth: ctx.current_depth,
|
||||
@@ -1263,7 +1265,9 @@ fn tail_chars(text: &str, max_chars: usize) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::test_fixtures::{FixtureServer, fixture_runtime};
|
||||
use crate::config::{AppConfig, AppState, WorkingMode};
|
||||
use crate::config::{LayerSource, ToolFilter};
|
||||
use crate::function::agents::{
|
||||
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]
|
||||
fn handle_start_rejects_unconnected_mcp_server() {
|
||||
let mut ctx = plain_ctx();
|
||||
|
||||
@@ -424,7 +424,7 @@ async fn step(
|
||||
Ok(StepResult::Continue(vec![next]))
|
||||
}
|
||||
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 {
|
||||
LlmExecutionOutcome::Continue => static_next_targets(node, current, "llm")?,
|
||||
LlmExecutionOutcome::FellBack(target) => vec![target],
|
||||
|
||||
+13
-1
@@ -30,11 +30,12 @@ pub struct LlmNodeExecutor;
|
||||
|
||||
impl LlmNodeExecutor {
|
||||
pub(super) async fn execute(
|
||||
node_id: &str,
|
||||
node: &LlmNode,
|
||||
state_manager: &mut StateManager,
|
||||
parent_ctx: &mut RequestContext,
|
||||
) -> 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 {
|
||||
Ok(raw) => match &node.output_schema {
|
||||
Some(schema) => match structured::extract(&raw, schema, parent_ctx).await {
|
||||
@@ -79,6 +80,7 @@ fn outcome_from(
|
||||
}
|
||||
|
||||
async fn run(
|
||||
node_id: &str,
|
||||
node: &LlmNode,
|
||||
state_manager: &mut StateManager,
|
||||
parent_ctx: &mut RequestContext,
|
||||
@@ -177,6 +179,13 @@ async fn run(
|
||||
// Jobs are node-local: everything job__start registers while this node
|
||||
// runs is recorded here and reaped on every exit path below.
|
||||
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 {
|
||||
Some(secs) => match timeout(
|
||||
Duration::from_secs(secs),
|
||||
@@ -193,6 +202,8 @@ async fn run(
|
||||
let node_jobs =
|
||||
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;
|
||||
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);
|
||||
result
|
||||
}
|
||||
@@ -506,6 +517,7 @@ mod tests {
|
||||
instructions: Some("sys".into()),
|
||||
prompt: "user".into(),
|
||||
tools: None,
|
||||
mcp_tools: None,
|
||||
model: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
|
||||
+5
-3
@@ -85,9 +85,11 @@ impl MapNodeExecutor {
|
||||
let mut ctx = sub_ctx;
|
||||
|
||||
let exec_result: Result<()> = match &branch_clone.node_type {
|
||||
NodeType::Llm(n) => LlmNodeExecutor::execute(n, &mut state, &mut ctx)
|
||||
.await
|
||||
.map(|_| ()),
|
||||
NodeType::Llm(n) => {
|
||||
LlmNodeExecutor::execute(&branch_clone.id, n, &mut state, &mut ctx)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
NodeType::Agent(n) => AgentNodeExecutor::execute(n, &mut state, &mut ctx)
|
||||
.await
|
||||
.map(|_| ()),
|
||||
|
||||
@@ -37,6 +37,9 @@ pub struct Graph {
|
||||
#[serde(default)]
|
||||
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")]
|
||||
pub skills_enabled: Option<bool>,
|
||||
|
||||
@@ -285,6 +288,9 @@ pub struct LlmNode {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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")]
|
||||
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]
|
||||
fn deserializes_every_node_type() {
|
||||
let yaml = r#"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::state::template_root_keys;
|
||||
use super::types::{Graph, Node, NodeType};
|
||||
use crate::client::{Model, ModelType};
|
||||
use crate::config;
|
||||
use crate::config::{Agent, AppConfig, paths};
|
||||
use crate::rag::{GraphRagConfig, RagData};
|
||||
use anyhow::{Result, bail};
|
||||
@@ -214,6 +215,14 @@ impl GraphValidator {
|
||||
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 {
|
||||
let NodeType::Llm(llm) = &node.node_type else {
|
||||
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
|
||||
&& Model::retrieve_model(ctx.app_config.as_ref(), model_id, ModelType::Chat)
|
||||
.is_err()
|
||||
@@ -1001,6 +1029,7 @@ mod tests {
|
||||
max_concurrent_jobs: None,
|
||||
global_tools: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
mcp_tools: None,
|
||||
skills_enabled: None,
|
||||
enabled_skills: None,
|
||||
inject_skill_instructions: None,
|
||||
@@ -1099,6 +1128,7 @@ mod tests {
|
||||
instructions: None,
|
||||
prompt: "p".into(),
|
||||
tools: None,
|
||||
mcp_tools: None,
|
||||
model: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
@@ -1257,6 +1287,19 @@ mod tests {
|
||||
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]
|
||||
fn llm_node_unknown_tool_is_an_error() {
|
||||
let graph = graph_with(
|
||||
@@ -1340,6 +1383,103 @@ mod tests {
|
||||
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]
|
||||
fn llm_node_unknown_model_is_an_error() {
|
||||
let graph = graph_with(
|
||||
|
||||
@@ -242,6 +242,7 @@ fn build_stdio(cli: &Cli, has_url: bool) -> Result<McpServer> {
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -300,6 +301,7 @@ fn build_remote(cli: &Cli, transport: McpTransportType, has_command: bool) -> Re
|
||||
url: Some(url),
|
||||
headers: (!headers.is_empty()).then_some(headers),
|
||||
oauth,
|
||||
allowed_tools: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+23
-1
@@ -6,7 +6,7 @@ mod sse_transport;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
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::interpolate_secrets;
|
||||
use anyhow::Error;
|
||||
@@ -166,6 +166,8 @@ pub(crate) struct McpServer {
|
||||
pub headers: Option<IndexMap<String, String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub oauth: Option<McpOAuthConfig>,
|
||||
#[serde(rename = "allowedTools", skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
@@ -177,6 +179,15 @@ impl McpServer {
|
||||
}
|
||||
|
||||
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() {
|
||||
let type_label = match self.transport_type {
|
||||
McpTransportType::Http => "http",
|
||||
@@ -802,6 +813,7 @@ mod tests {
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -815,6 +827,7 @@ mod tests {
|
||||
url: Some(url.to_string()),
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -828,6 +841,7 @@ mod tests {
|
||||
url: Some(url.to_string()),
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -860,6 +874,7 @@ mod tests {
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
@@ -878,6 +893,7 @@ mod tests {
|
||||
url: Some("http://localhost".into()),
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
@@ -898,6 +914,7 @@ mod tests {
|
||||
url: None,
|
||||
headers: Some(headers),
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
@@ -923,6 +940,7 @@ mod tests {
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
@@ -941,6 +959,7 @@ mod tests {
|
||||
url: Some("http://localhost".into()),
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
@@ -959,6 +978,7 @@ mod tests {
|
||||
url: Some("http://localhost".into()),
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
@@ -977,6 +997,7 @@ mod tests {
|
||||
url: Some("http://localhost".into()),
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
@@ -1002,6 +1023,7 @@ mod tests {
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
|
||||
+18
-3
@@ -55,7 +55,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
|
||||
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(".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",
|
||||
AssertState::True(StateFlags::FUNCTION_CALLING),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".info mcp-server",
|
||||
"Show an MCP server's tool filters and effective catalog",
|
||||
AssertState::pass(),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".authenticate",
|
||||
"Authenticate the current model client via OAuth (if configured)",
|
||||
@@ -639,6 +644,16 @@ pub async fn run_repl_command(
|
||||
let info = ctx.todo_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()?,
|
||||
None => {
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
@@ -1959,8 +1974,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_commands_has_62_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 62);
|
||||
fn repl_commands_has_63_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 63);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user