[README](../README.md) · [Documentation](README.md)

# Tool and MCP filtering

**Off by default and independent of skill filtering.** It has its own switch (the **Tools** tab in the `/decisions` dialog); the decision provider/model and all scoring tuning in **Advanced** (`decision.*`) are shared. Skill filtering has its own switch; there is no runtime master switch. Turning it on never selects a model (without one, every tool simply stays available), and selecting a model never turns it on. While it is off the extension never calls `getAllTools`/`setActiveTools`, writes no entries and lets every call through.

**Modes** (`tools.mode`):

- `tools` (default): every **external** tool is its own candidate, regardless of which extension or MCP server provides it.
- `mcp` (group mode): every **group of tools** is switched on or off as a whole, and there are two kinds of group, treated identically: an **MCP server** and an **extension**. MCP servers are identified only by Pi's public namespace (`mcp__<server>`, `-` folded to `_`); extensions only by Pi's public `sourceInfo` (see *Extension groups* below). Group identity never comes from parsing tool names or guessing an npm package; only built-in MCP origin scope uses the bounded names-only config read described below. Third-party MCP adapters that do not set an `mcp__` namespace are grouped by the extension that registered them. Allowlisting a single tool of a group in this mode **promotes the whole group**, except when its members have mixed global/project provenance (see *Scoped allowlists* below). (Earlier releases never filtered extension tools in this mode; it now does, which is a behavior change.) Extension tools whose provenance exposes no usable name are the only external tools this mode leaves unfiltered.

**Extension groups.** Pi has no namespace for extensions, so the group is derived from the public `ToolInfo.sourceInfo` the loader assigns: a package extension is named after its package (`npm:@acme/pi-web@2.0.0` → `@acme/pi-web`, `git:github.com/me/pi-bar@v2` → `pi-bar`), a loose extension after its file (`notes.ts` → `notes`, `notes/index.ts` → `notes`) and an SDK factory after its `<inline:name>` path (an unnamed one is `inline-2`). A package source of `.`, `..` or a path ending in them is never a name: the package is named after its resolved base directory. The name (bounded to 48 characters) is what the decision model and the dialog show; paths are only used to derive that name and are never sent. Built-in provenance is never an extension.

The group key is `ext__<name>`, at most 64 characters, derived from the **full** name (never from the displayed, truncated label). A name that is already canonical up to case and `-`/`_` (`notes`, `Pi-Web`) is used as is (`ext__notes`, `ext__pi_web`); any other name keeps a readable ASCII slug and gets a hash of the full name, for example `@acme/pi-web` → `ext__acme_pi_web_<10 hex>`, so `@acme/pi-web` and `acme-pi-web` are two groups, and a non-ASCII name such as `笔记` gets a stable key (`ext__ext_<10 hex>`) instead of escaping filtering. Keys are deterministic (the same name gives the same key on every machine); the dialog and `/decisions mcp list` show them. Extensions whose files or packages share a name share one group.

**Internal tools are always allowed**: Pi's built-in tools and built-in extensions (`read`, `bash`, `codemode`, `tool_search`, the MCP resource tools, ...) are recognised by Pi-assigned provenance (`builtin:` source), **never by name**. An extension that registers a tool called `read`, an SDK host tool, or a tool with unknown provenance is external and can be filtered; allowlist it if needed. Built-in MCP provenance does not make server tools internal.

**Picker cadence** (`tools.cadence`):

- `initial`: one pick, from your **first user prompt** after filtering became active on the branch (custom-message runs never supply it; a failed pick is retried at the next prompt). Tools or servers that appear later are scored against that first prompt only, never re-picking what was already considered, also after `/reload`.
- `prompt` (default): on each new user prompt, including queued steering/follow-up text.
- `tool-round`: also after completed tool rounds, adding the next-best candidates from cached scores for the same task (no extra call).

With no decision model selected, or fewer candidates than *Minimum candidates*, nothing is scored at ANY boundary and late arrivals stay available (and are recorded). Turning filtering off mid-run restores the tools it withheld at the next continuing boundary. Ledger records are split into chunks of at most 1000 names per list, so large inventories lose nothing.

