Tool catalog
The tool catalog is the registry every tool call passes through. It holds the mapping from name to tool.Tool, drives the ReadOnly dispatch model, and is the extension point where custom tools, MCP server tools, and skill bundles enter the harness.
The Catalog type
engine/tool.Catalog is a name → Tool map. Create one with tool.NewCatalog(). The loop never constructs a catalog — you build it and inject it.
Registration
cat := tool.NewCatalog()
// MustRegister panics on duplicate — right for static startup registration.
cat.MustRegister(myTool)
// Register returns ErrDuplicateTool on duplicate — right for dynamic registration
// where you want to handle the error rather than panic.
if err := cat.Register(myTool); errors.Is(err, tool.ErrDuplicateTool) {
// handle the collision
}
Collision rule: global-wins. assembleCatalog in internal/app registers families in a fixed order:
core → server-global MCP (+ resource meta-tools) → client MCP →
Subagent / InspectSubagent / SubagentStatus → Parallel →
Team / InspectMember → memory → user-model → Skill → SkillDraft
mcp.Register is first-wins + skip-and-continue: a later tier with a name that collides with an already-registered tool is silently dropped and the skipped names are surfaced in a WARN diagnostic. That means server-global MCP tools win over client MCP tools with the same name, and core tools win over everything. You cannot displace a built-in tool by registering an MCP server with a conflicting name.
Querying
| Method | Returns |
|---|---|
cat.Lookup(name) | (Tool, bool) — the registered tool or (nil, false) |
cat.Tools() | All registered tools, sorted by name for determinism |
cat.Available(mode) | Mode-filtered tools: in ModePlan, non-read-only tools are excluded |
cat.Specs(mode) | []ToolSpec of Available(mode) |
cat.AdvertisedSpecs(mode) | Per-turn specs under progressive disclosure — tools that implement Disclosable return their lightweight Advertised() spec instead of the full one |
Plan-mode filtering is enforced at the catalog level, before dispatch reaches the tool. A non-read-only tool that the model calls in plan mode never executes — the catalog does not expose it.
The Tool interface
Every tool registered in the catalog implements engine/tool.Tool:
type Tool interface {
Spec() ToolSpec
ReadOnly() bool
Execute(ctx context.Context, in session.ToolCall, env Environment) (session.ToolResult, error)
}
Spec() ToolSpec
ToolSpec is what the model sees:
type ToolSpec struct {
Name string // catalog name; must be unique across registrations
Description string // model-facing documentation: when to use, when not, limits
Schema json.RawMessage // JSON Schema for the tool's arguments
}
Description should be documentation-quality prose — it is the model's contract for when and how to call the tool. Schema is the JSON Schema the LLM adapter sends to the model to constrain argument generation.
ReadOnly() bool
ReadOnly drives the loop's read-parallel / mutate-serial dispatch model. Within a single turn:
- All
ReadOnly() == truecalls are batched and executed concurrently. - Any
ReadOnly() == falsecall runs alone, never overlapping another tool execution.
This means your tool's declaration of read-only is a correctness contract, not a hint. If your tool mutates the workspace and returns true, it can overlap another write.
MCP tools default to ReadOnly() == false unless the remote server advertises annotations.readOnlyHint == true. This is the conservative choice: an unknown remote tool is serialized with mutations.
Execute
Execute receives the model's tool call (session.ToolCall, which carries the call ID and the raw JSON args), and a session-scoped Environment containing the workspace and optional command runner. Return a session.ToolResult for success or a model-visible error result; return a non-nil Go error only for harness-level faults the model cannot recover from.
Tool errors do not abort the run. A ToolResult with IsError: true is fed back to the model as the tool's result, and the model can retry or choose a different path.
Progressive disclosure: Disclosable
A tool may optionally implement Disclosable to participate in progressive tool disclosure (corpus pattern 9):
type Disclosable interface {
Tool
Advertised() ToolSpec // cheap, metadata-only spec for the per-turn inventory
}
A Disclosable tool is advertised in the per-turn tool list with its lightweight Advertised() spec (typically just a name and a one-line description, with a minimal or empty schema) until the model explicitly hydrates the full spec via ToolSearch. Tools that do not implement Disclosable always appear in the per-turn list with their full Spec() — the catalog default is unchanged.
Adding a custom tool
Implement the three-method interface and register at build time. A minimal example:
package mytool
import (
"context"
"encoding/json"
"github.com/stacklok/mecatl/engine/session"
"github.com/stacklok/mecatl/engine/tool"
)
type PingTool struct{}
func (PingTool) Spec() tool.ToolSpec {
return tool.ToolSpec{
Name: "Ping",
Description: "Returns a pong. Use this to verify the tool catalog is reachable. Takes no arguments.",
Schema: json.RawMessage(`{"type":"object","properties":{}}`),
}
}
func (PingTool) ReadOnly() bool { return true }
func (PingTool) Execute(_ context.Context, in session.ToolCall, _ tool.Environment) (session.ToolResult, error) {
return session.NewToolResult(in.ID, "pong"), nil
}
For a direct engine embedding, register the tool on the catalog you pass through
agent.Deps. In the repository's full host composition, register it through
internal/app instead; external consumers cannot import that internal package.
cat.MustRegister(mytool.PingTool{})
eng := agent.NewEngine(agent.Deps{Catalog: cat /* other deps */})
session.NewToolResult(id, content) and session.NewToolError(id, msg) are the two constructors for a plain text result. Use NewToolError when the tool failed in a way the model should know about and can recover from. A third constructor, session.NewToolResultWithParts(id, content, parts), backs the typed-content-block results described in MCP client — most custom tools only need the plain-text pair above.
A tool registered at build time appears in every session's catalog. If a tool needs per-session state — a session-scoped HTTP client, a connection derived from session credentials — it must be wired differently. See Per-session vs. shared catalog below.
MCP servers as tools
mecated can mount MCP servers at startup. Each server's advertised tools land in the catalog under a namespace prefix so their names cannot collide with built-in tool names.
Naming convention
Every tool from an MCP server is registered as mcp__<server-name>__<tool-name>. For example, a server named github advertising a tool search_repos registers as mcp__github__search_repos.
The --mcp-server flag
mecated serve --mcp-server github=https://mcp.github.example.com/v1 \
--mcp-server slack=https://mcp.slack.example.com/v1
Each name=URL pair connects to a streaming-HTTP MCP server at startup. An auth token is read from the environment variable MCP_<NAME>_TOKEN (e.g. MCP_GITHUB_TOKEN). Multiple --mcp-server flags are additive. Server names must match [A-Za-z0-9_]+ and be case-insensitively unique (the name derives the env var), and when a token is present the URL must be https — or http to a loopback host — so the bearer is never sent in cleartext off-host.
For a deliberately plain-http endpoint on a trusted network segment (e.g. an in-cluster Service behind a NetworkPolicy), --mcp-server-insecure-http <name> (repeatable) opts that one server out of the https rule. It is an explicit acknowledgment that the token travels cleartext on the network path — you are relying on network-layer controls plus a short-lived token. The relaxation covers only the named server and only the http scheme; naming a server that is not registered, or whose URL is already https/loopback, is a startup error.
Mecatl also integrates with ToolHive: --toolhive (default on) discovers already-running ToolHive workloads by their HTTP proxy URLs. Mecatl never starts or spawns ToolHive workloads — it only reads URLs from an already-running instance.
The --mcp-resource-tools flag
When a connected server exposes MCP resources (server-provided documents addressable by URI), two harness-level meta-tools are registered:
ListMcpResources— enumerates resources across connected servers; optionally filtered to one server by name.ReadMcpResource— fetches a resource by URI.
These tools use plain names (not the mcp__ namespace) because they are harness meta-tools, not proxies for a specific remote tool. They are read-only and survive the plan-mode catalog filter.
--mcp-resource-tools defaults to true but is a no-op when no connected server exposes any resources.
The --mcp-prompts flag
When --mcp-prompts is enabled (default on), the harness registers an expander for slash-command inputs of the form /mcp__<server>__<prompt> key=value. At app.Build time, Mecatl takes a static snapshot of each server's advertised prompts. At run time, /mcp__github__summarize_pr number=42 renders into the server's prompt template and injects it as the turn's instruction.
MCP prompts steer the model like a slash command — a prompt template can rewrite the agent's goal. Enable --mcp-prompts only for servers you own or fully trust.
Connection resilience
The MCP adapter reconnects dropped sessions transparently (ADR 0056). A single retry is attempted; if the reconnect fails, the tool returns a model-visible error (mcp call failed: MCP server "<name>" unavailable after reconnect) rather than aborting the turn.
Skills
Skills are a separate extension seam for instruction bundles, not new tool implementations.
What a skill is
A skill is a named SKILL.md file containing YAML frontmatter (name, description) followed by a markdown body. The name is the stable activation key; description is the one-line always-in-context trigger hint the Skill tool's description enumerates. The body is the full instruction set — loaded only when the model activates the skill.
Skills follow progressive disclosure: the metadata (name + description) is always present in the Skill tool's spec, cache-stable across turns. The body loads on demand when the model calls Skill("my-skill-name").
The SkillSource port
The port for skill bundles is engine/tool.SkillSource:
type SkillSource interface {
ListSkills(ctx context.Context) ([]SkillMeta, error)
SkillBody(ctx context.Context, name string) (string, error)
ListSkillAssets(ctx context.Context, name string) ([]SkillAsset, error)
ReadSkillAsset(ctx context.Context, skill, asset string) ([]byte, error)
}
A SkillSource deals in logical bundles — no path, directory, or root concept is present on the port. Both the filesystem-backed source (engine/adapter/skillfs.FSSource) and a remote driver are consumed identically through these methods; filesystem paths remain private to the adapter.
Skills may have assets: auxiliary payloads identified by a logical name like references/api.md. Asset names are slash-separated relative identifiers with no .. or empty segments — validated by tool.ValidSkillAssetName. Calling Skill with {name} returns the instructions and bounded logical inventory. Calling it again with {name, asset} fetches only that textual payload, bounded by the tool-output cap and rejected if it is invalid UTF-8 or contains NUL bytes. Assets are not materialized, mounted as workspace read roots, or made available to Read or Shell.
Skills inject instructions, not tools
Activating a skill does not register new tools in the catalog. A skill's SKILL.md body is returned as the tool result of the Skill tool call, and the model incorporates it as instructions. Bundled scripts are not implicitly installed or executable: a workflow needing a real file must explicitly create or obtain it in the workspace under normal permissions. If you want to expose new tool capabilities, register a Tool (see above); skills are for instruction and behavioral guidance.
Skills as slash commands (/<skill-name>)
Each discovered skill is also invocable as a slash command: /<skill-name>
expands to the skill's body directly in context (Claude-Code skill-as-command
semantics — the body IS the command template). It reuses the existing
slash-command layer, so $ARGUMENTS/$1/$2 placeholders substitute exactly
like a file-backed command. Precedence (first-that-expands-wins): a local
command file shadows a same-named skill; a skill shadows a same-named
slash-command driver source; both shadow MCP prompts. The project-tier trust
gate is inherited — an untrusted workspace's project-tier skills are not
invocable as /<skill-name> until you --trust-project. See
Skills, commands, and soul
for the user-facing rules.
The filesystem source
engine/adapter/skillfs.FSSource is the reference SkillSource implementation (graduated into the importable engine module per #328; the in-repo binaries consume it through internal/adapter/skills, which re-exports it via alias). It discovers SKILL.md files under a directory at construction time and takes a snapshot — ListSkills is stable for the life of the source. There is no watch seam: skills are resolved once at app.Build and do not change mid-process. This is deliberate: the build-once trust-gate invariant depends on skills being resolved at a known trust level before any session starts.
Skill sources are registered per tier (explicit, project, user, driver) and are trust-gated at construction. The project tier is only admitted when workspace trust is granted. See engine/adapter/skillfs for FSSource, DirSource, and the trust-tier resolution logic.
Per-session vs. shared catalog
The shared catalog is built once in app.Build (Phase A + assembleCatalog). It is the starting point for every session. Per-session deltas are strictly controlled.
Sanctioned per-session deltas (the only two things that may differ between the shared catalog and a session's catalog):
- Client MCP tools — tools from MCP servers the client provided in its
CreateSessionrequest (mcpServers). These are session-scoped and are closed when the session ends. - Unwrapped hooks —
maybeWrapUserModelReviewdecorates the main engine only; per-session engines built for selectors use the unwrapped hook.
Everything else is identical. The invariant is enforced by TestPerSessionCatalogMatchesSharedCatalog (exact tool-name-set equality).
If you need a tool that carries per-session state, you have two options:
- Make the tool stateless and inject dependencies through the session-scoped
Environmentor via closures captured at construction time inassembleCatalog(where the per-session context is available). - Wire a custom
sessionEngineFactorythat constructs the per-session catalog differently — but you take on maintaining parity with the shared catalog yourself.
The global MCP manager is owned by Build. Its Close is never folded into a per-session close. A per-session CloseSession closing the global manager would kill MCP for every other concurrent session.
ToolCallRecorder
engine/port.ToolCallRecorder is the audit seam for tool execution. It is distinct from the EventSink (the model-visible event stream) and from port.Diagnostics (operator-facing log).
type ToolCallRecorder interface {
ToolCall(id session.SessionID, call session.ToolCall, result session.ToolResult, queued, took time.Duration)
}
queued is the coordinated-omission measure: the gap between when a call entered dispatch and when its execution actually began. For a read-only call that ran immediately, queued is near-zero. For a mutating call held behind a permission ask or behind an in-progress read batch, queued is the real wait. Both durations are zero when no Clock is injected.
The jsonlstore.Store implements port.ToolCallRecorder alongside port.SessionStore and port.EventLog. It writes one JSON record per ToolCall invocation to a .tools.jsonl sidecar in the session's family directory (<store-dir>/sid-v1/; see Session store for the layout and why the filename is not the session id). The .tools.jsonl file is parallel to, not a superset of, the .events.jsonl event log:
| File | What it captures |
|---|---|
*.tools.jsonl | Structured per-tool audit: args, queue/exec timing |
*.events.jsonl | Relayed run stream: reasoning, ask/verdict pairs, delegation lifecycle |
Neither subsumes the other. Wire a ToolCallRecorder when you need structured latency/audit data per tool call that is separate from the model's event stream.
What's next
- The agent loop — how the dispatcher executes the catalog's tools and applies the read-parallel / mutate-serial model.
- Permissions & guardrails — how tool calls are evaluated before the catalog executes them, and how guardrails inspect the data crossing the tool boundary.
- Hook system —
PreToolUseandPostToolUsehooks that decorate catalog execution without modifying tool implementations. - LLM provider port — the other half of what the loop dispatches against; implement it to bring your own model backend.