Skip to content

Tool providers

A read-only support agent has 25 tools registered, but on a refund turn the LLM should only see the 3 billing tools. Putting the gating logic inside each tool’s execute is wrong — the tool runs AFTER the LLM has already chosen it. The right place to gate is BEFORE the LLM sees the menu. That’s what ToolProvider exists for.

A ToolProvider answers ONE question per iteration: “what tools should the LLM see right now?” It’s pure compute — given the current iteration context, return the visible tool set.

Two implementations ship:

  • staticTools(arr) — the 90% case. Wraps a fixed Tool[] list. What agent.tools(arr) does today, made composable.
  • gatedTools(inner, predicate) — the DECORATOR. Wraps any provider with an additional per-tool filter. Composes freely.

Plus tool sources from the same subpath:

  • mcpClient(opts) — connect to a real MCP server
  • mockMcpClient({ tools }) — in-memory MCP source for dev / tests

For runtime tool catalogs (Rube, Composio, MCP registries, per-tenant policy services) where list() needs to be a network call: see Tool discovery (async ToolProvider). Same ToolProvider interface; list() returns Promise<Tool[]>. Sync providers on this page stay sync — zero overhead.

src/tool-providers/staticTools.ts (region: staticTools)
export function staticTools(tools: readonly Tool[]): ToolProvider {
// Capture the input list once. `list()` returns a fresh array each
// call so the agent's reference-equality check always sees an update
// (matches the `gatedTools` decorator's per-call recomputation).
const captured = [...tools];
return {
id: 'static',
list(_ctx: ToolDispatchContext): readonly Tool[] {
return [...captured];
},
};
}

That’s the whole implementation. Captures the input list defensively (mutating the source array doesn’t leak), returns a fresh array each list() call so reference-equality checks see the update.

src/tool-providers/gatedTools.ts (region: gatedTools)
export function gatedTools(inner: ToolProvider, predicate: ToolGatePredicate): ToolProvider {
return {
id: 'gated',
list(ctx: ToolDispatchContext): readonly Tool[] | Promise<readonly Tool[]> {
// Pull from the inner provider first; each recomputation sees
// the freshest state from any nested gates. Inner may be sync
// or async — we mirror what we get back so a sync chain stays
// sync (zero microtask overhead) and an async chain stays
// async (no premature `Promise.resolve` wrapping).
const innerResult = inner.list(ctx);
const filter = (innerTools: readonly Tool[]): readonly Tool[] =>
// Filter by predicate — tool name from `tool.schema.name`.
// Predicates throwing escape: a buggy predicate should crash
// loudly, not silently allow tools through. Per the
// permission-as-defense-in-depth principle.
innerTools.filter((t) => predicate(t.schema.name, ctx));
return innerResult instanceof Promise ? innerResult.then(filter) : filter(innerResult);
},
};
}

Takes any inner ToolProvider and a per-tool predicate. Filters the inner output. Predicates that throw propagate — better to fail closed than silently allow tools through a broken policy.

Composition — read-only over skill-gated

Section titled “Composition — read-only over skill-gated”

The composability is the point. Two concerns, two layers, decorator-shaped:

import { staticTools, gatedTools } from 'agentfootprint/tool-providers';
const isReadonly = (toolName: string) => toolName.startsWith('read_');
const skillToolMap: Record<string, readonly string[]> = {
billing: ['read_billing', 'write_billing'],
health: ['read_health'],
};
const provider = gatedTools(
gatedTools(staticTools(allTools), isReadonly), // (1) read-only
(name, ctx) => // (2) skill-gated
ctx.activeSkillId
? (skillToolMap[ctx.activeSkillId] ?? []).includes(name)
: true,
);

Reads as: “static list of all tools, filtered by readonly policy, then further filtered by the active skill’s tool set.” Each gate is one concern; composition handles the rest.

When the LLM is on a billing turn (ctx.activeSkillId === 'billing'), the visible tool set becomes ['read_billing'] — both gates pass. When no skill is active, the read-only gate alone applies.

ToolDispatchContext — what predicates can inspect

Section titled “ToolDispatchContext — what predicates can inspect”

Predicates receive a read-only context per iteration:

FieldMeaning
iterationCurrent ReAct iteration (1-based)
activeSkillId?The id of the currently-activated Skill, if any. Set by read_skill(id)
identity?Caller identity tuple — { tenant?, principal?, conversationId } for role checks

Predicates MUST be pure — no side effects, no async. They run dozens of times per agent.run().

SituationUse
All tools always visibleagent.tools(arr) (top-level builder)
Permission filtering neededgatedTools(staticTools(arr), permissionPredicate)
Per-skill tool gating (autoActivate)gatedTools(staticTools(arr), skillPredicate)
Both stackedLayer two gatedTools calls — each one concern
  • Don’t put permission checks inside tool execute. The LLM has already chosen the tool by then. Gate at dispatch with gatedTools.
  • Don’t make predicates async. They run per iteration, per tool. Async predicates multiply latency.
  • Don’t make predicates have side effects. They run during the agent loop’s hot path; mutation breaks composition.
  • Don’t catch + ignore predicate exceptions. Failing closed is correct — a bug in your policy SHOULD crash, not silently allow tools through.
  • Tool discovery (async) — the async Promise<Tool[]> path for runtime catalogs (Rube / MCP / per-tenant)
  • Permission policy — the natural consumer of gatedTools (data-driven role-based gating)
  • SkillsautoActivate uses the same gatedTools pattern under the hood
  • MCP integrationmcpClient ships in the same tool-providers subpath