---
title: Tool providers
description: staticTools + gatedTools — chainable tool dispatch primitives. Compose permission gating + per-skill tool filtering at the dispatch boundary, not inside each tool's execute.
---

import CodeFile from '@/components/CodeFile.astro';

> 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.

## What `ToolProvider` is

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.

Three 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.
- **`skillScopedTools(skillId, arr)`** — exposes a tool subset only while `ctx.activeSkillId === skillId`. Returns `[]` when a different skill (or no skill) is active. Compose with `staticTools` for the always-on baseline.

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)](/agentfootprint/guides/tool-discovery/). Same `ToolProvider` interface; `list()` returns `Promise<Tool[]>`. Sync providers on this page stay sync — zero overhead.

## staticTools — the simplest provider

<CodeFile path="src/tool-providers/staticTools.ts" region="staticTools" title="src/tool-providers/staticTools.ts (region: staticTools)" />

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.

## gatedTools — the decorator

<CodeFile path="src/tool-providers/gatedTools.ts" region="gatedTools" title="src/tool-providers/gatedTools.ts (region: gatedTools)" />

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

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

```ts
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.

## Wiring a provider into an agent

Pass the provider to `.toolProvider(...)` on the builder. The agent consults it every iteration via `provider.list(ctx)`, with `ctx = { iteration, activeSkillId, identity }`. Each agent has at most one external provider — calling `.toolProvider()` twice throws.

```ts
import { Agent } from 'agentfootprint';
import { gatedTools, staticTools } from 'agentfootprint/tool-providers';

const provider = gatedTools(staticTools(allTools), (name) => name.startsWith('read_'));

const agent = Agent.create({ provider: llm, model })
  .system('You answer.')
  .toolProvider(provider)
  .build();
```

Tools the provider emits flow into the Tools slot alongside any static tools registered via `.tool()` / `.tools()`, and the tool-call dispatcher consults the provider too — so dynamic chains (`gatedTools`, `skillScopedTools`) dispatch correctly when their visible set changes mid-turn.

## ToolDispatchContext — what predicates can inspect

Predicates receive a read-only context per iteration:

| Field | Meaning |
|---|---|
| `iteration` | Current 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 |
| `signal?` | Optional `AbortSignal` propagated from `run({ env })`. Async (discovery) providers MUST honor it; sync providers ignore it |

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

## When to use this vs flat `agent.tools(arr)`

| Situation | Use |
|---|---|
| All tools always visible | `agent.tools(arr)` (top-level builder) |
| Permission filtering needed | `gatedTools(staticTools(arr), permissionPredicate)` |
| Per-skill tool gating (autoActivate) | `skillScopedTools(skillId, arr)`, or `gatedTools(staticTools(arr), skillPredicate)` |
| Both stacked | Layer two `gatedTools` calls — each one concern |
| Wire any of the above into an agent | `Agent.create(...).toolProvider(provider)` |

## Anti-patterns

- **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.

## Next steps

- [Tool discovery (async)](/agentfootprint/guides/tool-discovery/) — the async `Promise<Tool[]>` path for runtime catalogs (Rube / MCP / per-tenant)
- [Permission policy](/agentfootprint/guides/security/) — the natural consumer of `gatedTools` (data-driven role-based gating)
- [Skills](/agentfootprint/guides/skills/) — `autoActivate` uses the same `gatedTools` pattern under the hood
- [Tools](/agentfootprint/guides/tools/) — `mcpClient` / `mockMcpClient` ship in the same `tool-providers` subpath
