Tools#

ECA supports 3 types of tools:
- Native tools: (edit_file, write_file, read, etc)
- MCP servers: if any configured
- User custom tools: if defined
MCP#
For MCP servers configuration, use the mcpServers config, examples:
{
"mcpServers": {
"memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
// optional
"env": {"FOO": "bar"}
}
}
}
ECA supports OAuth authentication automatically via MCP spec discovery.
{
"mcpServers": {
"cool-mcp": {
"url": "https://my-remote-mcp.com/mcp"
}
}
}
Some providers (e.g. Databricks) require a pre-registered OAuth application
and don't support dynamic client registration. Use clientId with the
client ID from your provider's OAuth app settings.
{
"mcpServers": {
"databricks-sql": {
"url": "https://my-workspace.cloud.databricks.com/api/2.0/mcp/sql",
"clientId": "<your-oauth-app-client-id>"
}
}
}
Some providers (e.g. Slack MCP) require confidential OAuth with both a
clientId and clientSecret, and require HTTPS pre-registered redirect URIs.
Setup steps:
- Create a Slack App at api.slack.com/apps
- Enable MCP under Features > Agents & AI Apps
- Under OAuth & Permissions > Redirect URLs, add
https://localhost:19284/auth/callback - Add the user scopes you need (e.g.
search:read.public,channels:history) - Copy the credentials from Settings > Basic Information > App Credentials
When oauthPort is set, ECA uses a bundled localhost certificate to serve
HTTPS on the callback. On first authorization, your browser will show a
certificate warning — click Advanced → Proceed to complete the flow.
{
"mcpServers": {
"slack": {
"url": "https://mcp.slack.com/mcp",
"clientId": "<your-slack-app-client-id>",
"clientSecret": "<your-slack-app-client-secret>",
"oauthPort": 19284
}
}
}
For servers that accept a static token (e.g. a personal access token),
set the Authorization header directly. This skips OAuth entirely.
{
"mcpServers": {
"my-api": {
"url": "https://my-remote-mcp.com/mcp",
"headers": {
"Authorization": "Bearer ${env:MY_API_TOKEN}"
}
}
}
}
You.com provides web search, URL content extraction, and AI-powered research via MCP.
Option 1: Authenticated (full features)
Get your API key from you.com/api and set it as YDC_API_KEY environment variable:
{
"mcpServers": {
"you-search": {
"url": "https://api.you.com/mcp",
"headers": {
"Authorization": "Bearer ${env:YDC_API_KEY}"
}
}
}
}
Option 2: Keyless (basic web search)
Try You.com search without an API key using the free profile:
{
"mcpServers": {
"you-search-free": {
"url": "https://api.you.com/mcp?profile=free"
}
}
}
Option 3: Specialized profiles
Use focused endpoints for specific domains:
{
"mcpServers": {
"you-finance": {
"url": "https://api.you.com/mcp?tools=you-finance",
"headers": {
"Authorization": "Bearer ${env:YDC_API_KEY}"
}
},
"you-docs": {
"url": "https://you.com/docs/_mcp/server"
}
}
}
Available tools include you_search (web search with citations), you_url_contents (webpage content extraction), and you_research (multi-step research workflows).
Some OAuth-protected MCP servers allowlist clients during Dynamic Client
Registration (DCR) by client_name. If the server rejects ECA's default
registration with a 403, set clientName to a value the server accepts.
{
"mcpServers": {
"figma": {
"url": "https://mcp.figma.com/mcp",
"clientName": "Claude Code"
}
}
}
The DCR attempt, its result and the chosen client_name are logged at
info/warn level so you can verify behavior in the ECA log.
By default a server's OAuth token is shared across every project, which is
convenient when the same account is used everywhere. If you sign in to a
different account (e.g. a different Linear workspace) per project, the
shared token would otherwise be clobbered. Set authScope to control this:
global(default): one token shared across all projects.workspace: a separate token per workspace folder set.- any other value: a named bucket shared by every project using that value.
{
"mcpServers": {
"linear": {
"url": "https://mcp.linear.app/mcp",
"authScope": "workspace"
}
}
}
Like other config values, authScope supports the full dynamic-string
interpolation (${env:...}, ${file:...}, ${cmd:...}, ${netrc:...},
${classpath:...}), e.g. "authScope": "${env:LINEAR_ORG}".
Set "disabled": true to keep the configuration but prevent ECA from starting the server.
{
"mcpServers": {
"memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"disabled": true
}
}
}
Custom Tools#
You can define your own command-line tools that the LLM can use. These are configured via the customTools key in your config.json.
The customTools value is an object where each key is the name of your tool. Each tool definition has the following properties:
description: A clear description of what the tool does. This is crucial for the LLM to decide when to use it.command: An string representing the command and its static arguments.schema: An object that defines the parameters the LLM can provide.properties: An object where each key is an argument name.required: An array of required argument names.
Placeholders in the format {{argument_name}} within the command string will be replaced by the values provided by the LLM.
{
"customTools": {
"web-search": {
"description": "Fetches the content of a URL and returns it in Markdown format.",
"command": "trafilatura --output-format=markdown -u {{url}}",
"schema": {
"properties": {
"url": {
"type": "string",
"description": "The URL to fetch content from."
}
},
"required": ["url"]
}
}
}
}
{
"customTools": {
"file-search": {
"description": "${file:tools/my-tool.md}",
"command": "find {{directory}} -name {{pattern}}",
"schema": {
"properties": {
"directory": {
"type": "string",
"description": "The directory to start the search from."
},
"pattern": {
"type": "string",
"description": "The search pattern for the filename (e.g., '*.clj')."
}
},
"required": ["directory", "pattern"]
}
}
}
}
Spawned command environment#
Processes spawned by ECA (shell_command foreground and background commands, the git tool, custom tools, and hooks) run with these extra environment variables, so external tooling (shell history, wrappers, audit scripts) can tell agent-spawned commands from human ones:
| Variable | Value | When |
|---|---|---|
ECA_AGENT |
1 |
Always |
ECA_CHAT_ID |
The id of the chat that triggered the command | When triggered from a chat (shell_command, git, custom tools) |
ECA_EXECUTABLE |
The launch command of the running ECA process | Always |
Detection contract: a non-empty ECA_AGENT means the command was spawned by ECA.
Disabled tools#
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:
- A builtin ECA tool name or regex, no
eca__prefix needed:edit_file,.*_file. - An exact MCP server name, disabling all tools of that server:
clojure-mcp. - A regex matched against the tool full name
server__tool:clojure-mcp__eval.*,my-mcp__dangerous_tool.
Regexes must match the whole name (anchored). It can be set globally, per agent or in the agent markdown frontmatter.
{
"disabledTools": ["eca__shell_command", "my-mcp__dangerous_tool"]
}
{
"agent": {
"plan": {
"disabledTools": ["eca__edit_file", "eca__write_file", "eca__move_file"]
}
}
}
{
"mcpServers": {
"clojure-mcp": {"command": "..."}
},
"agent": {
"plan": {
"disabledTools": ["clojure-mcp"]
}
}
}
Disabled vs Denied
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.nullby 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 — an exact MCP server name (all its tools) or an anchored regex against the tool full name server__tool.
{
"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.
{
"mcpToolSearch": {
"includePattern": [".*"]
}
}
{
"mcpToolSearch": {
"includePattern": [".*"],
"excludePattern": ["clojure-mcp"]
}
}
{
"mcpToolSearch": {
"includePattern": ["some-mcp__.*"]
}
}
{
"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. 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.
Native tools are never deferred
Only MCP tools can be deferred. ECA's native tools are the agent's baseline capabilities, so a catch-all ".*" never takes them away. Use disabledTools to remove a native tool. They are also left out of the deferAllWhenTotalTokensExceedPercentOfContext total, so the limit tracks what MCP actually adds.
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.
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), but that can easily be configured in several ways via the toolCall.approval config:
byDefault:"ask","allow"or"deny", used when no rule matches. Default:"ask".deny,askandallow: maps of tool selector to an optionalargsMatchers.
Tip
Approval rules are enforced by the ECA process itself. For OS-level isolation on top of them, check Sandboxing.
Check some examples:
{
"toolCall": {
"approval": {
"byDefault": "allow"
}
}
}
{
"toolCall": {
"approval": {
"byDefault": "allow",
"ask": {
"eca__edit_file": {},
"my-mcp__my_tool": {}
}
}
}
}
{
"toolCall": {
"approval": {
// "byDefault": "ask", not needed as it's eca default
"allow": {
"eca": {},
"my-mcp": {}
}
}
}
}
argsMatchers is a map of argument name by list of java regex.
{
"toolCall": {
"approval": {
"byDefault": "allow",
"ask": {
"shell_command": {"argsMatchers": {"command": [".*rm.*",
".*mv.*"]}}
}
}
}
}
{
"toolCall": {
"approval": {
"byDefault": "allow",
"deny": {
"shell_command": {"argsMatchers": {"command": [".*rm.*",
".*mv.*"]}}
}
}
}
}
Also check the plan agent which is safer.
The manualApproval setting was deprecated and replaced by the approval one without breaking changes
How ECA decides: rule precedence#
For each tool call, ECA checks the following in order, and the first match wins:
denyrules: always win, even over session-remembered approvals and trust mode.- Session-remembered approvals: what you approved with "approve and remember for this session" (below).
- Tool built-in checks: some native tools force asking in risky cases regardless of
allowrules, e.g. filesystem tools andshell_commandwhen the path or working directory is outside the workspace roots. askrules.allowrules.- Legacy
manualApprovalconfig. byDefault:askwhen not set.
Trust mode is applied after that: it promotes an ask result to auto-allow but never overrides deny.
Agent rules replace global rules
agent.<name>.toolCall.approval does not deep-merge with the global toolCall.approval at runtime: each key present (allow, ask, deny or byDefault) entirely replaces the global one. Since the builtin plan and explorer agents define their own allow and deny (default rules), global allow entries you add are ignored while using those agents — configure agent.plan.toolCall.approval.allow too if you need them there.
Tool selectors#
The keys of allow, ask and deny are matched by exact name (no regex or glob, unlike disabledTools), in one of 3 forms:
| Selector | Matches |
|---|---|
server__tool: eca__shell_command, my-mcp__my_tool |
that specific tool |
builtin tool name: shell_command |
the ECA native tool, the eca__ prefix is optional for them |
server name: my-mcp, eca |
all tools of that server |
For MCP tools the server__ prefix is required: a bare MCP tool name like my_tool is treated as a server name and will match nothing — use my-mcp__my_tool.
Matching arguments with argsMatchers#
argsMatchers is a map of argument name by list of java regexes tested against the argument value:
- Regexes are anchored, they must match the whole argument value:
"rm"does not matchrm -rf foo, use".*\\brm\\b.*". - The rule matches when any regex of any listed argument matches.
- A rule without
argsMatchersmatches every call of that tool. - Matchers of an argument absent in the call never match.
- A
denyrule withoutargsMatchersalso hides the tool from the LLM entirely; withargsMatchersthe tool stays visible and only matching calls are rejected.
Chained and piped shell commands#
Config argsMatchers are tested against the full command string — there is no per-command splitting: for cat file | grep foo, an allow regex must match that whole string. That's why deny patterns are usually written like ".*\\b(rm|mv|cp)\\b.*". Splitting a chain into individual commands only happens for session remember.
Approve & remember for this session#
When you approve a tool call choosing "approve and remember for this session", ECA whitelists that tool by name: future calls of it are auto-approved. Remembered approvals live in memory only — they apply to all chats of the running ECA process, are not written to any config file, and are lost on restart. deny rules still win over them.
Granular approve & remember for shell commands#
When you approve a eca__shell_command or eca__git tool call choosing "approve and remember", ECA remembers the approved commands for the session instead of whitelisting the whole tool: the command string is parsed and each command in a chain (&&, ||, ;, |) produces a key — command + subcommand for multi-command tools (git checkout, npm install), the plain command otherwise (rg). A future shell call runs automatically only when all its commands were already remembered.
ECA fails closed: commands it cannot safely reason about always ask again, like command/process substitution ($(...), backticks), subshells, heredocs, output redirections to files (except /dev/null), and wrapper commands that execute arbitrary code (sudo, bash -c, xargs, env, ...).
Trust mode by default#
Trust mode auto-accepts all tool calls in a chat (it never overrides deny). Editors expose a toggle for it, and you can make new chats start trusted for every editor via chat.defaultTrust:
{
"chat": {
"defaultTrust": true
}
}
Default approval rules#
Globally ECA allows its read-only builtin tools and asks for everything else:
{
"toolCall": {
"approval": {
"byDefault": "ask",
"allow": {
"eca__compact_chat": {},
"eca__preview_file_change": {},
"eca__read_file": {},
"eca__directory_tree": {},
"eca__grep": {},
"eca__editor_diagnostics": {},
"eca__skill": {},
"eca__task": {},
"eca__ask_user": {},
"eca__fetch_rule": {},
"eca__spawn_agent": {}
}
}
}
}
The builtin plan and explorer agents replace these with stricter rules: allow only covers the read-only builtin tools plus read-only shell commands (pwd, git diff/log/show, find, ls), and deny blocks dangerous shell patterns (file mutations like rm/mv/cp/touch/mkdir, output redirections, pipes to tee/dd/xargs, in-place sed/awk/perl, git add/commit/push, npm install). Check the up-to-date values in config.clj.
Debugging approval rules#
Start the server with --log-level debug (how to per editor) and search the logs for Tool call approval decision, logged on every tool call with the decision and which rule caused it:
[CHAT] Tool call approval decision {:decision :deny, :rule {:source :config-deny, :selector "eca__shell_command", :args-matcher ".*\\b(rm|mv|cp|touch|mkdir)\\b.*"}, :tool "eca__shell_command", :agent "plan"}
:source |
The decision came from |
|---|---|
:config-deny, :config-ask, :config-allow |
your approval rules; :selector and :args-matcher show the exact rule |
:session-remember |
an "approve and remember for this session" approval |
:tool-built-in-check |
the tool itself, e.g. path outside the workspace roots |
:legacy-manual-approval |
the deprecated manualApproval config |
:by-default |
the byDefault config |
:fallback |
nothing matched (likely a config error), falls back to ask |
Common reasons ECA still asks after you added an allow rule:
- The regex must match the whole argument: use
"grep(\\s+.*)?"instead of"grep". - Allowing
eca__grepdoesn't covergreprun througheca__shell_command— they are different tools. - You are on the
plan/exploreragent (or a custom one) whoseallowreplaces the global one. - The path or working directory is outside the workspace roots (
:tool-built-in-check). - Chained commands: the regex must match the full chained string, e.g.
cat file | grep foo.