MCP Servers
Connecting Model Context Protocol servers over stdio and HTTP, controlling how their tools reach the model, and what a permission gate can and cannot see of them.
The guard extension intercepts write and edit. It works beautifully. Then someone on the team installs a Jira MCP server, and the model closes a ticket through mcp__jira__transition_issue without the guard ever being consulted.
It was not consulted because the guard never claimed that path was its responsibility — and, more importantly, because whether it is covered turns out to be a real question with a documented answer. This chapter is about registering MCP servers, controlling how their tools reach the model, and the one thing a permission extension can and cannot learn from them.
Two transports, one config file
mcp.md states it plainly: Pi connects to Model Context Protocol servers “over stdio or streamable HTTP and makes their tools and resources available to the model.”
Stdio servers use command, args, env, and cwd. HTTP servers use url, headers, and oauth. The legacy SSE transport is not supported — mcp.md says sse is rejected, and that servers documenting an SSE endpoint often also provide streamable HTTP, commonly at /mcp instead of /sse. That single fact will save you an hour if you port a configuration from another client.
The file is mcp.json, with an mcpServers object:
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
},
"docs": {
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer ${DOCS_TOKEN}" },
"description": "Search and read the product documentation"
}
}
}
Documented, including two details worth reading twice. Relative cwd values resolve against the session directory, and a leading ~/ in command, an argument, or cwd names the home directory. And headers values can use environment variables such as ${GITHUB_TOKEN}, or run a command with !command — where “the command must make up the whole value”, so "Authorization": "!echo Bearer $(gh auth token)" works but a command embedded in a longer string does not.
Where the file goes, and why that is a security decision
User-level servers live in ~/.pi/agent/mcp.json. Project servers live in .pi/mcp.json. mcp.md states that project configuration “is read only after project trust is granted” and that a project entry replaces a user-level entry with the same name.
mcp.md also gives the rule that should govern where you put things: “Keep personal servers and servers with credentials in the user-level file. Use the project file only for servers the project requires, and only in trusted projects.” That is a documented instruction, not a style preference. A project mcp.json is executable configuration arriving from a folder you may not control.
The shell commands cover the common edits and work without a session:
pi mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem .
pi mcp add docs --url https://example.com/mcp --bearer-token-env-var DOCS_TOKEN
pi mcp list
pi mcp list is also the diagnostic. mcp.md says it connects to every enabled server and prints its state, tools, and errors, and “exits with status 1 when an entry is invalid or an enabled server is not connected.” That exit code is what you want in a CI check.
Naming is already decided for you
Every server tool arrives as mcp__<server>__<tool>. mcp.md gives the sanitisation rule: every character other than letters, digits, and _ becomes _, and colliding names all get a hash suffix.
There is one collision rule that surprises people: “Server names that differ only in - and _ count as the same server: a second one is rejected.” So dev-radius and dev_radius are one server, not two.
Exposure decides whether the model ever sees the tool
This is the part that changes how you think about MCP. Registering a server does not give the model its tools. exposure decides.
| Exposure | Behaviour |
|---|---|
codemode (default) |
Callable from codemode scripts, but not declared to the model and not listed in the codemode description. Scripts find them with searchTools(), describeTool(), or ALL_TOOLS. |
deferred |
Not declared until tool_search loads a match for the next model call. |
direct |
Declared to the model like a built-in tool, and also callable from codemode. |
hidden |
Registered but unreachable. |
codemode-deferred is accepted as an alias for codemode.
The default is the interesting one. A generic MCP server’s tools are, by default, not in the model’s tool list. They are reachable, but only through a script the model writes. For a large server that is the right default; for a two-tool server you actually want called directly, you set direct.
toolExposure overrides the server default per tool, with exact names winning over patterns and the first matching pattern winning among patterns:
{
"mcpServers": {
"github": {
"url": "https://api.githubcopilot.com/mcp/",
"exposure": "deferred",
"toolExposure": {
"search_code": "direct",
"get_*": "codemode",
"delete_*": "hidden"
}
}
}
}
The hidden row is the load-bearing one. It is how you keep a destructive tool reachable by nothing at all, using the same file that connects the server. mcp.md notes that “a server with hidden exposure can expose only selected tools”, which means hidden at server level plus toolExposure is a workable allowlist.
Registering from an extension
pi.registerMcpServer(name, config) adds a server for the current session. The config has “the shape of an mcpServers entry in mcp.json” plus exposure, toolExposure, description, enabled, and timeout.
pi.registerMcpServer("jira", {
url: "https://mcp.example.com/jira",
exposure: "codemode",
});
pi.unregisterMcpServer("jira");
Four documented behaviours to design around. Servers registered while the extension loads connect when the session starts, together with the mcp.json servers; servers registered later connect right away. Registrations are not saved — you must register again on every load. A server in mcp.json with the same name takes precedence, and /mcp shows the override. And names registered by another extension, invalid names, and invalid configs throw.
That last one has a design consequence: registering in the factory is safe, registering in a command handler is where you can crash a user’s session on a typo. Validate your own inputs before you call.
If another extension replaces the built-in MCP support, extensions.md says each registration is “reported as an extension error” — you do not silently get no servers.
What the guard can see
Here is the question the chapter opened with. mcp.md, section “Permissions”:
Every MCP call passes through Pi’s tool pipeline. Extension
tool_callandtool_resulthandlers, including permission gates, therefore apply to MCP tools. Calls made from codemode scripts carry the codemode call ID asparentToolCallId.
Documented, and the shape of it:
flowchart LR
subgraph P["Pi process"]
M["Model asks for a tool"] --> T["Pi tool pipeline"]
T --> G["Guard: tool_call decides"]
end
subgraph S["MCP server: another process"]
J["mcp__jira__transition_issue"]
end
G -->|"allowed"| J
J --> C["The ticket is closed in Jira"]
Read the two boxes carefully, because the gap between them is the whole point of this chapter and the easiest place to over-read it. They are two processes. That is a protocol boundary and a place to hang timeouts, logs and retries — it is not containment. security.md is blunt about the rest: “Extensions, package installers, language servers, and other child processes run with those same permissions unless an operating-system or virtualization boundary restricts them.” A stdio server launched from mcp.json inherits the permissions of the account that started Pi, and an HTTP server can be reached by anything that reaches that URL. Chapter 36 is where isolation is settled properly.
A tool_call gate that keys off the tool name must also key off the mcp__ prefix, or it will silently skip every MCP tool:
const MUTATING = new Set([
"mcp__jira__transition_issue",
"mcp__jira__delete_issue",
"mcp__github__create_pull_request",
]);
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` };
});
That is the deny-list shape. The allow-list shape uses annotations instead, and annotations are the second thing you have to understand about MCP.
Annotations are hints, not permission
MCP tools carry readOnlyHint, destructiveHint, idempotentHint, and openWorldHint. pi.getAllTools() reports them. extensions.md is precise about their status: “The hints are not verified, but a permission extension can use them to decide which calls to confirm.”
Read that sentence twice. Not verified. The server declares them about itself. A malicious or careless server can declare readOnlyHint: true on a tool that deletes things.
Pi documents the default when hints are missing: “a tool is not read-only, and may be destructive and reach an open world.” So absence of a hint is treated as the dangerous case, which is the right default and is what makes the following pattern sound:
pi.on("tool_call", async (event, ctx) => {
const hints = pi.getAllTools().find((t) => t.name === event.toolName)?.annotations;
const needsApproval =
hints?.destructiveHint === true ||
(!hints?.readOnlyHint &&
((hints?.destructiveHint ?? true) || (hints?.openWorldHint ?? true)));
if (needsApproval && !(await ctx.ui.confirm("Allow tool call?", event.toolName))) {
return { block: true, reason: `${event.toolName} was not approved` };
}
});
That is Pi’s own example from extensions.md, taken verbatim. It confirms the calls Codex asks approval for. Note it covers MCP tools too, because mcp.md confirms MCP tools carry the hints their server declares and that resource tools are marked read-only.
Resources, and when tools show up
If a connected server offers resources, Pi adds list_mcp_resources, list_mcp_resource_templates, and read_mcp_resource. Resources for MCP Apps, identified by ui:// URIs or text/html;profile=mcp-app, are omitted because Pi does not render them.
Timing matters for anything the guard inspects. mcp.md says the first prompt “waits up to 10 seconds only for servers with direct tools, which must be declared in its request.” Servers with codemode or deferred tools are waited for when needed. So a guard that wants to enumerate all tools must not assume they are all present at before_agent_start.
Verify it yourself
pi mcp list
pi mcp add -l issues --url https://mcp.example.com/issues --description "Read and update issues"
pi mcp list --json
Inside a session, run /mcp. It “lists configured servers with their state, tool count, exposure, and configuration source”, and servers needing attention appear first. Select the server to inspect its tools and see the effective exposure.
Then confirm the guard actually sees MCP calls: ask the model to close a ticket, and watch whether your confirmation dialog appears. If it does not, your handler is filtering on tool name only and the tool is named mcp__issues__close.
What this chapter does not settle
It does not settle the trust question. mcp.json in a project is executable configuration, an extension runs inside the Pi process, and neither is a security boundary. That is the argument the final chapter makes properly, and it needs security.md and containerization.md rather than an MCP chapter.
Next: Packages — the guard, the command, the UI and the MCP registration are four resources that always ship together, and a package is how they stop drifting apart.