Tools and MCP
A tool is a name, a description, a JSON Schema, and an async function. The registry holds them; MCP servers and native Rust functions both land there as the same trait object, so the agent loop never learns where a tool came from. That is the invariant worth protecting — both the tool and the provider are trait objects, and if the loop starts matching on either, something has leaked.
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn input_schema(&self) -> Value;
/// Read-only tools skip the approval gate and are safe to run in parallel.
fn read_only(&self) -> bool { false }
/// Declared risk surface.
fn capabilities(&self) -> Capabilities { Capabilities::default() }
async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput>;
}
Expected failures are results, not errors
// The model can recover from this.
Ok(ToolOutput::err("cannot read notes/missing.md: No such file or directory"))
// Reserve this for what the model cannot route around.
Err(anyhow!("..."))
ToolOutput { is_error: true } reaches the model as a tool_result with
is_error set, so it can try a different path, a different tool, or tell you
what is missing. Err propagates out of the loop and ends the run.
Almost everything is the first kind. fs_read on a missing file, fs_edit
whose old string appears zero or five times, a shell command that exits
non-zero, an MCP server whose transport died — all of these come back as
recoverable results. fs_edit refuses an ambiguous match outright rather than
guessing, because silently editing the wrong line is worse than a retry:
`old` appears 3 times; include more surrounding context to make it unique
There is a third field on ToolOutput, and it is not the same as the
capability:
pub struct ToolOutput {
pub content: String,
pub is_error: bool,
/// True when this content actually came from outside the machine.
pub external: bool,
}
Capabilities::untrusted_input says what a tool can return.
ToolOutput::external says whether this particular result actually came from
outside. Taint and untrusted-marking key off external, not the capability —
otherwise a refusal generated by mecha's own guard gets labelled third-party
content and the model starts inventing explanations for its own harness. Any
tool that reaches the network must call .from_outside().
Capabilities
Four axes, three of which are the lethal trifecta:
pub struct Capabilities {
pub private_data: bool, // returns data the user considers private
pub untrusted_input: bool, // returns content a third party can influence
pub egress: Egress, // whether data leaves — and who picks where
pub destructive: bool, // may destroy or overwrite data
}
pub enum Egress {
None, // nothing leaves
Blind, // it leaves, but only to a destination your config fixed
Chosen, // the model names the recipient: a url, a `to`, a channel
}
The loop tracks which of these have entered the conversation and refuses any
Egress::Chosen tool once both private and untrusted are present — an
injection can only be directed somewhere the attacker picks. A Blind tool
such as web_search is left alone by that interlock and refused by
block_sends_after_private instead. Full treatment in
Security.
Capabilities::union is the only combining operation, and it only ever widens.
Letting config narrow a tool's declared capabilities would disarm the
interlock on the strength of a claim nothing enforces — the same mistake as a
sandbox that silently degrades, and it would make the cheapest configuration
the most dangerous one.
Built-in tools
fs_read, fs_write, fs_edit, fs_list, shell, http_fetch,
web_search, and a todo list. No server required — these are ordinary Rust
functions.
| Tool | read_only | Declares |
|---|---|---|
fs_read | yes | private |
fs_list | yes | private |
fs_write | no | destructive |
fs_edit | no | destructive |
shell | no | private, sends, destructive (unconfined) |
http_fetch | yes | untrusted and sends |
web_search | yes | untrusted and sends |
Two of these look wrong until you read the reasoning.
http_fetch is read_only but is still a chosen-egress sink. It is
read-only with respect to your data — it touches nothing on disk — but a GET
is an exfiltration channel, because the payload fits in the query string and
the url argument lets the model pick who reads it. web_search is the
contrast that makes the class worth having: same payload problem, no
destination argument, so it is blind.
shell is not marked as an untrusted source. Taint tracking cannot see
inside a command, so labelling it untrusted would arm the interlock on every
ls. The mitigation is the sandbox, not a label. What confinement does
narrow is egress: with no network there is no way out, so a confined shell
drops to Egress::None and stops being a trifecta sink. private_data stays true regardless — a
confined shell still reads the workspace, and fs_read reads the same files
under the same label. Narrowing it would mean shell: cat secrets sets no
taint where fs_read: secrets does, making the cheapest route around the
interlock the more dangerous tool.
The sandbox policy lives on the Shell tool itself rather than in ToolCtx,
because it decides the tool's capabilities and capabilities() has no
context to consult. The workspace still comes from the context at call time, so
a per-run jail — an eval case's private fixture copy — is what gets mounted.
See Sandbox.
http_fetch additionally refuses loopback, private, link-local (including
169.254.169.254) and CGNAT addresses, does not follow redirects, and pins the
connection to the addresses that passed the check so a TTL-0 DNS answer cannot
rebind between check and connect.
web_search is registered only when a [[search]] backend is configured, and
it is a chain: backends are tried in order and the first that answers wins, so
a rate-limited provider degrades to the next one rather than to nothing. It
carries the same pair of labels as http_fetch and for the same reasons — what
comes back is whatever a stranger published, and a query string is a way out.
todo is planning as a tool rather than as a mode: a list the model rewrites as
it goes stays honest where a plan produced up front goes stale on the first
surprise, and the current state is echoed back in every tool result so the model
re-reads its own plan without anyone re-prompting it.
Additional tools depend on the front end. ask_user can ask a present owner,
or park a delegated task's question for mecha questions answer to resume
later. A generic batch or trigger does not get a blocking terminal question. message_send exists only when [messages] enabled is on and --no-messages was not passed, and it writes to another of
this machine's agents rather than to the outside world.
Which of the built-ins are registered is config, via [tools] enabled /
disabled. --tool on the command line narrows further, and reaches
web_search and message_send as well.
A tool's own state can cross a compaction
/// State this tool holds that a compaction must not lose.
fn carried_state(&self) -> Option<CarriedState> { None }
The todo list reached the model only through the echo in the last todo
result — which is a message, and therefore exactly what a compaction summarises
away. The mechanism was quietly conditional on the transcript never getting
long, in the one situation where a plan matters most.
So a tool may hand state to the compaction to be carried across verbatim. Three rules keep it from becoming a second source of truth:
- It is read at compaction time, so it is current by construction. A stale copy is impossible because nothing stores one.
- Exactly one copy survives. A second compaction replaces the carried block rather than stacking beside it — two contradictory task lists in one prompt are worse than none.
- It is for state the tool owns, not a summary of what happened. A tool returning prose here would be smuggling an unvalidated second summariser into the loop.
The loop learns that some tools have state, never which — the same shape as
everything else here. None is the default and the honest answer for every
stateless tool. See Compaction.
The path jail
Every model-supplied path goes through ToolCtx::resolve before anything
touches the filesystem. Never call fs::* on a raw path from tool input.
let path = ctx.resolve(arg_str(&input, "path")?)?;
resolve joins relative paths against the workspace, canonicalizes the nearest
existing ancestor (the file may not exist yet, for a write), re-appends the
rest, and then proves containment. .., symlinks, and absolute paths outside
the root are all checked after canonicalization, not before.
There is exactly one sanctioned exception: the per-context spill directory,
where oversized tool output is saved in full. The truncation marker tells the
model to read the rest from that path, so fs_read has to be able to follow
it; its contents are the context's own tool results, so nothing new becomes
reachable. A re-rooted context gets a fresh spill directory, because two eval
cases sharing one could read each other's output through it.
Output budgets and spilling
ToolCtx::output_budget_bytes is the byte budget one turn's tool results
share, divided across the calls in the batch so one runaway tool cannot starve
its siblings — mecha executes a turn's calls concurrently, so they land
together. The old per-tool cap was 200 KB, roughly 50k tokens: not a cap so
much as a promise to overflow. Left unset in [tools], the budget derives
from the provider's context_window — an eighth of the window in tokens,
~3 bytes each — because one turn's results must not leap the gap between the
compaction threshold and the window: a flat 24 KB of numeric data is bigger
than that gap at a 32k window, and a benchmark trial died on exactly that
jump.
An oversized result is written whole to the spill directory and its transcript copy is cut, with a marker that names the recovery:
[truncated by the harness: showing the first 24000 of 91234 bytes; the rest
begins on line 812. The full output is saved at /tmp/mecha-spill-.../shell-t1-
9f2c1a04.txt — continue with fs_read {"path": "...", "offset": 812}, or search
it with grep.]
A truncation notice that only says "gone" leaves the model to conclude the rest never existed. A failed spill degrades to a plain cut that admits the loss and says to re-run the tool — losing the tail must never lose the run — and it never promises a path that does not exist.
The registry
pub struct Registry {
tools: BTreeMap<String, Arc<dyn Tool>>,
}
A BTreeMap, and the ordering is load-bearing. specs() returns tool
definitions in that stable order because the tool list is the very front of
the cached prompt prefix — reordering it invalidates the cache on every
request. See Providers.
specs_for(phase) filters to what a phase permits, in the same stable order.
Planning sends a shorter list, which changes the front of the prefix and makes
the next turn re-pay for it; that is the price of the tools being genuinely
absent rather than merely refused, and it is the right trade.
A later insert with the same name replaces the earlier one, so an MCP server
can shadow a built-in deliberately.
Approval
#[async_trait]
pub trait Approver: Send + Sync {
async fn approve(&self, tool: &dyn Tool, input: &Value) -> Decision;
}
Decision::Deny(reason) passes its reason to the model so it can pick another
approach. Approval is sequential — it may block on a human — while execution is
concurrent.
ModeApprover answers from the configured PermissionMode without asking
anyone: allow permits everything, read-only permits read_only() tools and
refuses the rest, and ask denies, because nothing is watching to answer
and the safe reading of a question nobody hears is no.
`fs_write` needs approval and this run is non-interactive (use --yes to allow)
The CLI supplies interactive approvers instead — a terminal prompt for run
and chat, a modal for the TUI.
Configured outbox actions pass the hook gate and then stage for owner review, skipping the interlock and execution approval checks. For executing calls the order is interlock → hook → approval rules → approver. A hook can narrow policy and never loosen security.
[[rule]] entries distinguish
commands inside one tool: allow git status, require a fresh decision for
git push, or forbid a command. prompt refuses when nobody can answer, even
under --yes; allow cannot bypass read-only mode or the interlock.
Policy refusals use Decision::Blocked and are not mined as user corrections.
See Hooks and The outbox.
MCP
mcp.rs is a minimal MCP client over stdio, speaking JSON-RPC 2.0 line by line
to a child process and exposing whatever tools it advertises as ordinary Tool
implementations.
[[mcp]]
name = "graph"
command = "mecha-graph-mcp" # or an absolute path to the binary
prefix_tools = false # its kg_* tools carry their own namespace
env_passthrough = ["MECHA_GRAPH_DB"]
env = { MECHA_TZ = "America/New_York" }
Two things about that command line. A bare name resolves against the
PATH of whatever started mecha, so a systemd unit needs ~/.cargo/bin on
its Environment=PATH (the shipped units carry it); what a failed spawn
then does depends on the command — the front-ends and the corpus commands
that go through the shared setup (serve, slack, triggers, mail classify, frontdoor, validate, learn) report it once on stderr and
carry on with the kg_* tools simply absent, while distill,
corroborate, gossip and vet connect directly and exit non-zero. And
this server is deliberately not confined: sandbox = true replaces
PATH with the system directories (so a bare name can never resolve
there), binds nothing under your home directory unless [sandbox] readable/writable lists it, and the graph's store is
~/.mecha-graph/graph.db, read-write — confining it would need an absolute
command, its directory in readable and the store's in writable, which
is most of the sandbox given away for a server that runs as you anyway.
Tools are namespaced <server>__<tool> by default, so two servers can both
expose a search. A server whose tools already carry their own namespace —
mecha-graph's kg_* family — can
set prefix_tools = false and register them under their raw names. That
setting is a promise of distinct names, and the promise is enforced: an
unprefixed tool that collides with anything already registered fails startup
loudly rather than shadowing it. Protocol version 2025-06-18; each request
has a 120s timeout.
Details that cost something to get right:
- Response ids are accepted however the server spelled them. mecha always sends numeric ids, but JSON-RPC allows strings and real servers echo numbers back as strings. Refusing those would leave every call to time out against a server that is answering.
tools/listis paged. A server with more tools than one page returns anextCursor, and stopping at page one silently shrinks its surface — tools the config counted on simply would not exist. Bounded at 100 pages so a server handing out cursors forever cannot wedge startup.- stderr is a log, not the protocol. It used to inherit mecha's, which
garbles a full-screen front end mid-frame. It now flows through
tracing, tagged with the server's name and visible underMECHA_LOG. - A dead server wakes its callers. When stdout closes, every pending request is released rather than left to time out one by one.
- A transport failure is a result, not an error.
MCP call failed: ...comes back asToolOutput::err— the agent's problem to route around, not a reason to abort the run. - One broken server does not sink the session.
connect_allreports and skips it. (mecha eval --mcp-fileis the exception: there, a failure is fatal, because a case set graded against a partial tool surface measures nothing.)
Annotations become capabilities
Capabilities {
private_data: true,
untrusted_input: hint("openWorldHint"),
// `Chosen`, never `Blind`: a remote tool's input schema is the
// server's to write, so nothing local can prove it holds no
// destination.
egress: if hint("openWorldHint") { Egress::Chosen } else { Egress::None },
destructive: hint("destructiveHint"),
}.union(self.forced)
An unannotated server tool is assumed to return private data — that is what
most of them exist to do — but not to reach the open world, because assuming
otherwise would arm the interlock on every call. openWorldHint means the tool
talks to the wider world, which makes it both a source of attacker-influenced
content and a way out.
readOnlyHint is honoured unless config forces destructive. Only a forced
destructive contradicts a read-only claim; the other axes are orthogonal to
it, which is the same reason http_fetch is read-only while being a send sink.
Blanket narrowing here made every knowledge-graph retrieval prompt for
approval, which is unusable for a memory read at turn start.
Config can force capabilities on, per server, and the union means it can only ever distrust a server further, never less.
The environment is an allowlist, not an inheritance
This is the rule that matters most, because an MCP server is third-party code
running on your machine — where shell at least runs commands a model asked
for out loud, a server runs whatever its author wrote.
// Clear first, then add back. `envs()` alone layers on top of the
// inherited environment, which is how a server ends up holding your
// provider keys without anyone deciding it should.
command.env_clear();
command.envs(Sandbox::child_env(&cfg.env_passthrough));
command.envs(&cfg.env);
Command::envs() adds to the inherited environment rather than replacing it,
so the bug this prevents looks entirely correct at the call site while every
server on the machine quietly holds your API keys. connect clears first, then
adds a minimal base — PATH, HOME, LANG, LC_ALL, TZ, because most
runtimes cannot start without them — plus whatever env_passthrough names and
env sets.
Measured against a deliberately nosy test server that reports its own environment: 64 variables including two API keys, down to 3 and none. The test asserts a subset, not a hand-listed set of secrets, because the leak was never about one variable.
sandbox = true that cannot be honoured is an error
MCP server `graph` is configured with `sandbox = true`, but no sandbox backend
is set. Set [sandbox] kind = "bwrap" or "docker", or drop `sandbox = true` to
accept that it runs unconfined.
The same rule as shell, for the same reason: running unconfined after being
told to confine leaves every downstream decision resting on a belief nothing is
enforcing. Per-server network overrides the global switch, because otherwise
you would have to give shell the network to let one server reach its own API.
A server starts in the run's workspace, confined or not
// The workspace, whether or not we confine.
c.current_dir(workspace);
The confined branch always did this — the workspace is its only writable mount
and wrap_argv --chdirs into it. The unconfined branch inherited mecha's
working directory, so a server resolving a relative path resolved it against
wherever the user happened to launch mecha.
That is not a containment hole; an unconfined server can reach everything
regardless. It is the two branches disagreeing about where the model's paths
point, which silently breaks any server that takes one — mecha-factory-publish
documents --root as defaulting to the working directory on exactly this
assumption. What changed is that they now agree.
Inspecting the surface
mecha tools # names, descriptions, and the active sandbox
mecha tools --schema # exactly what the model sees
mecha tools --json # each tool's capabilities
mecha tools runs without any provider configured, which makes it the right
smoke test for a newly wired MCP server.