Skip to content

Appendix 11

MCP Addendum: Why Pi Integrates an External Tool Protocol ​

Xiao A saw a familiar name in the terminal: MCP. The team already had a local file service, a document-retrieval service, and a remote issue system. Someone proposed writing each service as a separate Pi extension. Xiao A asked Lao Z: "Why did Pi also build MCP? Isn't implementing every tool yourself more controllable?"

Lao Z answered: "If the question is 'why did the maintainers subjectively decide to build it,' only their first-hand account can answer that — you cannot reverse-engineer author motivation from code. This addendum answers a checkable engineering question: in Pi v1.0.0, what connectivity problem does the MCP integration solve, what responsibilities does it take on, and what does it explicitly not solve?"

This addendum serves Chapter 12 of the Concepts part, Part II Chapter 8 of the Source part, and Part III Chapter 11 of the Build part: consult it before wiring up an external service, and when debugging connection or permission issues for its failure semantics. It is anchored to upstream tag v1.0.0 (commit a13d35a742c6ef8462812a28fbe1d8c8b7431c32). The key paths are the standalone client in packages/mcp/, the built-in integration in packages/coding-agent/src/extensions/mcp/, and the MCP chapter of the coding-agent docs. It derives engineering value from implementation structure, and never passes that derivation off as a maintainer statement.

Align the protocol generation first: Pi v1.0.0 implements the 2025-11-25 generation ​

Chapter 12 of the Concepts part teaches MCP 2026-07-28: stateless, each request carries version and capabilities, server/discover, no handshake, no session. Pi v1.0.0's client does not implement that generation. packages/mcp/src/protocol/types.ts hard-codes:

ts
export const LATEST_PROTOCOL_VERSION = "2025-11-25";
export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, "2025-06-18", "2025-03-26", "2024-11-05"];

McpClient.connect() follows the older generation's shape: an initialize request negotiates the version, notifications/initialized completes the handshake, and Streamable HTTP has server sessions, a server-to-client GET stream, and reconnection resumed via Last-Event-ID. Reading Pi's MCP code with "the Concepts part's stateless model" in mind will mismatch everywhere — this is the easiest misalignment between the two chapters. State the conclusion per version: Pi v1.0.0 connects to MCP 2025-11-25 and earlier generations; the 2026-07-28 generation from the Concepts part is not yet implemented in this snapshot.

Bound the "why" to what is provable ​

MCP describes external services' declared tools and resources as a common protocol. Pi's @earendil-works/pi-mcp implements the client core, stdio and Streamable HTTP transports, and tool-call and resource-read interfaces; coding-agent then adapts the discovered tools into Pi tool definitions. The benefit directly verifiable from code: a service author can expose capabilities through the protocol, and Pi need not write a separate wire-protocol client for each service.

That does not mean MCP eliminates integration work. Pi still handles connection lifecycle, configuration, authentication, result conversion, tool exposure, failure, and permission hooks; the protocol lets both sides exchange a shared message shape, but the host still owns product policy. When a tool's function is small, the service is fixed long-term, and it needs a tight bespoke interface, a native extension may be the better fit. A protocol is not the only answer.

Which path an MCP tool takes inside Pi ​

text
mcp.json / extension registration
        ↓
MCP extension reads config and starts connecting
        ↓
pi-mcp client + stdio or Streamable HTTP
        ↓ tools/list
MCP tool descriptions → Pi ToolDefinition
        ↓ tools/call
CallToolResult → Pi text / image / structured result
        ↓
Pi tool events, rendering, usage, and permission extensions

McpServerConnection owns the protocol connection; createMcpToolDefinition() adapts the server's name, description, input schema, and annotations into a Pi tool; execution sends requests through the MCP client's callTool(). Pi's Agent loop sees ordinary tools — the loop itself need not understand MCP's JSON-RPC messages. Tool names follow mcp__<server>__<tool>, with non-alphanumeric-underscore characters replaced by _; tools that collide after normalization all get a hash suffix; two server names differing only by - versus _ count as the same server.

This adaptation boundary has real value: the loop, TUI, and session need no new execution semantics per external service. Different MCP services still need their own config and permission decisions, but they share one discovery-and-call path.

A unified connection method; the deployment boundaries still differ ​

Pi supports two transports, both implementing the same McpTransport interface:

TransportService formLifecycle and failure boundary
stdioPi launches a local subprocess, exchanging JSON-RPC over stdin/stdoutsubprocess fails to start, exits, pipe breaks; on stop it closes stdin then kills the whole process tree — a signal to the process group on POSIX, taskkill /T /F on Windows, so npx/uvx wrappers leave no orphans
Streamable HTTPconnects to a remote MCP servicenetwork errors, auth, timeouts, HTTP status, session expiry; during connection setup, transient statuses (408/429/5xx) retry at 250ms then 1s

The legacy SSE transport is unsupported: writing sse in the config is rejected outright, and the docs' migration path is to use the /mcp streamable-HTTP endpoint the server usually also provides.

The two transports share the client contract but do not erase the local-versus-remote trust difference: a stdio config makes Pi start a local process; an HTTP config sends requests to a chosen service. Trusting a server means trusting the capabilities it actually runs or can reach on its side. One direction that is easy to overlook: as a client, Pi declares the roots capability and sends the current session directory to the server (roots: [{ uri: pathToFileURL(cwd) }] in connectOnce). The directory path therefore leaves the local machine and enters the remote server's view — that is a client capability, not a filesystem sandbox.

Configuration matches the common mcpServers shape. User-level servers come from ~/.pi/agent/mcp.json, project servers from .pi/mcp.json; project config is read only after project resources pass Pi's trust gate, and a same-named project entry overrides the user one. A server's default per-request timeout is 60 seconds, reset by progress notifications; enabled: false keeps the entry without connecting. Extensions can also register a server for the current session with pi.registerMcpServer(), with file config winning on a name clash; the pi mcp shell commands do not load extensions and see only file config. Inversely, an extension that registers /mcp (such as pi-mcp-adapter) replaces the built-in MCP support entirely — Pi then stops reading mcp.json; SDK sessions do not load built-in extensions by default, so to use MCP you must add the MCP, codemode, and tool-search extensions explicitly.

A many-tool service cannot all be stuffed into every request ​

If a server has dozens or even hundreds of tools, handing the model all names, descriptions, and schemas inflates the prompt and makes relevant capabilities harder to find. Pi therefore gives MCP tools four exposure levels:

ExposureHow the model reaches itUse
directthe tool appears in the model's tool lista small set of frequent tools
codemode (default)not declared to the model, not listed in the codemode description; scripts discover and call them via searchTools() etc.the default, good for batching, filtering, and cross-tool composition
deferredloaded into the next request by tool_search, then callable directlylarge catalogs, called directly after discovery
hiddenregistered but unreachabletools to keep unavailable

Two details are easy to misread. First, the two indirect mechanisms reach each other: a codemode script can call deferred tools, and tool_search can also load codemode tools — the level decides which discovery entry to activate, it does not lock a tool into a single channel (codemode-deferred is also accepted as an alias for codemode). Second, an MCP config-layer codemode maps to the pi tool layer's deferred, so the two namespaces share names but not meaning (the tool-exposure system is covered in Appendix 10, Codemode Addendum).

toolExposure overrides a server default per tool: keys are exact tool names or * wildcards, exact names win. For example, on a fully-deferred server, make search_code direct-visible and delete_* hidden. Exposure control answers "which call entry is presented to the model"; it is not a security isolation of the service's capability.

One silent coupling to note: whenever a server with codemode exposure connects, Pi activates the codemode tool (unless autoEnableCodemode: false is set); a deferred server activates tool_search. In other words, MCP configuration changes the session's tool surface. If neither is available yet indirect-exposure tools exist, those non-direct tools cannot be called, and Pi warns once at startup.

Pi connects enabled servers in the background by default. The first prompt waits at most 10 seconds and only for servers with direct tools; the rest are waited for on demand: a codemode script waits for the servers it names, and for all of them when it calls searchTools() or reads ALL_TOOLS; tool_search and the resource tools also wait for all enabled servers. This keeps one slow non-direct server from blocking the first conversation, but it also means a capability may become available only after startup.

When a server pushes a changed tool list, Pi updates registrations incrementally. Because Pi's tool registration has no unregister, withdrawn tools are re-registered as hidden rather than removed from the registry. This is a host-side reachability tightening, not a claim that the underlying server revoked credentials or stopped the capability.

MCP is not only Tool; it also includes Resource ​

An MCP server can also list Resources and Resource Templates. Pi wraps them into three tools — list_mcp_resources, list_mcp_resource_templates, and read_mcp_resource: the model usually lists first to discover URIs, then reads explicitly (read_mcp_resource itself does not verify that the URI appeared in a list; given server + uri it reads directly). In results, text goes into context and images stay images; other binaries land in a temp file with the path given. On the model side, text over 20 KiB keeps its head and tail with the middle cut and a full-text temp file attached; codemode scripts get the complete CallToolResult. The resource tools' exposure is the widest level among the reachable resource servers.

There is a checkable retry boundary here. McpServerConnection.withClient() retries only read-only requests (listing, reading resources) once after a transient HTTP error; tool calls are not retried on a transient error — "the server may already have run it." The one exception is session expiry: an HTTP 404 means the server no longer recognizes the session, so the request is provably unrun, and it retries once on a new session, leaving the old client open so its other in-flight requests each hit 404 and reconnect the same way. This "result-unknown versus provably-not-run" distinction is a textbook example of retry design for remote side effects.

MCP Prompts did not enter Pi's built-in integration: the client has no prompts/list adaptation, and coding-agent's MCP extension has no corresponding entry. MCP Apps resources — ui:// URIs or text/html;profile=mcp-app — are filtered because Pi does not render them, and resource icons are dropped too. The client README draws the line in one sentence: batch JSON-RPC, legacy HTTP+SSE, servers, sampling, and tasks are outside the initial core. So "Pi supports MCP" should be read as it implements the client subset and host integration the docs list, not the whole protocol surface.

Authentication and trust boundaries ​

Remote HTTP servers can use MCP OAuth: Pi discovers the authorization server via protected-resource metadata (RFC 9728), runs the PKCE authorization-code flow and dynamic client registration, stores tokens in ~/.pi/agent/mcp-auth.json keyed separately per server name + URL; refresh happens on expiry or rejection, with concurrent 401s sharing one refresh; when a server requests more scope (an insufficient_scope challenge) Pi asks the user to sign in again while keeping already-granted scopes. OAuth applies only to HTTP servers without an Authorization header. Two escape hatches: a server without dynamic registration can be configured with clientId/clientSecret; a server advertising the wrong (or no) authorization server can set authServerMetadataUrl to point at the metadata document directly — Pi then skips discovery and trusts it as configured, and the docs explicitly warn to point it only at a document you trust. The other path is auth: { "provider": ... }: an HTTP server uses a logged-in Provider's current token as bearer, read live on every request; this config is allowed only in the user-level file and from extensions, not in project config.

But authentication only answers "on what credentials does this request pass server-side verification," not "is this tool appropriate to hand to the Agent." MCP annotations such as readOnlyHint, destructiveHint, idempotentHint, and openWorldHint are self-reported server hints; Pi passes them through verbatim to extensions (pi.getAllTools()), they guarantee nothing, and they do not trigger confirmation by default. MCP tools pass through Pi's tool_call/tool_result extension events, where permission extensions can intercept; nested calls made from codemode scripts carry parentToolCallId, letting a permission extension attribute a call to the script that made it. Whether an intercepting extension is even installed, and how it fails, remain separate host-configuration questions.

