Example

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.

Full source and tests .

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.