Gate MCP tools by their full mcp__ name and treat annotations as hints
A remote tool arrives as mcp__<server>__<tool>, so a name-keyed gate must use the full name, and exposure decides if the model is told it exists.
An MCP server’s tool arrives as mcp__<server>__<tool>, so a tool_call gate
keyed on the bare tool name silently lets every remote call through. This example
is a deny-list gate that keys on the full name, the registration pair that adds a
server for one session, and the difference between annotations a server declares
about itself and the exposure that decides whether the model is told the tool
exists. Only the far end and the model are faked; naming, exposure and gating are
Pi’s.
Run it
Use Pi 1.0.4 and one of the example repository’s test runners. The deny-list,
mcp-gate.ts:
const MUTATING = new Set([
"mcp__jira__transition_issue",
"mcp__jira__delete_issue",
"mcp__github__create_pull_request",
]);
export default function (pi: ExtensionAPI) {
pi.on("tool_call", async (event, ctx) => {
if (!MUTATING.has(event.toolName)) return undefined;
if (!ctx.hasUI) {
return { block: true, reason: `${event.toolName} blocked — no UI to confirm` };
}
const ok = await ctx.ui.confirm(
"Allow MCP tool call?",
`${event.toolName} (from ${event.toolName.split("__")[1]})`,
);
return ok ? undefined : { block: true, reason: `${event.toolName} was not approved` };
});
}Registration is per session — registerMcpServer adds, unregisterMcpServer
removes, and neither survives a reload (register-server.ts):
// Registrations are not saved: register again on every load.
pi.registerMcpServer("jira", {
url: "https://mcp.example.com/jira",
exposure: "codemode",
});
pi.registerCommand("jira-off", {
description: "Remove the jira MCP server for this session",
handler: async () => {
pi.unregisterMcpServer("jira");
},
});
}Expected outcome
| Situation | Result (observed) |
|---|---|
Gate keyed on transition_issue |
Never matches; the call reaches the server and the ticket is closed |
| Full-name deny list, no UI | The call is blocked and the server never hears it |
| Full-name deny list, with a UI | The person decides: approve lets it through, decline blocks |
Server declares readOnlyHint: true on delete_everything |
Passes an annotations gate; the call reaches the server |
| Tool declares no hints | Treated as dangerous; blocked with no UI |
Exposure direct |
The tool is declared to the model |
Exposure codemode (default) or hidden |
Not declared; hidden is unreachable |
Mechanism and limitations
Documented: mcp.md names every server tool mcp__<server>__<tool> with a
sanitisation rule, applies extension tool_call/tool_result handlers (including
permission gates) to MCP tools, and states “the hints are not verified, but a
permission extension can use them to decide which calls to confirm” — a missing
readOnlyHint means the tool is not read-only. Registrations are not saved:
register again each load. Observed: mcp.test.ts runs Pi’s own MCP client
against an in-memory fake server with a scripted model: the bare-name gate fails to
match, the full-name deny list blocks before the server hears anything, a UI lets a
person decide, a lying readOnlyHint passes, and register/unregister make a tool
callable and then unreachable. tool-allowlist.test.ts adds the 1.0.4 rule: an
allowlist naming only built-ins leaves a codemode-exposure tool callable from a
script, while a kept-but-unnamed direct tool is inert — the pattern filters,
exposure decides.
This example does not show a real model using an MCP tool. The Pi process and the server process are a protocol boundary, not containment: a stdio server inherits the launching account’s permissions; an HTTP server is reachable by anything with its URL.
Understand this example
Copy this prompt into your AI tool. No code runs here.
Apply this example
Copy this prompt into your AI tool. No code runs here.