Skip to content

Commit 308b8c3

Browse files
committed
Support MCP server names and regexes in disabledTools
Also add disabledTools to agent markdown frontmatter (#534)
1 parent d01b021 commit 308b8c3

8 files changed

Lines changed: 144 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- Add agent `spawnableBy` configuration to restrict subagent discovery and spawning to specific primary agents.
6+
- Support MCP server names and regexes in `disabledTools` entries and add `disabledTools` to agent markdown frontmatter. #534
67

78
## 0.147.1
89

docs/config.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@
113113
},
114114
"disabledTools": {
115115
"type": "array",
116-
"description": "List of tool names to disable globally.",
117-
"markdownDescription": "List of tool names to disable globally.",
116+
"description": "Tools to disable globally. Each entry matches a builtin tool name or regex (no eca__ prefix needed), an exact MCP server name (disables all its tools), or a regex against the tool full name server__tool.",
117+
"markdownDescription": "Tools to disable globally. Each entry matches a builtin tool name or regex (no `eca__` prefix needed), an exact MCP server name (disables all its tools), or a regex against the tool full name `server__tool`.",
118118
"items": {
119119
"type": "string"
120120
}
@@ -895,8 +895,8 @@
895895
},
896896
"disabledTools": {
897897
"type": "array",
898-
"description": "Tools to disable for this agent.",
899-
"markdownDescription": "Tools to disable for this agent.",
898+
"description": "Tools to disable for this agent. Each entry matches a builtin tool name or regex (no eca__ prefix needed), an exact MCP server name (disables all its tools), or a regex against the tool full name server__tool.",
899+
"markdownDescription": "Tools to disable for this agent. Each entry matches a builtin tool name or regex (no `eca__` prefix needed), an exact MCP server name (disables all its tools), or a regex against the tool full name `server__tool`.",
900900
"items": {
901901
"type": "string"
902902
}

docs/config/agents.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ Subagents can be configured in config or markdown and support/require these fiel
111111
- `spawnableBy` (optional): one primary agent ID or a collection of primary agent IDs allowed to discover and spawn this subagent. When omitted or empty, the subagent remains available to every primary agent.
112112
- `model` (optional): which full model to use for this subagent, using primary agent model if not specified.
113113
- `tools` (optional): same as ECA tool approval logic to control what tools are allowed/askable/denied.
114+
- `disabledTools` (optional): tools to hide from this agent entirely. Same matching as the global [`disabledTools`](tools.md#disabled-tools): a builtin tool name or regex (no `eca__` prefix needed), an exact MCP server name (all its tools), or a regex against the tool full name `server__tool`.
114115
- `maxSteps` (optional): set a max limit of turns/steps that his subagent must finish and return an answer.
115116

116117
### Parent-scoped subagents

docs/config/tools.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,9 +237,13 @@ Placeholders in the format `{{argument_name}}` within the `command` string will
237237

238238
## Disabled tools
239239

240-
You can completely disable tools so they are never available to the LLM. This is configured via the `disabledTools` config, which accepts a list of tool name strings.
240+
You can completely disable tools so they are never available to the LLM. This is configured via the `disabledTools` config, which accepts a list of strings matched against each tool, in this order:
241241

242-
It can be set globally or per agent.
242+
1. A builtin ECA tool name or regex, no `eca__` prefix needed: `edit_file`, `.*_file`.
243+
2. An exact MCP server name, disabling all tools of that server: `clojure-mcp`.
244+
3. A regex matched against the tool full name `server__tool`: `clojure-mcp__eval.*`, `my-mcp__dangerous_tool`.
245+
246+
Regexes must match the whole name (anchored). It can be set globally, per agent or in the [agent markdown frontmatter](agents.md).
243247

244248
=== "Global"
245249

@@ -261,9 +265,24 @@ It can be set globally or per agent.
261265
}
262266
```
263267

268+
=== "Whole MCP server per agent"
269+
270+
```javascript title="~/.config/eca/config.json"
271+
{
272+
"mcpServers": {
273+
"clojure-mcp": {"command": "..."}
274+
},
275+
"agent": {
276+
"plan": {
277+
"disabledTools": ["clojure-mcp"]
278+
}
279+
}
280+
}
281+
```
282+
264283
!!! tip "Disabled vs Denied"
265284

266-
`disabledTools` removes the tool entirely from the LLM — it won't even know it exists. This is different from `toolCall.approval.deny` which lets the LLM see the tool but blocks execution.
285+
`disabledTools` removes the tool entirely from the LLM — it won't even know it exists. `toolCall.approval.deny` rules without `argsMatchers` also remove the tool from the LLM tool list, while rules with `argsMatchers` keep the tool visible and only block matching calls.
267286

268287
## Approval / permissions
269288

src/eca/features/agents.clj

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,34 @@
8686
(logger/warn logger-tag (format "Ignoring malformed spawnableBy value: %s" (pr-str spawnable-by)))
8787
nil)))
8888

89+
(defn ^:private normalize-disabled-tools
90+
"Coerces the YAML `disabledTools:` value into a vector of strings.
91+
Accepts a single string or a list of strings; other shapes are ignored."
92+
[disabled-tools]
93+
(cond
94+
(nil? disabled-tools) nil
95+
(string? disabled-tools) (when-not (string/blank? disabled-tools)
96+
[disabled-tools])
97+
(sequential? disabled-tools) (some->> disabled-tools
98+
(keep (fn [entry]
99+
(let [s (str entry)]
100+
(when-not (string/blank? s) s))))
101+
vec
102+
not-empty)
103+
:else (do
104+
(logger/warn logger-tag (format "Ignoring malformed disabledTools value: %s" (pr-str disabled-tools)))
105+
nil)))
106+
89107
(defn ^:private md->agent-config
90-
[{:keys [description mode model steps tools body inherit spawnableBy]}]
108+
[{:keys [description mode model steps tools body inherit spawnableBy disabledTools]}]
91109
(let [tools-map (normalize-tools tools)
92-
spawnable-by (normalize-spawnable-by spawnableBy)]
110+
spawnable-by (normalize-spawnable-by spawnableBy)
111+
disabled-tools (normalize-disabled-tools disabledTools)]
93112
(cond-> {}
94113
inherit (assoc :inherit (str inherit))
95114
description (assoc :description description)
96115
spawnable-by (assoc :spawnableBy spawnable-by)
116+
disabled-tools (assoc :disabledTools disabled-tools)
97117
mode (assoc :mode (if (sequential? mode)
98118
(mapv str mode)
99119
(str mode)))

src/eca/features/tools.clj

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
[eca.metrics :as metrics]
2525
[eca.shared :refer [assoc-some] :as shared])
2626
(:import
27-
[java.util Map]))
27+
[java.util Map]
28+
[java.util.regex PatternSyntaxException]))
2829

2930
(set! *warn-on-reflection* true)
3031

@@ -117,9 +118,30 @@
117118
(get-in config [:agent agent-name :disabledTools] [])
118119
[]))))
119120

121+
(defn ^:private disabled-entry-matches?
122+
"Matches a `disabledTools` entry against a tool, checking in order:
123+
1. Entry as anchored regex against a eca builtin tool name (no `eca__` prefix needed).
124+
2. Entry as exact server name, disabling all tools of that server.
125+
3. Entry as anchored regex against the tool full name `server__tool`.
126+
Invalid regexes fall back to literal equality."
127+
[entry tool]
128+
(let [server-name (:name (:server tool))
129+
tool-name (:name tool)
130+
full-name (str server-name "__" tool-name)
131+
pattern (try (re-pattern entry)
132+
(catch PatternSyntaxException _ nil))]
133+
(boolean
134+
(or (and (= "eca" server-name)
135+
(if pattern
136+
(re-matches pattern tool-name)
137+
(= entry tool-name)))
138+
(= entry server-name)
139+
(if pattern
140+
(re-matches pattern full-name)
141+
(= entry full-name))))))
142+
120143
(defn ^:private tool-disabled? [tool disabled-tools]
121-
(or (contains? disabled-tools (str (:name (:server tool)) "__" (:name tool)))
122-
(contains? disabled-tools (:name tool))))
144+
(boolean (some #(disabled-entry-matches? % tool) disabled-tools)))
123145

124146
(defn make-tool-status-fn
125147
"Returns a function that marks tools as disabled based on config and agent.

test/eca/features/agents_test.clj

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,33 @@
175175
(:spawnableBy (#'agents/md->agent-config {:description "partially valid"
176176
:spawnableBy ["duel" 42 nil]})))))
177177

178+
(testing "disabledTools list is normalized to a vector of strings"
179+
(let [md (str "---\n"
180+
"description: K8s agent\n"
181+
"disabledTools:\n"
182+
" - clojure-mcp\n"
183+
" - eca__shell_command\n"
184+
" - \".*_file\"\n"
185+
"---\n\n"
186+
"Body.")
187+
parsed (shared/parse-md md)
188+
config (#'agents/md->agent-config parsed)]
189+
(is (= ["clojure-mcp" "eca__shell_command" ".*_file"]
190+
(:disabledTools config)))))
191+
192+
(testing "disabledTools single string becomes a vector"
193+
(is (= ["clojure-mcp"]
194+
(:disabledTools (#'agents/md->agent-config {:description "a"
195+
:disabledTools "clojure-mcp"})))))
196+
197+
(testing "omitted, empty or malformed disabledTools is ignored"
198+
(is (nil? (:disabledTools (#'agents/md->agent-config {:description "a"}))))
199+
(is (nil? (:disabledTools (#'agents/md->agent-config {:description "a" :disabledTools []}))))
200+
(is (nil? (:disabledTools (#'agents/md->agent-config {:description "a" :disabledTools 42}))))
201+
(is (= ["ok"]
202+
(:disabledTools (#'agents/md->agent-config {:description "a"
203+
:disabledTools ["ok" "" nil]})))))
204+
178205
(testing "tools as a YAML list normalizes to byDefault=ask + allow map (Claude form)"
179206
(let [md (str "---\n"
180207
"description: Reviewer\n"

test/eca/features/tools_test.clj

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,48 @@
152152
(is (= #{"plan_tool"}
153153
(#'f.tools/get-disabled-tools config "plan"))))))
154154

155+
(deftest disabled-tools-matching-test
156+
(let [db {:mcp-clients {"clojureMCP"
157+
{:version "1.0.2"
158+
:tools [{:name "eval" :description "eval code" :parameters {}}
159+
{:name "sync_deps" :description "sync deps" :parameters {}}]}}}
160+
full-names (fn [config] (set (map :full-name (f.tools/all-tools "123" "code" db config))))]
161+
(testing "exact builtin tool short name (existing behavior)"
162+
(let [names (full-names {:disabledTools ["edit_file"]})]
163+
(is (not (contains? names "eca__edit_file")))
164+
(is (contains? names "eca__read_file"))))
165+
(testing "exact full name (existing behavior)"
166+
(let [names (full-names {:disabledTools ["clojureMCP__eval"]})]
167+
(is (not (contains? names "clojureMCP__eval")))
168+
(is (contains? names "clojureMCP__sync_deps"))))
169+
(testing "regex against builtin tool short names"
170+
(let [names (full-names {:disabledTools [".*_file"]})]
171+
(is (not (contains? names "eca__edit_file")))
172+
(is (not (contains? names "eca__write_file")))
173+
(is (not (contains? names "eca__move_file")))
174+
(is (not (contains? names "eca__read_file")))))
175+
(testing "server name disables all tools of that server"
176+
(let [names (full-names {:disabledTools ["clojureMCP"]})]
177+
(is (not (contains? names "clojureMCP__eval")))
178+
(is (not (contains? names "clojureMCP__sync_deps")))
179+
(is (contains? names "eca__read_file"))))
180+
(testing "regex against full names"
181+
(let [names (full-names {:disabledTools ["clojureMCP.*"]})]
182+
(is (not (contains? names "clojureMCP__eval")))
183+
(is (not (contains? names "clojureMCP__sync_deps")))
184+
(is (contains? names "eca__read_file"))))
185+
(testing "bare MCP tool name does not match"
186+
(let [names (full-names {:disabledTools ["eval"]})]
187+
(is (contains? names "clojureMCP__eval"))))
188+
(testing "invalid regex is treated literally and does not throw"
189+
(let [names (full-names {:disabledTools ["eval(" "clojureMCP__eval"]})]
190+
(is (not (contains? names "clojureMCP__eval")))
191+
(is (contains? names "clojureMCP__sync_deps"))))
192+
(testing "server name works per agent"
193+
(let [names (full-names {:agent {"code" {:disabledTools ["clojureMCP"]}}})]
194+
(is (not (contains? names "clojureMCP__eval")))
195+
(is (contains? names "eca__read_file"))))))
196+
155197
(deftest approval-test
156198
(let [read-tool {:name "read" :server {:name "eca"} :origin :native}
157199
write-tool {:name "write" :server {:name "eca"} :origin :native}

0 commit comments

Comments
 (0)