← Pi Agents

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 blocks those calls as intended. Then someone on the team installs a Jira MCP server, and the model closes a ticket through mcp__jira__transition_issue without that guard blocking the call. The MCP call has a different tool name, so a guard limited to write and edit does not match it.

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.” Availability still depends on the tools’ exposure and selection settings, as the sections below explain.

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.

Since 1.0.1 that has a partial exception, and it is the more useful half. A project entry with no command, no url and no type is an override rather than a replacement: it changes only enabled, exposure and toolExposure on the user-level server and keeps everything else, including env, headers and auth.

{
  "mcpServers": {
    "internal-tools": { "enabled": false }
  }
}

That single line disables a user-level server for this project without repeating its command or credentials in the project file. It changes which integration Pi will connect to in this project; it does not revoke the user-level credentials or restrict other processes that can access the server. The interactive route to the same state is “Enable in this project” and “Disable in this project”; once an override exists, later changes are saved to the override rather than to the user-level file.

So there are now three states a server can be in for a given project, and they are not the same thing: declared at user level, overridden per project, or replaced outright by a project entry that does carry a command. An override is the narrow one, and it is the one worth reaching for by default.

The OAuth configuration also gained an option in 1.0.1: "oauth": { "clientRegistration": "cimd" } identifies Pi by its Client ID Metadata Document on pi.dev instead of performing dynamic registration, for authorization servers that allow clients by document URL. The documented constraints are specific — it cannot be combined with clientId or clientName, a callbackUrl must use localhost or 127.0.0.1 with the path /callback, and a server that advertises neither Client ID Metadata Document support nor public clients fails sign-in. This is an opt-in client-registration alternative, not a general privacy guarantee; what the authorization server learns depends on the registration and sign-in flow.

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 prevents Pi from offering that tool through its MCP tool routes, 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 explicit toolExposure overrides is a useful capability allowlist within Pi. It does not revoke the server’s permissions or prevent access through other clients.

An allowlist of tools is not an allowlist of MCP tools

This is the one place where a CLI habit has to be unlearned, and it is new as of 1.0.4. --tools does not remove MCP tools unless one of its entries starts with mcp__. Before that version it removed them, and --tools codemode left you with codemode and no servers to call.

So pi --tools read,grep,find,ls against a configured server does not produce an agent restricted to four built-ins. The MCP tools stay registered, and what remains reachable depends on exposure:

Exposure No entry names or matches the tool An mcp__… entry names it
codemode Callable from a script, not declared Same
deferred Callable from a script; tool_search can load it Same
direct Registered but inert: not declared, not callable Declared, like a built-in
hidden Unreachable Unreachable

The direct row is the one the documentation leaves implicit. A direct tool survives the allowlist but gets no declaration, so it is reachable neither by the model nor by a script. The allowlist does not override exposure; it only decides which tools are kept, and a direct tool it does not name is kept into a corner. This is observed on 1.0.4.

Reach for the pattern when you mean one server:

pi --tools read,bash,codemode,'mcp__radius__*'

--exclude-tools is the other direction and needs no mcp__ prefix: it takes patterns and applies to MCP tools as well, so --exclude-tools 'mcp__*' is the blunt version, and --no-mcp is the blunter one — no servers connect, and there are no MCP tools and no /mcp for that run. Chapter 42 needs all of this, because “a capability that is not present cannot be called” is weaker than it sounds once a session has MCP servers in it.

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: either registration site can throw on invalid input. Validate configuration before calling registerMcpServer() and report failures explicitly, especially when the call happens in an interactive command handler.

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. The 1.0.4 addition to that rule is worth knowing because it is the one that is scoped to a run: --no-mcp disables the built-in support for one invocation, and deliberately “does not affect an extension that replaces the built-in MCP support”.

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_call and tool_result handlers, including permission gates, therefore apply to MCP tools. Calls made from codemode scripts carry the codemode call ID as parentToolCallId.

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 42 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 a name-based deny-list shape: only explicitly listed calls require confirmation. The next example uses annotations to decide which calls deserve review; it is not an allowlist of trusted tools or permissions.

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((tool) => tool.name === event.toolName)?.annotations;
	const needsApproval =
		hints?.destructiveHint === true ||
		(!hints?.readOnlyHint && ((hints?.destructiveHint ?? true) || (hints?.openWorldHint ?? true)));
	if (!needsApproval) return;
	// In print or JSON mode there is no UI to approve the call.
	if (!ctx.hasUI) return { block: true, reason: `${event.toolName} requires approval, but no UI is available` };
	const approved = await ctx.ui.confirm("Allow tool call?", event.toolName);
	if (!approved) return { block: true, reason: `${event.toolName} was not approved` };
});

This adapts the approval pattern from extensions.md by refusing calls needing approval when no UI is available. It prompts for calls whose declared hints meet the predicate, including MCP calls. The server supplies those hints, so they are not an independent safety assessment: a falsely declared readOnlyHint can bypass this predicate. For an enforceable policy, restrict tool and server capabilities outside the model and its annotations.

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.

To check the guard, use a disposable issue or a local test MCP server with a harmless close operation. Record whether the tool_call handler sees the fully qualified tool name and whether declining approval prevents the server from receiving the call. A missing dialog alone does not establish the cause: the model may not have issued the call, the tool may not have been exposed, or the handler may not have matched it.

What was run

examples/ch21-mcp/ connects a real Pi MCP client to a small in-memory server, so tool naming, exposure and gating are Pi’s own and only the far end is faked.

  • the tool arrives as mcp__jira__transition_issue;
  • a gate keyed on the bare name never matched, and the ticket was closed: the call succeeded and the server received it;
  • the full-name deny list blocks with no UI, and the server receives nothing;
  • with a UI the person decides: approving lets the call through and declining blocks it;
  • annotations are hints, not permission: a server that declared readOnlyHint: true on a tool named delete_everything passed the approval extension and the call reached the server, while a tool that declared nothing was blocked;
  • direct exposure declares the tool to the model, while codemode, the default, and hidden do not;
  • a tools allowlist naming only built-ins does not remove MCP tools: with read, codemode a codemode- and a deferred-exposure tool both stayed callable from a script and the server heard the call, while neither was declared to the model;
  • a direct-exposure tool kept but not named is inert: registered, not declared, and a script calling it failed with Available: read, so the allowlist does not rescue it;
  • an mcp__jira__* entry declared that direct tool, still left a codemode-exposure tool undeclared, and did not make a hidden one reachable — the pattern filters, exposure decides;
  • --exclude-tools 'mcp__*' took the tool out of even the registered set, and disabledBuiltinExtensions left an injected MCP extension alone;
  • pi.registerMcpServer() makes a server’s tools callable, and after pi.unregisterMcpServer() the server receives no further calls.

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.