Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Tool prompts: push, git config, shell search/read and sub-agents run on explicit request; rule headings are tool-scoped.
- Recover Anthropic streaming responses interrupted by transient TLS `bad_record_mac` failures.
- BREAKING: `plugins.install` now appends across config layers. Set `plugins.installMode` to `replace` beside the list to exclude inherited plugins as before.
- Add `mcpToolSearch` config to keep MCP tool schemas out of context until the LLM loads them with the new `eca__search_tools` tool.

## 0.159.0

Expand Down
41 changes: 41 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@
"type": "string"
}
},
"mcpToolSearch": {
"$ref": "#/definitions/mcpToolSearch"
},
"commands": {
"type": "array",
"description": "Custom command prompt files.",
Expand Down Expand Up @@ -1006,6 +1009,9 @@
"type": "string"
}
},
"mcpToolSearch": {
"$ref": "#/definitions/mcpToolSearch"
},
"autoCompactPercentage": {
"type": "integer",
"description": "Context window usage percentage at which auto-compact triggers for this agent.",
Expand Down Expand Up @@ -1198,6 +1204,41 @@
},
"additionalProperties": false
},
"mcpToolSearch": {
"type": "object",
"description": "Keeps matching MCP tools out of the LLM context until it loads them with the eca__search_tools tool. Deferred tools are still listed by name and short description in the system prompt. ECA native tools are never deferred.",
"markdownDescription": "Keeps matching MCP tools out of the LLM context until it loads them with the `eca__search_tools` tool. Deferred tools are still listed by name and short description in the system prompt. ECA native tools are never deferred.",
"properties": {
"deferAllWhenTotalTokensExceedPercentOfContext": {
"type": ["number", "null"],
"description": "Percentage of the model context window the MCP tool definitions may take before all of them are put behind the search tool. For example 10 defers them once they exceed 10% of the context window. Null (the default) never defers automatically, leaving includePattern in control.",
"markdownDescription": "Percentage of the model context window the MCP tool definitions may take before all of them are put behind the search tool. For example `10` defers them once they exceed 10% of the context window. `null` (the default) never defers automatically, leaving `includePattern` in control.",
"default": null,
"minimum": 0,
"maximum": 100,
"examples": [10, 5]
},
"includePattern": {
"type": "array",
"description": "MCP tools to put behind the search tool regardless of deferAllWhenTotalTokensExceedPercentOfContext. Each entry matches an exact MCP server name (all its tools) or an anchored regex against the tool full name server__tool.",
"markdownDescription": "MCP tools to put behind the search tool regardless of `deferAllWhenTotalTokensExceedPercentOfContext`. Each entry matches an exact MCP server name (all its tools) or an anchored regex against the tool full name `server__tool`.",
"items": {
"type": "string"
},
"examples": [[".*"], ["some-mcp__.*"]]
},
"excludePattern": {
"type": "array",
"description": "MCP tools to keep loaded, taking precedence over both deferAllWhenTotalTokensExceedPercentOfContext and includePattern. Same matching as includePattern.",
"markdownDescription": "MCP tools to keep loaded, taking precedence over both `deferAllWhenTotalTokensExceedPercentOfContext` and `includePattern`. Same matching as `includePattern`.",
"items": {
"type": "string"
},
"examples": [["some-mcp"], ["some-mcp__.*"]]
}
},
"additionalProperties": false
},
"toolCallConfig": {
"type": "object",
"description": "Tool call configuration including approval rules and tool-specific settings.",
Expand Down
32 changes: 32 additions & 0 deletions docs/config/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ Subagents can be configured in config or markdown and support/require these fiel
- `variant` (optional): default model variant; ignored when unavailable for the selected model. See [Variants](variants.md#agent-default-variant).
- `tools` (optional): same as ECA tool approval logic to control what tools are allowed/askable/denied.
- `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`.
- `mcpToolSearch` (optional): MCP tools this agent loads on demand via `eca__search_tools` instead of keeping in context. See [MCP tool search](#mcp-tool-search) below.
- `maxSteps` (optional): set a max limit of turns/steps that his subagent must finish and return an answer.

### Parent-scoped subagents
Expand All @@ -135,6 +136,37 @@ spawnableBy:

Matching uses exact resolved agent IDs. Markdown agent IDs and Markdown `spawnableBy` values are trimmed and lowercased during loading; JSON configuration values are matched against the configured agent keys exactly.

### MCP tool search

`mcpToolSearch` mirrors the [config object](tools.md#mcp-tool-search) as a YAML mapping, so an agent can keep MCP tools out of its context until it loads them with `eca__search_tools`:

```yaml
---
description: Reviews pull requests
mcpToolSearch:
deferAllWhenTotalTokensExceedPercentOfContext: 10
includePattern:
- ".*"
excludePattern:
- github__get_pull_request
---
```

Since deferring without exclusions is the common case, a bare list (or a single string) is shorthand for `includePattern`:

```yaml
mcpToolSearch:
- ".*"
```

```yaml
mcpToolSearch: some-mcp__.*
```

!!! note "Agent patterns add to the global ones"

Both lists are unioned with the global config rather than replacing it, so an agent can defer more tools or exclude more tools, but cannot re-load a tool the global `includePattern` deferred. Use `excludePattern` on the agent for that.

When `spawnableBy` is omitted or empty, the subagent is unrestricted, preserving the default behavior. When it contains IDs:

- only a listed current primary agent sees the subagent in the `spawn_agent` tool description, `/subagents`, and contextual diagnostics such as the built-in `eca-info` skill;
Expand Down
5 changes: 5 additions & 0 deletions docs/config/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ By default ECA consider the following as the base configuration:
"commands" : [],
"skills": [],
"disabledTools": [],
"mcpToolSearch": {
"deferAllWhenTotalTokensExceedPercentOfContext": null,
"includePattern": [],
"excludePattern": []
},
"toolCall": {
"approval": {
"byDefault": "ask",
Expand Down
92 changes: 92 additions & 0 deletions docs/config/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,98 @@ Regexes must match the whole name (anchored). It can be set globally, per agent

`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.

## MCP tool search

Every tool sent to the LLM costs context: its description and full input schema are part of each request. With a few MCP servers connected that easily adds up to thousands of tokens the model rarely needs.

MCP tool search trades that upfront cost for an extra round trip. Matching tools are *deferred*: their schemas are **not** sent to the model, only a compact catalog of names and truncated descriptions in the system prompt. When the model needs one, it calls the `eca__search_tools` tool, which loads the matching tools — from then on they are sent as regular tools and can be called normally.

Loading is per chat and sticks for the rest of it, including every follow-up request ECA makes while the model works through a chain of tool calls. Tools you never search for stay withheld for the whole conversation.

This is configured via `mcpToolSearch`, and is off until you turn it on:

- `deferAllWhenTotalTokensExceedPercentOfContext`: defer **all** MCP tools once their definitions outgrow this percentage of the model's context window. `null` by default, meaning never.
- `includePattern`: MCP tools to put behind the search tool regardless of that limit.
- `excludePattern`: MCP tools to keep loaded, taking precedence over both.

So a tool is deferred when it is over the automatic limit **or** matches `includePattern`, and does not match `excludePattern`.

The two patterns use the same matching as [`disabledTools`](#disabled-tools) — an exact MCP server name (all its tools) or an anchored regex against the tool full name `server__tool`.

=== "Defer once MCP gets expensive"

```javascript title="~/.config/eca/config.json"
{
"mcpToolSearch": {
"deferAllWhenTotalTokensExceedPercentOfContext": 10
}
}
```

On a 200k model this defers every MCP tool once their definitions pass ~20k tokens, and leaves them loaded below that. Percentage rather than a fixed token count so the same setting behaves sensibly on a 32k local model and a 1M model.

=== "Defer all MCP tools"

```javascript title="~/.config/eca/config.json"
{
"mcpToolSearch": {
"includePattern": [".*"]
}
}
```

=== "Defer all but one MCP server"

```javascript title="~/.config/eca/config.json"
{
"mcpToolSearch": {
"includePattern": [".*"],
"excludePattern": ["clojure-mcp"]
}
}
```

=== "Defer one noisy MCP server"

```javascript title="~/.config/eca/config.json"
{
"mcpToolSearch": {
"includePattern": ["some-mcp__.*"]
}
}
```

=== "Per agent"

```javascript title="~/.config/eca/config.json"
{
"agent": {
"plan": {
"mcpToolSearch": {
"includePattern": [".*"],
"excludePattern": ["some-mcp__read_.*"]
}
}
}
}
```

Both lists are merged from the global config and the agent config, and everything here can also be set in the [agent markdown frontmatter](agents.md#mcp-tool-search). `deferAllWhenTotalTokensExceedPercentOfContext` is a single value rather than a list, so an agent's value replaces the global one; set it to `null` on the agent to opt that agent out.

!!! info "Native tools are never deferred"

Only MCP tools can be deferred. ECA's [native tools](../features.md#native-tools) are the agent's baseline capabilities, so a catch-all `".*"` never takes them away. Use [`disabledTools`](#disabled-tools) to remove a native tool. They are also left out of the `deferAllWhenTotalTokensExceedPercentOfContext` total, so the limit tracks what MCP actually adds.

!!! note "Models without a known context window"

`deferAllWhenTotalTokensExceedPercentOfContext` needs the model's context window to compute a budget. When ECA does not know it, nothing is deferred automatically — use `includePattern` if you want deferral on such a model.

`eca__search_tools` is only offered to the model when at least one tool is actually deferred.

!!! tip "Disabled vs Deferred"

`disabledTools` makes a tool unusable. `mcpToolSearch` keeps it fully usable, it just costs the model one `eca__search_tools` call to load it.

## Approval / permissions

By default, ECA asks to call any non read-only tool (check the [default rules](#default-approval-rules)), but that can easily be configured in several ways via the `toolCall.approval` config:
Expand Down
69 changes: 57 additions & 12 deletions integration-test/integration/chat/mcp_remote_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,31 @@
(def ^:private mcp-server-config
{:mcpServers {"test-mcp" {:url (str "http://localhost:" mcp-mock/port "/mcp")}}})

(defn ^:private init-with-mcp-remote! []
(eca/start-process!)
(mcp-mock/reset-requests!)
(eca/request! (fixture/initialize-request
{:initializationOptions
(merge fixture/default-init-options mcp-server-config)}))
(eca/notify! (fixture/initialized-notification)))
(defn ^:private init-with-mcp-remote!
([] (init-with-mcp-remote! nil))
([extra-config]
(eca/start-process!)
(mcp-mock/reset-requests!)
(eca/request! (fixture/initialize-request
{:initializationOptions
(merge fixture/default-init-options mcp-server-config extra-config)}))
(eca/notify! (fixture/initialized-notification))))

(defn ^:private await-mcp-running! []
(eca/client-awaits-server-notification :tool/serverUpdated) ;; native
(eca/client-awaits-server-notification :tool/serverUpdated) ;; mcp starting
(eca/client-awaits-server-notification :tool/serverUpdated)) ;; mcp running

(defn ^:private drain-chat-until-finished!
"Consumes chat notifications until the turn finishes. Used when a test asserts
on what reached the LLM rather than on the notification sequence itself."
[]
(loop [remaining 50]
(when (pos? remaining)
(let [{:keys [content]} (eca/client-awaits-server-notification :chat/contentReceived)]
(when-not (and (= "progress" (:type content))
(= "finished" (:state content)))
(recur (dec remaining)))))))

(deftest mcp-remote-server-connects
(init-with-mcp-remote!)
Expand Down Expand Up @@ -55,11 +73,7 @@

(deftest mcp-remote-tool-call-in-chat
(init-with-mcp-remote!)

;; Wait for MCP server to be ready
(eca/client-awaits-server-notification :tool/serverUpdated) ;; native
(eca/client-awaits-server-notification :tool/serverUpdated) ;; mcp starting
(eca/client-awaits-server-notification :tool/serverUpdated) ;; mcp running
(await-mcp-running!)

(testing "LLM invokes an MCP tool and ECA processes it"
(mcp-mock/reset-requests!)
Expand Down Expand Up @@ -149,6 +163,37 @@
{:name "testMcp__add"}])}
req-body)))))))

(deftest mcp-deferred-tools-stay-deferred-across-the-tool-call-loop
;; `echo` is excluded from deferral so the mocked tool call targets a tool the
;; LLM can actually see; every other testMcp tool sits behind eca__search_tools.
(init-with-mcp-remote! {:mcpToolSearch {:includePattern ["testMcp__.*"]
:excludePattern ["testMcp__echo"]}})
(await-mcp-running!)

(testing "the continuation request withholds deferred tools, like the first one"
(mcp-mock/reset-requests!)
(llm.mocks/set-case! :mcp-tool-call-0)

(eca/request! (fixture/chat-prompt-request
{:model "anthropic/claude-sonnet-4-6"
:message "Call the echo tool"}))
(drain-chat-until-finished!)

;; get-req-body keeps the last body, which here is the request ECA sent
;; after running the tool - the one that used to re-send every tool.
(let [tool-names (->> (llm.mocks/get-req-body :mcp-tool-call-0)
:tools
(map :name)
set)]
(is (contains? tool-names "testMcp__echo")
"excludePattern keeps echo loaded")
(is (contains? tool-names "eca__search_tools")
"search tool is offered while something is deferred")
(is (not (contains? tool-names "testMcp__add"))
"deferred tools must not come back in the tool-call loop")
(is (not (contains? tool-names "testMcp__add-tool"))
"deferred tools must not come back in the tool-call loop"))))

(deftest mcp-remote-instructions-in-prompt
(init-with-mcp-remote!)

Expand Down
10 changes: 10 additions & 0 deletions resources/prompts/tools/search_tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Load deferred tools so you can call them.

Some tools are deferred: they are listed by name and short description in the "Deferred Tools" section of your system prompt, but their full descriptions and input schemas are not loaded, so you cannot call them yet.

Use this tool to load the ones you need:
- Search with keywords describing the capability you want (e.g. "create pull request", "query database"), not the exact tool name.
- Omit `query` to list every deferred tool.
- Matches are loaded immediately and become callable from your next message onward, with their full input schemas returned here.
- Prefer a single search with a focused query over many searches; each loaded tool consumes context.
- If a deferred tool looks relevant to the task, load it before concluding the capability is unavailable.
6 changes: 6 additions & 0 deletions src/eca/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@
"eca__grep" {}
"eca__editor_diagnostics" {}
"eca__skill" {}
"eca__search_tools" {}
"eca__task" {}
"eca__fetch_rule" {}
"eca__spawn_agent" {}}
Expand All @@ -210,6 +211,7 @@
"eca__grep" {}
"eca__editor_diagnostics" {}
"eca__skill" {}
"eca__search_tools" {}
"eca__task" {}
"eca__fetch_rule" {}}
:deny {"eca__shell_command"
Expand Down Expand Up @@ -238,6 +240,9 @@
:skills []
:extraConfigs []
:disabledTools []
:mcpToolSearch {:deferAllWhenTotalTokensExceedPercentOfContext nil
:includePattern []
:excludePattern []}
:toolCall {:approval {:byDefault "ask"
:allow {"eca__compact_chat" {}
"eca__preview_file_change" {}
Expand All @@ -246,6 +251,7 @@
"eca__grep" {}
"eca__editor_diagnostics" {}
"eca__skill" {}
"eca__search_tools" {}
"eca__task" {}
"eca__ask_user" {}
"eca__fetch_rule" {}
Expand Down
Loading
Loading