diff --git a/Cargo.lock b/Cargo.lock index d856e4a..0208f01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 811fad0..1a89ab8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/config.agent.example.yaml b/config.agent.example.yaml index a0852c1..039171e 100644 --- a/config.agent.example.yaml +++ b/config.agent.example.yaml @@ -11,64 +11,74 @@ # - _AGENT_SESSION # - _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: # Name of the agent, used in the UI and logs -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: # Name of the agent, used in the UI and logs +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 `/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 `/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. @@ -88,12 +98,12 @@ instructions: | # Static instructions for the agent; ignored if username: {{username}} -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 /agents/ 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 /agents/ 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) diff --git a/config.example.yaml b/config.example.yaml index 40ab1c5..9425cbb 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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, ) -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, ) +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:, session:, :) -cmd_prelude: null # Set a default session or role for CMD mode to use (e.g. role:, session:, :) -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:, session:, :) +cmd_prelude: null # Set a default session or role for CMD mode to use (e.g. role:, session:, :) +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/ -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/ +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: # : # 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 # : # 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: /application_default_credentials.json # Optional + adc_file: /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 \ No newline at end of file + api_key: '{{VOYAGEAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault diff --git a/config.role.example.md b/config.role.example.md index 3fabd63..d806b24 100644 --- a/config.role.example.md +++ b/config.role.example.md @@ -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) diff --git a/graph.example.yaml b/graph.example.yaml index 19a5d0b..6791810 100644 --- a/graph.example.yaml +++ b/graph.example.yaml @@ -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:` - ddg-search +mcp_tools: # Optional per-server tool allowlists (globs: * and ?) applied to + ddg-search: # every node that uses `mcp:`; 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:` 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) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index cc6df8a..848815c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -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] diff --git a/src/config/agent.rs b/src/config/agent.rs index 15638ea..87d6fe6 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -682,6 +682,10 @@ impl RoleLike for Agent { Some(self.config.mcp_servers.clone()) } + fn mcp_tools(&self) -> Option>> { + 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>>) { + 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mcp_tools: Option>>, #[serde(default)] pub global_tools: Vec, #[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"; diff --git a/src/config/app_config.rs b/src/config/app_config.rs index 4f4e4b1..6235c1d 100644 --- a/src/config/app_config.rs +++ b/src/config/app_config.rs @@ -50,6 +50,7 @@ pub struct AppConfig { pub mapping_mcp_servers: IndexMap, #[serde(default, deserialize_with = "super::deserialize_csv_or_vec")] pub enabled_mcp_servers: Option>, + pub mcp_tools: Option>>, 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() { diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index 5013171..9b8d0a8 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -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-"); diff --git a/src/config/mcp_factory.rs b/src/config/mcp_factory.rs index 961847d..23dba66 100644 --- a/src/config/mcp_factory.rs +++ b/src/config/mcp_factory.rs @@ -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, } } diff --git a/src/config/mcp_tool_policy.rs b/src/config/mcp_tool_policy.rs new file mode 100644 index 0000000..ab2100e --- /dev/null +++ b/src/config/mcp_tool_policy.rs @@ -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, + regexes: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct ToolFilter { + layers: Vec, +} + +impl ToolFilter { + pub fn push_layer(&mut self, source: LayerSource, patterns: &[String]) { + self.layers.push(CompiledPatterns { + source, + raw: patterns.to_vec(), + regexes: patterns.iter().map(|p| compile_glob(p)).collect(), + }); + } + + pub fn layers(&self) -> impl Iterator { + 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, &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, + pub mcp_tools: IndexMap>, +} + +pub struct McpToolPolicy; + +impl McpToolPolicy { + #[allow(clippy::too_many_arguments)] + pub fn effective( + mcp_config: &McpServersConfig, + session: Option<&IndexMap>>, + agent: Option<(&str, &IndexMap>)>, + role: Option<(&str, &IndexMap>)>, + global: Option<&IndexMap>>, + skills: &[SkillMcpLayer], + node: Option<(&str, &IndexMap>)>, + aliases: &IndexMap, + ) -> HashMap { + let mut filters: HashMap = HashMap::new(); + + for (server, spec) in &mcp_config.mcp_servers { + if let Some(patterns) = &spec.allowed_tools { + filters + .entry(server.clone()) + .or_default() + .push_layer(LayerSource::Global, patterns); + } + } + + if let Some(map) = global { + push_level( + &mut filters, + mcp_config, + aliases, + &LayerSource::AppConfig, + map, + None, + ); + } + if let Some((name, map)) = role { + push_level( + &mut filters, + mcp_config, + aliases, + &LayerSource::Role(name.to_string()), + map, + None, + ); + } + if let Some((name, map)) = agent { + push_level( + &mut filters, + mcp_config, + aliases, + &LayerSource::Agent(name.to_string()), + map, + None, + ); + } + if let Some(map) = session { + push_level( + &mut filters, + mcp_config, + aliases, + &LayerSource::Session, + map, + None, + ); + } + for skill in skills { + push_level( + &mut filters, + mcp_config, + aliases, + &LayerSource::Skill(skill.name.clone()), + &skill.mcp_tools, + Some(&skill.enabled_servers), + ); + } + if let Some((id, map)) = node { + push_level( + &mut filters, + mcp_config, + aliases, + &LayerSource::Node(id.to_string()), + map, + None, + ); + } + + filters + } +} + +fn push_level( + filters: &mut HashMap, + mcp_config: &McpServersConfig, + aliases: &IndexMap, + source: &LayerSource, + map: &IndexMap>, + enabled_servers: Option<&[String]>, +) { + for (server, patterns) in expand_server_keys(mcp_config, aliases, map) { + if let Some(enabled) = enabled_servers + && !enabled.iter().any(|id| id == &server) + { + continue; + } + filters + .entry(server) + .or_default() + .push_layer(source.clone(), &patterns); + } +} + +fn expand_server_keys( + mcp_config: &McpServersConfig, + aliases: &IndexMap, + map: &IndexMap>, +) -> IndexMap> { + let mut expanded: IndexMap> = IndexMap::new(); + for (key, patterns) in map { + let key = key.trim(); + if mcp_config.mcp_servers.contains_key(key) { + expanded + .entry(key.to_string()) + .or_default() + .extend(patterns.iter().cloned()); + } else { + for mapped_id in expand_mcp_server_alias(aliases, key) { + if mcp_config.mcp_servers.contains_key(&mapped_id) { + expanded + .entry(mapped_id) + .or_default() + .extend(patterns.iter().cloned()); + } + } + } + } + + expanded +} + +pub(crate) fn expand_mcp_server_alias( + aliases: &IndexMap, + key: &str, +) -> Vec { + aliases + .get(key) + .map(|mapped| { + mapped + .split(',') + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::{McpServer, McpServersConfig, McpTransportType}; + + fn spec(allowed_tools: Option<&[&str]>) -> McpServer { + McpServer { + transport_type: McpTransportType::Stdio, + command: Some("echo".to_string()), + args: None, + env: None, + cwd: None, + url: None, + headers: None, + oauth: None, + allowed_tools: allowed_tools.map(list), + } + } + + fn config(servers: &[(&str, Option<&[&str]>)]) -> McpServersConfig { + McpServersConfig { + mcp_servers: servers + .iter() + .map(|(name, tools)| (name.to_string(), spec(*tools))) + .collect(), + } + } + + fn list(items: &[&str]) -> Vec { + items.iter().map(|s| s.to_string()).collect() + } + + fn tool_map(entries: &[(&str, &[&str])]) -> IndexMap> { + entries + .iter() + .map(|(server, patterns)| (server.to_string(), list(patterns))) + .collect() + } + + fn single_layer(patterns: &[&str]) -> ToolFilter { + layered(&[(LayerSource::Global, patterns)]) + } + + fn layered(layers: &[(LayerSource, &[&str])]) -> ToolFilter { + let mut filter = ToolFilter::default(); + for (source, patterns) in layers { + filter.push_layer(source.clone(), &list(patterns)); + } + filter + } + + fn no_aliases() -> IndexMap { + IndexMap::new() + } + + fn aliases(entries: &[(&str, &str)]) -> IndexMap { + entries + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() + } + + fn resolve( + config: &McpServersConfig, + session: Option<&IndexMap>>, + role: Option<(&str, &IndexMap>)>, + ) -> HashMap { + McpToolPolicy::effective(config, session, None, role, None, &[], None, &no_aliases()) + } + + #[test] + fn literal_pattern_matches_only_the_exact_name() { + let filter = single_layer(&["get_issue"]); + + assert!(filter.allows("get_issue")); + assert!(!filter.allows("get_issues")); + assert!(!filter.allows("get_issu")); + assert!(!filter.allows("xget_issue")); + } + + #[test] + fn star_matches_any_run_of_characters() { + let filter = single_layer(&["get_*"]); + assert!(filter.allows("get_issue")); + assert!(filter.allows("get_")); + assert!(!filter.allows("set_issue")); + + let filter = single_layer(&["*_issue"]); + assert!(filter.allows("create_issue")); + assert!(!filter.allows("create_pr")); + + let filter = single_layer(&["get*sue"]); + assert!(filter.allows("get_issue")); + assert!(filter.allows("getsue")); + + let filter = single_layer(&["*"]); + assert!(filter.allows("")); + assert!(filter.allows("anything_at_all")); + } + + #[test] + fn question_mark_matches_exactly_one_character() { + let filter = single_layer(&["get_?"]); + + assert!(filter.allows("get_a")); + assert!(!filter.allows("get_")); + assert!(!filter.allows("get_ab")); + } + + #[test] + fn regex_metacharacters_are_matched_literally() { + let filter = single_layer(&["get.issue"]); + assert!(filter.allows("get.issue")); + assert!(!filter.allows("getXissue")); + + for pattern in ["a(b", "a[b", "a+b", "a|b", "a$b"] { + let filter = single_layer(&[pattern]); + assert!(filter.allows(pattern), "'{pattern}' should match itself"); + assert!(!filter.allows("ab"), "'{pattern}' should not match 'ab'"); + } + } + + #[test] + fn backslash_is_literal_and_star_still_wildcards() { + let filter = single_layer(&["a\\b"]); + assert!(filter.allows("a\\b")); + assert!(!filter.allows("ab")); + + let filter = single_layer(&["a\\*b"]); + assert!(filter.allows("a\\b")); + assert!(filter.allows("a\\xyzb")); + assert!(!filter.allows("ab")); + } + + #[test] + fn the_never_matching_placeholder_matches_nothing() { + let regex = never_matching_regex(); + + assert!(!regex.is_match("").unwrap()); + assert!(!regex.is_match("anything").unwrap()); + } + + #[test] + fn within_a_layer_any_pattern_may_match() { + let filter = single_layer(&["get_*", "set_*"]); + + assert!(filter.allows("get_x")); + assert!(filter.allows("set_x")); + assert!(!filter.allows("delete_x")); + } + + #[test] + fn across_layers_every_layer_must_match() { + let filter = layered(&[ + (LayerSource::Global, &["get_*"]), + (LayerSource::Session, &["*_issue"]), + ]); + + assert!(filter.allows("get_issue")); + assert!(!filter.allows("get_pr")); + assert!(!filter.allows("create_issue")); + } + + #[test] + fn an_empty_layer_blocks_everything() { + let filter = layered(&[(LayerSource::Global, &["*"]), (LayerSource::Session, &[])]); + + assert!(!filter.allows("anything")); + assert_eq!( + filter.allows_explain("anything"), + Err(&LayerSource::Session) + ); + } + + #[test] + fn allows_explain_reports_the_first_matching_pattern_per_layer() { + let filter = layered(&[ + (LayerSource::Global, &["x_*", "get_*"]), + (LayerSource::Session, &["*"]), + ]); + + assert_eq!( + filter.allows_explain("get_issue").unwrap(), + vec![ + (&LayerSource::Global, "get_*"), + (&LayerSource::Session, "*") + ] + ); + } + + #[test] + fn allows_explain_reports_the_first_layer_without_a_match() { + let filter = layered(&[ + (LayerSource::Global, &["get_*"]), + (LayerSource::Session, &["*"]), + ]); + assert_eq!( + filter.allows_explain("delete_repo"), + Err(&LayerSource::Global) + ); + + let filter = layered(&[ + (LayerSource::Global, &["*"]), + (LayerSource::Session, &["get_*"]), + ]); + assert_eq!( + filter.allows_explain("delete_repo"), + Err(&LayerSource::Session) + ); + } + + #[test] + fn global_allowed_tools_from_mcp_json_is_the_first_layer() { + let config = config(&[("gh", Some(&["get_*"]))]); + let session_map = tool_map(&[("gh", &["*"])]); + + let filters = resolve(&config, Some(&session_map), None); + + assert_eq!( + filters["gh"].allows_explain("get_issue").unwrap(), + vec![ + (&LayerSource::Global, "get_*"), + (&LayerSource::Session, "*") + ] + ); + } + + #[test] + fn servers_without_patterns_at_any_level_are_absent() { + let config = config(&[("gh", None)]); + + let filters = resolve(&config, None, None); + + assert!(filters.is_empty()); + } + + #[test] + fn server_absent_from_a_level_map_gets_no_layer_from_it() { + let config = config(&[("gh", Some(&["get_*"])), ("gl", None)]); + let role_map = tool_map(&[("gl", &["x_*"])]); + + let filters = resolve(&config, None, Some(("dev", &role_map))); + + assert!(filters["gh"].allows("get_issue")); + assert!(!filters["gh"].allows("delete_repo")); + assert_eq!(filters["gh"].allows_explain("get_issue").unwrap().len(), 1); + assert!(filters["gl"].allows("x_1")); + assert!(!filters["gl"].allows("y_1")); + } + + #[test] + fn empty_pattern_list_at_a_level_blocks_all_tools_for_that_server() { + let config = config(&[("gh", Some(&["get_*"]))]); + let session_map = tool_map(&[("gh", &[])]); + + let filters = resolve(&config, Some(&session_map), None); + + assert!(!filters["gh"].allows("get_issue")); + assert_eq!( + filters["gh"].allows_explain("get_issue"), + Err(&LayerSource::Session) + ); + } + + #[test] + fn session_cannot_widen_a_role_restriction() { + let config = config(&[("gh", None)]); + let role_map = tool_map(&[("gh", &["get_*"])]); + let session_map = tool_map(&[("gh", &["*"])]); + + let filters = resolve(&config, Some(&session_map), Some(("dev", &role_map))); + + assert!(filters["gh"].allows("get_issue")); + assert!(!filters["gh"].allows("delete_repo")); + } + + #[test] + fn app_config_map_contributes_its_own_layer() { + let config = config(&[("gh", None)]); + let app_map = tool_map(&[("gh", &["get_*"])]); + + let filters = McpToolPolicy::effective( + &config, + None, + None, + None, + Some(&app_map), + &[], + None, + &no_aliases(), + ); + + assert_eq!( + filters["gh"].allows_explain("get_issue").unwrap(), + vec![(&LayerSource::AppConfig, "get_*")] + ); + assert!(!filters["gh"].allows("delete_repo")); + } + + #[test] + fn skill_layer_applies_only_to_its_enabled_servers() { + let config = config(&[("gh", None), ("gl", None)]); + let skill = SkillMcpLayer { + name: "reviewer".to_string(), + enabled_servers: vec!["gh".to_string()], + mcp_tools: tool_map(&[("gh", &["get_*"]), ("gl", &["*"])]), + }; + + let filters = McpToolPolicy::effective( + &config, + None, + None, + None, + None, + &[skill], + None, + &no_aliases(), + ); + + assert!(filters.contains_key("gh")); + assert!(!filters.contains_key("gl")); + } + + #[test] + fn two_skills_naming_the_same_server_stack_independent_layers() { + let config = config(&[("gh", None)]); + let skills = vec![ + SkillMcpLayer { + name: "a".to_string(), + enabled_servers: vec!["gh".to_string()], + mcp_tools: tool_map(&[("gh", &["get_*"])]), + }, + SkillMcpLayer { + name: "b".to_string(), + enabled_servers: vec!["gh".to_string()], + mcp_tools: tool_map(&[("gh", &["*_issue"])]), + }, + ]; + + let filters = McpToolPolicy::effective( + &config, + None, + None, + None, + None, + &skills, + None, + &no_aliases(), + ); + + assert!(filters["gh"].allows("get_issue")); + assert!(!filters["gh"].allows("get_pr")); + assert!(!filters["gh"].allows("create_issue")); + assert_eq!(filters["gh"].allows_explain("get_issue").unwrap().len(), 2); + } + + #[test] + fn layers_stack_in_documented_order_with_node_last() { + let config = config(&[("gh", Some(&["*"]))]); + let app_map = tool_map(&[("gh", &["*"])]); + let role_map = tool_map(&[("gh", &["*"])]); + let agent_map = tool_map(&[("gh", &["*"])]); + let session_map = tool_map(&[("gh", &["*"])]); + let skills = vec![SkillMcpLayer { + name: "reviewer".to_string(), + enabled_servers: vec!["gh".to_string()], + mcp_tools: tool_map(&[("gh", &["*"])]), + }]; + let node_map = tool_map(&[("gh", &["*"])]); + + let filters = McpToolPolicy::effective( + &config, + Some(&session_map), + Some(("worker", &agent_map)), + Some(("dev", &role_map)), + Some(&app_map), + &skills, + Some(("n1", &node_map)), + &no_aliases(), + ); + + let sources: Vec = filters["gh"] + .allows_explain("anything") + .unwrap() + .iter() + .map(|(source, _)| source.to_string()) + .collect(); + assert_eq!( + sources, + vec![ + "global (mcp.json)", + "config (config.yaml)", + "role (dev)", + "agent (worker)", + "session (.set)", + "skill (reviewer)", + "node (n1)", + ] + ); + } + + #[test] + fn alias_key_expands_to_all_mapped_servers() { + let config = config(&[("github", None), ("gitlab", None)]); + let role_map = tool_map(&[("gh", &["get_*"])]); + + let filters = McpToolPolicy::effective( + &config, + None, + None, + Some(("dev", &role_map)), + None, + &[], + None, + &aliases(&[("gh", "github,gitlab")]), + ); + + assert!(filters["github"].allows("get_issue")); + assert!(!filters["github"].allows("delete_repo")); + assert!(filters["gitlab"].allows("get_issue")); + assert!(!filters["gitlab"].allows("delete_repo")); + } + + #[test] + fn alias_ids_missing_from_the_config_are_skipped() { + let config = config(&[("github", None)]); + let role_map = tool_map(&[("gh", &["get_*"])]); + + let filters = McpToolPolicy::effective( + &config, + None, + None, + Some(("dev", &role_map)), + None, + &[], + None, + &aliases(&[("gh", "github,missing")]), + ); + + assert_eq!(filters.len(), 1); + assert!(filters.contains_key("github")); + } + + #[test] + fn unknown_map_keys_are_dropped() { + let config = config(&[("github", None)]); + let role_map = tool_map(&[("nope", &["get_*"])]); + + let filters = resolve(&config, None, Some(("dev", &role_map))); + + assert!(filters.is_empty()); + } + + #[test] + fn alias_and_direct_key_for_the_same_server_merge_into_one_layer() { + let config = config(&[("github", None)]); + let role_map = tool_map(&[("gh", &["get_*"]), ("github", &["set_*"])]); + + let filters = McpToolPolicy::effective( + &config, + None, + None, + Some(("dev", &role_map)), + None, + &[], + None, + &aliases(&[("gh", "github")]), + ); + + assert!(filters["github"].allows("get_issue")); + assert!(filters["github"].allows("set_topic")); + assert!(!filters["github"].allows("delete_repo")); + assert_eq!( + filters["github"].allows_explain("get_issue").unwrap().len(), + 1 + ); + } + + #[test] + fn layer_source_display() { + assert_eq!(LayerSource::Global.to_string(), "global (mcp.json)"); + assert_eq!(LayerSource::AppConfig.to_string(), "config (config.yaml)"); + assert_eq!(LayerSource::Role("dev".into()).to_string(), "role (dev)"); + assert_eq!( + LayerSource::Agent("worker".into()).to_string(), + "agent (worker)" + ); + assert_eq!(LayerSource::Session.to_string(), "session (.set)"); + assert_eq!( + LayerSource::Skill("review".into()).to_string(), + "skill (review)" + ); + assert_eq!(LayerSource::Node("n1".into()).to_string(), "node (n1)"); + } + + #[test] + fn dead_context_patterns_flags_patterns_matching_nothing() { + let filter = layered(&[ + (LayerSource::Global, &["get_*"]), + (LayerSource::Role("dev".into()), &["get_issue", "set_*"]), + ]); + + let advertised = vec!["get_issue".to_string(), "set_topic".to_string()]; + let dead = filter.dead_context_patterns(&advertised); + + // set_* only matches set_topic, which the global layer hides. + assert_eq!(dead, vec![(&LayerSource::Role("dev".into()), "set_*")]); + } + + #[test] + fn dead_context_patterns_is_empty_when_every_pattern_is_live() { + let filter = layered(&[ + (LayerSource::Global, &["get_*"]), + (LayerSource::Session, &["get_issue"]), + ]); + + let advertised = vec!["get_issue".to_string()]; + assert!(filter.dead_context_patterns(&advertised).is_empty()); + } + + #[test] + fn dead_context_patterns_ignores_the_global_layer_itself() { + let filter = layered(&[(LayerSource::Global, &["zzz_*"])]); + + let advertised = vec!["get_issue".to_string()]; + assert!(filter.dead_context_patterns(&advertised).is_empty()); + } + + #[test] + fn expand_mcp_server_alias_splits_and_trims() { + let aliases = aliases(&[("gh", "github, gitlab,")]); + + assert_eq!( + expand_mcp_server_alias(&aliases, "gh"), + vec!["github".to_string(), "gitlab".to_string()] + ); + assert!(expand_mcp_server_alias(&aliases, "nope").is_empty()); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index e89caee..26b2eca 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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, #[serde(default, deserialize_with = "deserialize_csv_or_vec")] pub enabled_mcp_servers: Option>, + pub mcp_tools: Option>>, 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 { 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 { @@ -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(); diff --git a/src/config/request_context.rs b/src/config/request_context.rs index b8a7368..55d0972 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -1,4 +1,5 @@ use super::bundles::BundleStore; +use super::mcp_tool_policy::{McpToolPolicy, SkillMcpLayer, ToolFilter, expand_mcp_server_alias}; use super::rag_cache::{RagCache, RagKey}; use super::session::{INTERRUPTED_RESPONSE_TEXT, Session}; use super::skill::{SKILL_SCAFFOLD, Skill}; @@ -28,7 +29,8 @@ use crate::function::{ }; use crate::mcp::{ CatalogItem, MCP_SEARCH_META_FUNCTION_NAME_PREFIX, McpAuthReason, McpAuthRequired, - McpServersConfig, is_auth_required_error, is_mcp_meta_function, mcp_meta_function_names, + McpServerFeatures, McpServersConfig, McpTransportType, is_auth_required_error, + is_mcp_meta_function, mcp_meta_function_names, }; use crate::rag::Rag; use crate::supervisor::Supervisor; @@ -51,7 +53,6 @@ use crate::graph; use anyhow::{Context, Error, Result, bail}; use colored::Colorize; use gman::providers::SupportedProvider; -#[cfg(test)] use indexmap::IndexMap; use indoc::formatdoc; use inquire::{Confirm, MultiSelect, Text, list_option::ListOption, validator::Validation}; @@ -95,10 +96,10 @@ pub(crate) fn expand_enabled_mcp_server_ids( for item in enabled_mcp_servers.iter().map(|s| s.trim()) { if mcp_config.mcp_servers.contains_key(item) { ids.push(item.to_string()); - } else if let Some(mapped) = app.mapping_mcp_servers.get(item) { - for mapped_id in mapped.split(',').map(|s| s.trim()) { - if mcp_config.mcp_servers.contains_key(mapped_id) { - ids.push(mapped_id.to_string()); + } else { + for mapped_id in expand_mcp_server_alias(&app.mapping_mcp_servers, item) { + if mcp_config.mcp_servers.contains_key(&mapped_id) { + ids.push(mapped_id); } } } @@ -207,7 +208,7 @@ fn complete_skills_with_descriptions(names: Vec) -> Vec<(String, Option< .collect() } -const SET_COMPLETION_KEYS: [&str; 26] = [ +const SET_COMPLETION_KEYS: [&str; 27] = [ "auto_continue", "continuation_prompt", "temperature", @@ -220,6 +221,7 @@ const SET_COMPLETION_KEYS: [&str; 26] = [ "inject_skill_instructions", "skill_instructions", "max_auto_continues", + "mcp_tools", "memory", "save_session", "compression_threshold", @@ -333,6 +335,10 @@ pub struct RequestContext { /// context owns every job in its supervisor. pub node_job_scope: Option>, + /// Set while a graph LLM node with `mcp_tools` is executing; re-applied as + /// the last filter layer by every `refresh_mcp_tool_filters` recompute. + pub active_node_mcp_tools: Option<(String, IndexMap>)>, + pub supervisor: Option>>, pub parent_supervisor: Option>>, pub self_agent_id: Option, @@ -369,6 +375,7 @@ impl RequestContext { tool_scope: ToolScope::default(), declared_function_names: Default::default(), node_job_scope: None, + active_node_mcp_tools: None, supervisor: None, parent_supervisor: None, self_agent_id: None, @@ -410,7 +417,7 @@ impl RequestContext { mcp_runtime.sync_from_registry(registry); } - Ok(Self { + let mut ctx = Self { app, macro_flag: false, macro_non_isolated: false, @@ -431,6 +438,7 @@ impl RequestContext { }, declared_function_names: Default::default(), node_job_scope: None, + active_node_mcp_tools: None, supervisor: None, parent_supervisor: None, self_agent_id: None, @@ -445,7 +453,9 @@ impl RequestContext { last_continuation_response: None, pending_prefill: None, render_mode: RenderMode::default(), - }) + }; + ctx.refresh_mcp_tool_filters(); + Ok(ctx) } /// Forks the context for one parallel branch of a graph super-step. @@ -480,6 +490,7 @@ impl RequestContext { tool_scope: self.tool_scope.clone(), declared_function_names: self.declared_function_names.clone(), node_job_scope: None, + active_node_mcp_tools: self.active_node_mcp_tools.clone(), supervisor: self.supervisor.clone(), parent_supervisor: self.parent_supervisor.clone(), self_agent_id: self.self_agent_id.clone(), @@ -527,6 +538,7 @@ impl RequestContext { }, declared_function_names: Default::default(), node_job_scope: None, + active_node_mcp_tools: None, supervisor: None, parent_supervisor: parent.supervisor.clone(), self_agent_id: Some(self_agent_id), @@ -763,6 +775,154 @@ impl RequestContext { } } + pub async fn mcp_server_info(&self, name: &str) -> Result { + let Some(spec) = self + .app + .mcp_config + .as_ref() + .and_then(|config| config.mcp_servers.get(name)) + else { + bail!( + "MCP server '{name}' is not configured. Run `.list mcp-servers` to see what's available" + ); + }; + let Some(handle) = self.tool_scope.mcp_runtime.servers.get(name).cloned() else { + bail!("MCP server '{name}' is not running. Enable it with `.mcp enable {name}`."); + }; + + let transport = match spec.transport_type { + McpTransportType::Stdio => "stdio", + McpTransportType::Http => "http", + McpTransportType::Sse => "sse", + }; + let info = handle.peer_info(); + let features = + McpServerFeatures::from_capabilities(name, info.as_ref().map(|i| &i.capabilities)); + let mut capabilities: Vec = vec![]; + if features.tools { + capabilities.push("tools".to_string()); + } + if features.resources { + let resources = handle.list_all_resources().await; + let templates = handle.list_all_resource_templates().await; + let any_failed = resources.is_err() || templates.is_err(); + let count = resources.map(|r| r.len()).unwrap_or_default() + + templates.map(|t| t.len()).unwrap_or_default(); + if count > 0 { + capabilities.push(format!("resources ({count})")); + } else if any_failed { + capabilities.push("resources (declared, list failed)".to_string()); + } + } + if features.prompts { + match handle.list_all_prompts().await { + Ok(prompts) if prompts.is_empty() => {} + Ok(prompts) => capabilities.push(format!("prompts ({})", prompts.len())), + Err(_) => { + capabilities.push("prompts (declared, list failed)".to_string()); + } + } + } + + const INFO_LABEL_WIDTH: usize = 15; + let mut out = String::new(); + out.push_str(&format!( + "{: = filter + .map(|f| { + f.layers() + .map(|(source, patterns)| (format!("{source}:"), patterns.join(" | "))) + .collect() + }) + .unwrap_or_default(); + if layers.is_empty() { + out.push_str(&format!( + "{: = tools.iter().map(|tool| tool.name.to_string()).collect(); + names.sort_unstable(); + let allowed = names + .iter() + .filter(|tool| filter.is_none_or(|f| f.allows(tool))) + .count(); + out.push_str(&format!( + "\ntools ({allowed} allowed / {} total)\n", + names.len() + )); + let name_width = names + .iter() + .map(|tool| tool.chars().count()) + .max() + .unwrap_or_default(); + let allowed_marker = "✓".green().bold().to_string(); + let hidden_marker = "✗".red().bold().to_string(); + for tool in &names { + let explained = filter.map(|f| f.allows_explain(tool)); + match explained { + None => out.push_str(&format!(" {allowed_marker} {tool}\n")), + Some(Ok(matches)) => { + let chain: Vec = matches + .iter() + .map(|(source, pattern)| format!("{pattern} ({})", source.short_label())) + .collect(); + if chain.is_empty() { + out.push_str(&format!(" {allowed_marker} {tool}\n")); + } else { + out.push_str(&format!( + " {allowed_marker} {tool: out.push_str(&format!( + " {hidden_marker} {tool: