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 external_send: bool, // can transmit data outside the user's control
pub destructive: bool, // may destroy or overwrite data
}
The loop tracks which of these have entered the conversation and refuses any
external_send tool once both private and untrusted are present. 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 an external_send 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.
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 external_send: with no network there is no way out, so a confined
shell 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.
Two more tools are registered conditionally. ask_user exists only where a
human is actually present — a batch worker or an eval case has nobody to
answer, and a tool that blocks forever is worse than one that does not exist.
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.
Which built-ins are registered is config, via [tools] enabled / disabled,
or --tool on the command line.
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.
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.
The dispatch order is interlock → hook → approver. A hook can narrow policy
and never loosen security, and a pre_tool denial never reaches the human:
mechanical policy is cheaper than an interruption, and a hook cannot be talked
into clicking yes. See Hooks.
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 = "pkg"
command = "~/Github/personalized_knowledge_graph/target/release/pkg-mcp"
env_passthrough = ["PKG_DB"]
env = { MECHA_TZ = "America/New_York" }
sandbox = true
network = false
Tools are namespaced <server>__<tool>, so two servers can both expose a
search. 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"),
external_send: hint("openWorldHint"),
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 `pkg` 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.