Tool rounds share the progress indicator described under [How skills are added](skill-picking.md#how-skills-are-added-additive-picking): Pi's working row reads **Deciding Context** (its own braille spinner) from the first in-flight round until the last one settles, including a lazy score triggered by a tool call. Rounds that run while the session is idle — the `/decisions` allowlist relevance ranking and `/decisions test` — cannot use that row (nothing is streaming), so there the editors keep their own inline `ranking…` state and `test` reports its result when the call returns.

Candidates that register later (an MCP server connecting late, a new tool) are scored when first met, at the next boundary or lazily when called, and **availability is additive per branch**: once a tool or server is available on a branch it stays available; later rounds only add. Removing an allowlist entry never takes availability away. State lives in non-context `context-decisions-tools` entries derived from the active branch, so `/tree`, fork, resume and `/reload` follow it.

**Scoped allowlists** (both modes): always-available tools and always-available **groups** (MCP servers and extensions, in the list that keeps its original `alwaysAllowedMcpServers` name), never scored. Extension tools and extension-provenance MCP namespaces use public `ToolInfo.sourceInfo.scope`, like skills; **built-in MCP namespaces** use file origins from the names-only reader below (unresolved origins fall back to Global): **Global** editors list and grant user/temporary/missing-scope items and save `tools.alwaysAllowedTools`/`tools.alwaysAllowedMcpServers` in `<Pi agent dir>/pi-context-decisions/settings.json` (`~/.pi/agent` by default, respecting `PI_CODING_AGENT_DIR`). **Project** editors list and grant only `project`-scope items and save `alwaysAllowedTools`/`alwaysAllowedMcpServers` in `<cwd>/.pi/pi-context-decisions.json`; the file still always contains `alwaysAllowed` for older readers. A global entry cannot pin an observed project-origin tool/group, nor a project entry a global-origin one. Persistence remains names-only; existing branch availability is additive and is not revoked by this change. Tool names are 1-128 characters without whitespace; group entries are `[A-Za-z0-9_-]{1,64}` (`dev-radius` and `dev_radius` are the same server), or a canonical key: `mcp__` or `ext__` followed by such a name (so the canonical key of every valid server name is valid too, up to 69 characters). An entry that starts with `mcp__` is **exclusively a canonical MCP namespace key** (`mcp__prod` is the server `prod`) and one that starts with `ext__` is **exclusively a canonical extension key** (`ext__notes`, stored lowercase); a raw server literally named `mcp__prod` has the namespace `mcp__mcp__prod` (and one named `ext__x` has `mcp__ext__x`) and must be allowlisted by that key.

**Built-in MCP scope from config names.** Pi 1.1.0 does not expose the defining file through a public observer inventory accessor. With explicit user approval, we therefore read exactly `<getAgentDir()>/mcp.json` (default `~/.pi/agent`, respecting `PI_CODING_AGENT_DIR`) followed by trusted `<cwd>/.pi/mcp.json`, matching Pi's loader; no parent-directory search. Project trust comes from public `ctx.isProjectTrusted()`; an unavailable trust accessor means untrusted, so the project file is not read. A full valid project definition replaces a same-named global server as **Project**. A settings-only project override (`enabled`/`exposure`/`toolExposure`, without `command`/`url`/`type`) keeps the server's **Global** defining scope, even though Pi saves its policy override in the project file. Invalid project definitions leave the global entry intact; names differing only in `-` and `_` clash just as in Pi.

Reads are regular-file-only (no file symlinks), bounded to **64 KiB / 1000 server entries per file**, and never write or execute anything. Parsed values are inspected transiently inside the reader to mirror Pi 1.1.0 validation/precedence, then discarded; only canonical server names, scopes and safe warning codes escape. No commands, arguments, env, URLs, headers or credentials reach decision payloads, ledgers, settings, history, warnings or logs. Both MCP config files are optional: an absent file or parent directory contributes no names from that scope and produces no warning. Unreadable, unsafe or malformed files still contribute no names and warn safely. **Any built-in server whose origin cannot be determined retains the Global fallback.** Extension groups and MCP tools without `builtin:mcp` provenance keep their public provenance scope; configs do not relabel them. Names/scopes are **captured at `session_start`**, alongside Pi's own MCP config load, even when filtering is initially off. Inventory classification, gates, resource routing and editors reuse that capture until `/reload` or session replacement. Disk edits do **not** relabel live connections; reload to apply them. Settings/allowlist saves and branch navigation do not refresh connection origins. There is no file watching. Already-recorded branch availability remains additive after reload too.

Resource-only/disconnected MCP servers can have a known file scope even without registered tools: manual pins must use that scope; unknown origins use Global. They are not new editor candidates until tools load (saved names still remain stable rows). Absent `ext__` entries stay saved but grant nothing until extension scope is observable.

A group inherits its members' common scope. Same-named extensions (or namespace members) with **mixed global/project scopes** cannot safely be pinned as a group: neither group editor offers that group as a loaded candidate. Their tools appear individually in the appropriate tool editor; individual pins never promote an opposite-scope sibling, even in `mcp` mode. Relevance decisions or earlier branch-ledger group grants still apply to the whole group.

**The tool allowlist editor is nested** (Tools tab → *Always allowed tools*). Built-in tools come first, collapsed and locked (they are always available and never saved). Then every MCP server, then every extension, **in the selected origin scope**, is a collapsible group (Enter expands, groups start collapsed, a search expands the matching ones), then tools with no group. A group header shows `[ ]` nothing selected, `[x]` everything selected or `[-]` some tools selected individually. **Space on a header selects or clears the whole group**: selecting pins the group key (the same entry as the *MCP servers & extensions* list, so tools the server or extension registers later are included); clearing removes the pin and every individual pick. Children are individually selectable and are saved as tool names; if you uncheck one tool of a pinned group the pin is replaced by its other tools, and checking every tool one by one shows `[x]` but stays a list of names. One save writes both lists. Saved names that no longer match a loaded tool or group **in this scope** stay in the list, shown as *(not loaded)*, and are never dropped by a save. The *MCP servers & extensions* editor is the same group list, flat.

The Tools tab edits its switch, mode, cadence and four allowlists; limits are in Advanced. `/decisions tools list` and `/decisions mcp list` (MCP servers and extensions, one row per group) print a local, names-only inventory with availability (no decision calls); there are no other `tools`/`mcp` arguments.

**How it works**, using public Pi control APIs plus the names-only config reader above: undecided *declared* tools (direct/model-only) are removed from Pi's active set with `pi.setActiveTools` before the request (only ever removing tools this extension decided against, and restoring only tools it removed); `selectedTools`, `context_with_system` and provider payloads are never touched. Codemode/deferred tools stay callable and discoverable in Pi whether or not they are active, so **every call is also gated in `tool_call`**, including calls nested in codemode scripts; a blocked call returns a short reason pointing the user to the Tools tab's allowlists. A tool nobody has scored yet is scored once (shared by parallel calls) before the verdict. A hidden names-only notice tells the model which *indirect* (codemode/tool-search) MCP servers, extensions and tools are available, and says that directly declared, built-in and unfiltered extension tools are unaffected. The MCP resource tools stay internal (always declared) but are **routed by their `server` argument**: reading a server that is not available is blocked, and **listing resources without naming a server is always blocked while filtering is on**, because the complete server inventory (resource-only and disconnected servers) cannot be verified. A server that exposes only resources must be allowlisted in its file scope (or globally when its origin is unknown); an allowlisted server is also recorded by canonical key on the branch, so removing the entry later never revokes access that was already available.

**Privacy.** The decision model receives the latest bounded user text plus, in `tools` mode, tool names, bounded descriptions and the MCP server or extension label, or in `mcp` (group) mode the group key, the server or extension label, the namespace description (MCP only) and bounded tool names. Never parameter schemas, annotations, namespace instructions, provenance paths, server configs, arguments, results, assistant text or transcripts. Names and descriptions are third-party data and are sent as data, not instructions. Diagnostics carry counts and safe codes.

**Limits, stated plainly.**

- **Not a sandbox.** `bash`, other processes, the `mcp_servers` prompt section, codemode `searchTools`/`ALL_TOOLS`, `tool_search` results and the connections of MCP servers are unaffected: Pi has no public hook to filter discovery or disable file-configured servers. Filtering is relevance gating through Pi's tool pipeline.
- **First-prompt gap.** Direct-exposure MCP tools that finish connecting during Pi's startup wait can be declared for the first request; calls to them are still gated and the next boundary withholds them.
- **Enabling mid-session** keeps every tool already registered on that branch available (the model may have seen them); filtering then affects tools registered later and new sessions.
- **Ownership.** If another extension, `tool_search` or `--tools` re-activates a filtered tool it is removed again at the next boundary and calls are blocked meanwhile; allowlist it to resolve the conflict. An explicit `selectedTools` edit by another extension wins for that run.
- **Unreadable registry or branch.** If Pi's tool registry cannot be read at call time, the gate falls back to the last provenance-classified snapshot: verified internal tools pass, known external tools follow the ledger and allowlists, anything unknown is blocked, and with no snapshot every call is blocked until the registry recovers (fail closed). A throwing project-allowlist read keeps the last good cache, else only the global lists apply. If the session branch cannot be read the gate still fails closed (allowlisted names and internal tools pass) and no boundary writes, grants or reconciles anything.
- **Superseded rounds.** A round overtaken by a settings or allowlist save, a disable, a mode change or a branch change commits and reconciles nothing; a run that is merely cancelled reconciles only from what the branch already committed.
- **Unreadable settings** never silently drop filtering a branch already has: it keeps enforcing its ledger and adds nothing.
- **Fail closed.** A failed or cancelled round adds nothing; `failMode: open` adds the remaining candidates but never for configuration errors or cancellation.
- **SDK embedders.** Codemode, `tool_search` or MCP added as plain extension factories get `<inline:...>` provenance and are therefore treated as external (and filtered); register them as built-in extensions (`{ builtin: true }`) or allowlist them (add `codemode` to the Tools tab's global tools allowlist).
- **Server labels.** Pi's public namespace has no original-name alias, so a server `dev-radius` is labelled `dev_radius` in reasons and lists; allowlisting accepts either spelling.
- **Very long server names.** Pi accepts MCP server names of any length, but allowlist entries are bounded (a name of at most 64 characters, plus an optional `mcp__`/`ext__` prefix). A server whose name is longer cannot be pinned (the editor save reports an error); it can still be decided by the decision model, or have its tools allowlisted by name.
- **Extension names are derived, not declared.** Pi exposes no display name for an extension, so the label comes from its package source or file name (above); two extensions that share a name share one group, and an extension tool with no usable `sourceInfo` has no group (it stays decided per tool in `tools` mode and unfiltered in `mcp` mode).
- **Handler order.** Pi runs file/package extensions before its built-in MCP/codemode extensions; an SDK-inline instance runs after them. `turn_start` cannot close the first-prompt gap because the first request's declarations are fixed before it.
- **Not covered by compaction re-delivery.** The availability notice is not re-delivered if compaction summarizes it away (calls stay gated).
