Pi said no to MCP for most of a year, then shipped it just before 1.0.
This appears to be a reversal.
Earendil, the company that now ships Pi, put the objection of the anti-MCP crowd in the title of its own write-up, "You Said No MCP!", and pi.dev links it as "Why we changed our minds".
Pi, the harness of choice for true connoisseurs, now supports something it refused. But how did it actually add MCP?
Since 2025 Pi has kept MCP tool schemas and raw results out of what the model reads. If an MCP server is connected the traditional way, all its tools land right in front of the model, unfiltered, polluting context and driving token spend. In a default Pi 1.0 install, with MCP, they land in a sandbox the model drives through code. That's it.
The Refusal
Mario Zechner laid out his position in November 2025:
pi does not and will not support MCP.
The popular servers he cited load their full tool descriptions into context on every session.
- Playwright MCP is 21 tools and 13.7k tokens.
- Chrome DevTools MCP is 26 tools and 18k tokens.
He had written up the alternative in an earlier post. Agents are already good at running bash and writing code. Bash and code actually compose, so let the agent call CLI tools and write code. And results from an MCP server "have to go through the agent's context to be persisted to disk or combined with other results." In a script, joins and disk writes happen inside the script, and the model reads the output.
By February 2026 he was conceding MCP has its uses, three of them:
- a remote transport for people without a terminal,
- auth kept away from the model, and
- statefulness.
None of the three has anything to do with his complaint, which was about schemas and results sitting in context.
What Shipped
Zechner joined Earendil in April 2026 and Pi with him. On 29 September, version 0.99.0 made MCP a built-in, and 1.0 followed two days later. Earendil's post still complains about composability:
The biggest issue with MCP continues to be that it's hard to compose.
Their read on the ecosystem picks up the 2025 complaint: many servers are still built for harnesses that put every tool into context.
MCP in Pi exposes a server's tools to a JavaScript sandbox, as Codex does.
The tool is called codemode (Cloudflare named the pattern in September 2025).
Its input is a JavaScript program written by the model, and Pi runs it in a QuickJS sandbox compiled to WebAssembly, without a filesystem, network or timers.
Codemode is off in a bare install. Configure a server with the default exposure, codemode, and the MCP extension switches it on. A direct tool call is something you turn on by hand, per server or per tool. For a configured server the model's context gets one system-prompt entry: how to reach its tools, and one line on what the server offers. Tool names and argument schemas stay out. The script looks them up when it needs them.
- The model writes a script and calls
codemodewith it. - Pi runs the script in the sandbox. It's plain JavaScript,
so it does what the model wrote, with the helpers Pi documents:
searchTools()to find a tool,describeTool()to read its arguments,tools.mcp__<server>__<tool>(args)to run it. The first two are lookups over tool metadata. The third is Pi executing the real tool on the harness side and handing the result back into the script. - The script returns a value, and that value is what lands in the model's context as the
codemodeoutput. The raw data stays in the sandbox. - The model reads it and answers, or writes the next script.
One turn, and the tool is found, its schema read, the call made and a filtered summary returned. The model can also spread the work over several turns, say one script that returns a schema and a second that uses it.
Against the GitHub server, the second of those turns would look like this:
const search = tools.mcp__github__search_issues;
const count = async (state) => {
const result = await search({ owner: "earendil-works", repo: "pi", query: `is:${state}` });
return JSON.parse(result.content[0].text).total_count;
};
const [open, closed] = await Promise.all(["open", "closed"].map(count));
return { open, closed };
The tool name would have come out of searchTools() a turn earlier, the argument shape out of describeTool().
The two GitHub calls and all their JSON stay inside the sandbox, and the model gets back one small object.
With classic MCP all of this runs through the model: schemas sit in context, every call is a round trip, and every result comes back into context.
Between codemode and a direct declaration sits deferred. A tool_search call loads the matching schemas into context for the next turn
(up to eight by default).
The GitHub MCP server, v1.14.0 from 2 October 2026, has 46 tools and roughly 11k tokens of definitions in its default set. With every toolset on, I tally up 91 tools and about 23k tokens. Declare it directly and you pay that on every turn. Through codemode the same server costs one line, plus whatever scripts the model writes and whatever they return. Codemode's own tool declaration is a fixed cost once it is on, the same for one server as for ten. The scripts are output tokens, the expensive kind, and what they return comes back into context, so the saving is smaller than 23k suggests. The end-to-end cases summarized in The Agent Wall still cut tokens by around two orders of magnitude.
How the GitHub numbers were measured
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["tiktoken"]
# ///
"""Measure what the GitHub MCP server's tool definitions cost in context.
uv run measure-github-mcp.py dump [--toolsets=all] > tools.json
uv run measure-github-mcp.py tiktoken tools.json ...
dump talks to the server's Docker image over stdio and needs
GITHUB_PERSONAL_ACCESS_TOKEN set (`gh auth token` works). MCP_IMAGE overrides
the image; the default is the v1.14.0 tag the post measured.
tiktoken counts the compact JSON of name, description and schema, in the
shape the Claude API takes tool definitions. Its o200k_base encoding stands
in for Claude's tokenizer, so the counts are approximate.
"""
import itertools
import json
import os
import subprocess
import sys
import tiktoken
IMAGE = os.environ.get("MCP_IMAGE", "ghcr.io/github/github-mcp-server:v1.14.0")
def send(proc, message):
proc.stdin.write(json.dumps(message) + "\n")
proc.stdin.flush()
def call(proc, rid, method, params):
"""Send one JSON-RPC request and return the result of the matching response."""
send(proc, {"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
for line in proc.stdout:
if line.startswith("{"):
message = json.loads(line)
if message.get("id") == rid:
return message["result"]
sys.exit("server closed the pipe:\n" + proc.stderr.read()[-2000:])
def dump(server_args):
"""Print the server's complete tool list as JSON."""
cmd = ["docker", "run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", IMAGE, "stdio", *server_args]
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
info = call(proc, 1, "initialize", {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "measure", "version": "0"},
})
send(proc, {"jsonrpc": "2.0", "method": "notifications/initialized"})
tools = []
cursor = None
for rid in itertools.count(2):
page = call(proc, rid, "tools/list", {"cursor": cursor} if cursor else {})
tools += page["tools"]
cursor = page.get("nextCursor")
if not cursor:
break
proc.terminate()
json.dump({"serverInfo": info["serverInfo"], "args": server_args, "tools": tools}, sys.stdout)
def count(paths):
"""Print the token count of each dump."""
enc = tiktoken.get_encoding("o200k_base")
for path in paths:
with open(path) as f:
d = json.load(f)
tools = [
{"name": t["name"], "description": t.get("description", ""), "input_schema": t.get("inputSchema", {})}
for t in d["tools"]
]
tokens = len(enc.encode(json.dumps(tools, separators=(",", ":"))))
print(f"{path}: {d['serverInfo']['version']}, {len(tools)} tools, {tokens} tokens")
def main(argv):
if argv[:1] == ["dump"]:
dump(argv[1:])
elif argv[:1] == ["tiktoken"] and argv[1:]:
count(argv[1:])
else:
sys.exit(__doc__)
if __name__ == "__main__":
main(sys.argv[1:])
Same Old Wall
The line between sandbox and context keeps getting drawn, under other pressures: cost, model performance, non-determinism, prompt injection. Each one ends up in about the same place. The model plans, a sandbox executes, and data crosses back once, distilled. I collected some cases in The Agent Wall, and this is the same wall seen from the protocol side.
Sandboxing MCP servers predates Pi 1.0. Cloudflare's Agents SDK did it in September 2025, and Earendil's post names Codex. Pi just makes it the default, for every server.
Pi changed its answer and kept the wall. MCP fits inside it.
Whether anyone should use MCP at all is a different post.