Trust also covers the local config surface, and it is wider than "reading a JSON":

  • Once a project's .pi/mcp.json is trusted, a stdio entry makes Pi launch the configured command — project config is not a harmless manifest, it is a surface that affects process startup.
  • Besides ${VAR}, env and headers values also support !command: when the whole value starts with !, Pi runs it through a shell and uses the output. A single ! line in a trusted project is local command execution.
  • The roots capability sends the session's directory path to remote servers.

Trust boundary quick reference

  • An MCP schema describes argument shape; it does not prove the service is trustworthy, nor define user permissions.
  • An OAuth token proves an authenticated identity, not that an Agent call is business-appropriate.
  • readOnlyHint and friends are server hints, not trustworthy security labels.
  • Trusted project config can launch a local process, run !command, or connect to a remote service.
  • Tool results and resource content remain external data that may carry prompt injection.

What engineering value can be derived from the implementation ​

The v1.0.0 code supports the following:

  1. Fewer per-service protocol adapters. A service describes its tools and call boundary over MCP; Pi writes one client, transports, and adaptation layer to Pi tools and can wire in many compatible servers.
  2. Composable tool capability. Once MCP tools enter Pi's tool pipeline, Agent, Codemode, tool search, renderers, and extension hooks can all use them.
  3. Catalog size controlled on demand. direct/codemode/deferred/hidden plus per-tool overrides let the host control prompt cost and call reachability.
  4. Local and remote in one integration model. stdio for local services, Streamable HTTP for remote; both share the client contract but keep their own error and trust boundaries.
  5. Resource reads become explicit actions. Resources are not poured into every request but fetched through list-and-read tools on demand, with result size controlled.

These are engineering readings of the implemented structure and configuration, not history about "why the maintainers built MCP." To cite author motivation, look for a separate upstream interview, design note, or release record.

Gains and costs ​

Protocolizing makes integration reusable but adds runtime responsibility. Pi must manage multi-service connections, tool-name collisions, schema compatibility, list changes, OAuth, failure diagnostics, resource conversion, and exposure policy. Users must also decide, per server, whether it is trusted, per tool, whether it should be directly visible to the model, and whether a failure may be retried.

So "Pi added MCP" is not "all external tools are now safely wired." It provides one reusable discovery-and-call path and lets external tools enter through the existing tool pipeline. The protocol handles interoperability; Pi handles the client and host integration; permission and trust must be built per service, per tool.

Source Walkthrough ​

  1. Start at the version constants in packages/mcp/src/protocol/types.ts; cross-check against connect(), listTools(), and callTool() in client.ts to confirm which MCP generation Pi implements, and who owns the handshake and request correlation.
  2. Read createDefaultTransport() and open()/getClient() in extensions/mcp/runtime.ts (connectOnce() is a private step within it), comparing the stdio and Streamable HTTP failure paths; in transports/stdio.ts, confirm the platform difference in process-tree termination.
  3. Read withClient() closely: which requests retry once on a transient error, why tool calls do not, and why McpSessionExpiredError is the exception.
  4. Read createMcpToolDefinition() and toToolExposure() in extensions/mcp/tools.ts; record how MCP schema and annotations map onto Pi tools, and the correspondence between the two exposure namespaces.
  5. Back in extensions/mcp/index.ts, trace registerTools(), syncResourceTools(), ensureDiscoveryActive(), and waitForDirectServers(); draw the timeline "background connect → wait on demand → tool surface changes with config."
  6. Cross-check extensions/mcp/config.ts against oauth.ts and packages/mcp/src/oauth/flow.ts: where each fact lands — the allowed scope of auth.provider, credentials keyed by name+URL, authServerMetadataUrl bypassing discovery, and the step-up scope union.
  7. Take a project .pi/mcp.json containing a !command value and a stdio entry, and list the local capabilities it actually grants once the project is trusted.