MCP 与 Codemode(1.5)
TL;DR:早期 Pi 的 README 明确写着 "No MCP",主要顾虑是 MCP 会把一大堆工具 schema 塞进上下文。从 0.99(1.0 前夕)起,Pi 以内置扩展的形式加入了 MCP 客户端,同时加入了 Codemode 来化解这个顾虑。MCP 服务器默认不直接声明给模型,而是以
codemode方式暴露:模型写一段 JavaScript,在 QuickJS 沙箱里用searchTools()找工具、并行调用tools.mcp__server__tool()、过滤结果,最后只有脚本输出进入上下文。小而常用的工具可以设成direct,大服务器可以设成deferred交给tool_search按需加载。这三块都走公开的 ExtensionAPI,可以整体关掉(--no-mcp、-builtin:mcp)或被第三方扩展替换。代价是产品面变大了:OAuth、暴露模式、worker 并发这些都成了 Pi 要维护的东西。
它解决什么问题
MCP 的价值在于一个已有生态:GitHub、Sentry、Figma、各家内部工具都提供 MCP 服务器,用户在 Claude Code、Cursor 里已经配好了。不支持 MCP,用户就得为每个服务自己写扩展或 CLI 包装。
传统接法的问题也很明显。把每个 MCP 工具都声明成模型可见的工具,一个 GitHub 服务器就是几十个工具定义,每轮请求都要带上它们;每次调用都是一次完整的模型往返,返回的大 JSON 原样进入上下文。工具越多,模型越难选对,上下文越贵,prompt cache 也越容易因为工具列表变化而失效。
Pi 的回答分两层:
- MCP 客户端解决"连上":读配置、连接 stdio/HTTP 服务器、OAuth 登录、把服务器工具注册成 Pi 工具。
- Codemode 解决"怎么用才不贵":工具不必声明给模型,模型写脚本调用;中间结果留在沙箱里,只有脚本输出进入上下文。
再加一个配套的 tool_search:在完全声明和完全通过脚本调用之间,提供"先搜索、再把命中的工具声明给下一次调用"的折中方案。
组成与运行形态
全部运行在 Pi 进程内,没有新的常驻服务:
packages/mcp(@earendil-works/pi-mcp):独立的小型 MCP 客户端,不依赖官方 MCP SDK,也不依赖其他 pi 包。提供 stdio 和 Streamable HTTP 两种传输、OAuth 子集,以及toLlmContent()把CallToolResult转成模型可读内容。不支持旧版 SSE 传输。packages/codemode(@earendil-works/pi-codemode):在 QuickJS(编译为 WebAssembly)里运行模型写的 JS,唯一的能力是调用宿主注入的函数。Node/Bun 上每次执行占用一个 worker 线程,另有 Cloudflare Durable Object 版本和在调用线程内运行的版本。同样不依赖 pi。- coding-agent 的四个内置扩展:
builtin:mcp、builtin:codemode、builtin:tool-search、builtin:llama.cpp。它们和第三方扩展用同一套ExtensionAPI(注册工具、命令、事件处理),默认加载;pi config里能看到,-builtin:mcp可以关掉,第三方扩展注册了/mcp或同名的codemode、tool_search工具时,内置版本让位。 - CLI:
pi mcp add|remove|list|login|logout不启动会话、不加载扩展,直接操作配置文件;会话内用/mcp查看、登录、重连、调整暴露方式。
这个分层是 Pi 一贯的做法:协议客户端和沙箱是可以单独复用的库,产品集成只是一个扩展,核心循环(pi-agent-core)并不知道 MCP 的存在。
配置:两层 mcp.json,项目层要信任
服务器配置在 <agent-dir>/mcp.json(用户级)和 .pi/mcp.json(项目级),格式与 Claude Desktop、Cursor 的 mcpServers 一致,可以直接复制过来。规则与 settings 类似(见 配置体系),但合并语义是按服务器名:
- 项目级配置只在项目被信任后读取。stdio 服务器就是一条会被执行的命令,克隆来的仓库不能自动运行它。
- 同名时项目条目整体替换用户条目;如果项目条目里没有
command、url、type,就只覆盖enabled、exposure、toolExposure,保留用户层的env、headers、auth。典型用法是"在这个项目里关掉某个个人服务器",而不会把凭据也冲掉。 env、headers支持${VAR}和!command,所以 token 不必明文写进文件。文档建议带凭据的服务器只放用户级。- 单个条目无效只会报告并跳过,不影响其他服务器连接。
- 扩展可以用
pi.registerMcpServer()为当前会话加服务器;同名时文件配置优先。
OAuth:远程服务器拒绝未认证连接时,/mcp 会提示登录。Pi 通过受保护资源元数据(RFC 9728)发现授权服务器,校验 issuer,然后用 pi.dev 上的 Client ID Metadata Document 或动态注册来标识自己。token 存在 <agent-dir>/mcp-auth.json,过期或被拒绝时自动刷新;服务器要求更多 scope 时重新登录。这和模型 provider 的 auth.json 是两套独立的凭据。
命名:每个工具注册为 mcp__<server>__<tool>,非法字符替换为 _,冲突时加 hash 后缀。服务器名也是命名空间,describeNamespace("mcp__github") 和 --tools 'mcp__github__*' 都按它工作。
连接时机:会话开始时后台连接所有启用的服务器,不阻塞启动。第一次 prompt 最多只等 10 秒,而且只等含 direct 工具的服务器,因为那些工具必须出现在请求的工具声明里。其他服务器在真正需要时才等:codemode 脚本提到了 mcp__x,或调用了 searchTools()。
暴露模式:同一个工具,四种到达方式
exposure(服务器级)和 toolExposure(按工具名或通配符覆盖)决定模型怎么够到一个 MCP 工具:
| 模式 | 模型看到什么 | 适合 |
|---|---|---|
codemode(默认) | 看不到声明;只能在 codemode 脚本里通过 searchTools() / ALL_TOOLS 找到并调用 | 一般服务器,尤其需要组合、过滤调用时 |
deferred | 先看不到;tool_search 命中后,下一次模型调用时声明 | 大服务器,但希望找到后直接调用 |
direct | 像内置工具一样直接声明,脚本里也能调 | 少量高频工具 |
hidden | 注册了但不可达 | 屏蔽危险工具,如 delete_* |
默认值选 codemode,是整个设计的关键:接入再多服务器,模型的工具声明列表也不变长。为了让模型知道有哪些服务器,系统提示词里有一个 mcp_servers 段,每个服务器一行描述(来自配置的 description 或服务器 instructions)。服务器连上后描述变了,Pi 会把新的段追加到对话里,而不是修改工具声明,这样前面的消息仍然能命中 prompt cache。基于同样的考虑,codemode 工具的描述里也不列 MCP 工具,因此它不会随服务器陆续连上而变化。
有 codemode 暴露的服务器连上时,Pi 自动启用 codemode 工具;有 deferred 暴露时,自动启用 tool_search。两者都没启用、导致非 direct 工具不可达时,Pi 会警告一次。
所有 MCP 调用都走 Pi 的统一工具管线:扩展的 tool_call / tool_result 处理器(包括权限确认)同样作用于 MCP 工具,脚本里发起的嵌套调用会带上 parentToolCallId。服务器声明的 readOnlyHint / destructiveHint 等注解会透传给扩展,供权限扩展判断哪些调用需要确认。也就是说,codemode 并没有绕过权限:脚本里的每一次 tools.x(),在 Pi 看来都是一次普通的工具调用。
场景:一个脚本完成十次工具调用
用户接了一个 GitHub MCP 服务器(几十个工具,默认 codemode 暴露),问:"最近两周带 bug 标签、涉及 auth 的 issue 有哪些?按严重程度排一下。"
-
模型看到的:系统提示词的
mcp_servers段里有github一行;工具列表里只有read、bash、edit、write和codemode。GitHub 的几十个工具一个都没有声明。 -
模型写脚本,作为
codemode工具的输入(就是 JS 源码本身):// 示意:假设该服务器在 structuredContent 里返回结构化结果 const [found] = await searchTools("list issues", { namespace: "mcp__github", limit: 1 }); const res = await tools[found.name]({ owner: "acme", repo: "api", labels: ["bug"], since: "2026-10-22" }); const issues = res.structuredContent.items.filter((i) => /auth|login|token/i.test(i.title + i.body)); const details = await Promise.all(issues.map((i) => tools.mcp__github__get_issue({ owner: "acme", repo: "api", number: i.number }))); return details.map((d) => ({ n: d.structuredContent.number, title: d.structuredContent.title, comments: d.structuredContent.comments })); -
Pi 执行:脚本进入一个 codemode worker(整个进程默认最多 2 个,超出就排队)。每个
tools.*调用通过 worker 边界回到宿主,走正常的工具管线,命中 MCP 扩展,再由 pi-mcp 客户端发给服务器;Promise.all里的调用是真正并行的。几百 KB 的 issue JSON 只存在于沙箱内存里。 -
回到模型的:只有
return的那份精简列表,外加Script completed和耗时。模型据此排序、回答。
对比传统方式:同样的任务要几十个工具声明常驻上下文,外加 1 次列表调用和 N 次详情调用的模型往返,每次的完整 JSON 都留在历史里。Codemode 把 N 次往返压成 1 次,并把"过滤和组合"这一步从模型的上下文挪进了确定性的代码。
Codemode 的设计要点
- 沙箱只有一种能力:调用工具。QuickJS 里没有 Node API、文件系统、网络和定时器;对外界的一切访问都必须经过
tools.*(以及调用分类器、图像等非聊天模型的models.*),所以权限模型不变。脚本 VM 内存上限 256 MB,不能再嵌套启动 codemode。等待一个永远不会完成的 promise(没有挂起的工具调用、又没有定时器)会立即失败,不会卡住。 - 找工具靠检索,不靠全量列出。
ALL_TOOLS列出全部可调用工具;searchTools()用 BM25 排序;describeTool()返回 TypeScript 声明;describeNamespace()返回服务器说明和工具名。codemode工具自身的描述里,内置和扩展工具的声明共享一个预算(codemode.inlineBudget,默认约 3000 token),放不下的留给检索。 - 结果是结构化的。有输出 schema 的工具在脚本里返回结构化值:
bash返回{output, exit_code, ...},非零退出码也正常返回;MCP 工具返回完整的CallToolResult,isError在脚本里不抛异常,交给脚本判断。脚本拿到的bash输出上限是 1 MiB,而模型直接看到的只有 50 KB / 2000 行,所以更适合在脚本里过滤大输出。 - 跨调用状态存进会话树。
store(key, value)在脚本成功后以codemode-store自定义条目写入会话,恢复会话后仍在,每个分支只看到自己路径上写入的值(见 会话树与上下文压缩)。 - 两种模式。
codemode.mode: "on"(默认)下,原有工具保持声明,只在描述里补一句"也可以在脚本里调用";"only"下把它们都从模型侧隐藏,模型只能通过脚本调用。 - 非 LLM 模型的入口。分类器、图像生成模型不出现在
/model,模型通过脚本里的models.classify()/models.generateImages()使用,费用计入会话(见 模型与认证)。
它的局限也要说清楚:模型必须会写正确的 JS,脚本出错要再来一轮;工具调用是真实发生的,脚本中途失败时已完成的调用不会回滚;用户在 UI 上看到的是"一个 codemode 调用"加若干嵌套调用,不如逐个工具调用直观;worker 有并发上限,进程内跑多个子 agent 时需要调大 PI_CODEMODE_MAX_WORKERS。对单次、小结果的调用,direct 反而更省事。
和"最小 harness"哲学的张力
这一节值得在面试里坦白讲。Pi 的自我定位是 "a minimal, extensible agent harness"。0.8x 时代的 README 在 Philosophy 一节里明确写着 "No MCP.":要么写带 README 的 CLI 工具配合 skills,要么自己写扩展接 MCP,理由见作者的文章 What if you don't need MCP。核心论点是模型本来就会用 bash,按需读文档即可,不必为常驻的工具 schema 付上下文成本。0.99 内置 MCP 是一次立场调整,但调整方式仍然体现了原则:
- 没有进核心。MCP、codemode、tool_search 都是内置扩展,用的是任何第三方扩展都能用的 API;
pi-agent-core和pi-ai不知道 MCP 的存在。可以整体关闭(--no-mcp、-builtin:mcp),也可以被pi-mcp-adapter这类第三方扩展完全接管。 - 默认配置针对的就是当初的反对理由。默认
codemode暴露意味着接入 MCP 不会增加工具声明;系统提示词变化以追加方式处理,保护 cache。 - 协议和沙箱做成零依赖的独立包,不引入官方 SDK 的依赖树。
但张力是真实存在的。内置功能的面积明显变大:OAuth 的一串 RFC 细节(CIMD、DCR、RFC 9207 的 iss 校验、step-up scope)、四种暴露模式加按工具覆盖、/mcp 管理界面、worker 并发与排队、资源工具……这些都是 Pi 团队要长期维护、用户要理解的概念。"最小"从"不提供"变成了"提供,但默认克制、可以关掉"。对面试来说,比较好的表述是:Pi 的最小主义针对的是核心和默认的上下文成本,而不是功能清单。当一个生态(MCP)的需求大到用户必然会自己接时,与其让每个人各写一个质量参差的适配器,不如官方用扩展机制做一个默认克制、可替换的版本。
常见追问
- 为什么默认不是
direct,也不是deferred?direct会让工具声明随服务器数量线性增长;deferred每次用到都要先tool_search一轮,再在下一次调用时声明,并且声明会留在该分支上。codemode让发现、调用、过滤在一个脚本里完成,上下文最稳定,所以作为通用默认值。 - 为什么用 QuickJS 而不是 Node 的
vm或子进程? 要的是"除了注入的函数什么都没有"的隔离:没有require、没有网络、能限制内存、能中断。Nodevm不是安全边界,子进程又太重。QuickJS 编译成 WASM 后在 worker 里运行,这几点都能满足,同一套 API 还能跑在 Cloudflare 上。 - Codemode 会绕过权限扩展吗? 不会。脚本里的每个
tools.x()都回到宿主的工具管线,tool_call钩子照常触发,并且能通过parentToolCallId看出是哪个脚本发起的。 - 项目里的
.pi/mcp.json有什么风险? stdio 服务器就是任意命令,所以它和项目扩展一样放在信任闸门之后;未信任的项目不会读取,用户级服务器则不受影响。 - SDK 嵌入时能用吗? SDK 会话默认不加载内置扩展,需要自己把 MCP、codemode、tool-search 扩展加进资源加载器;或者完全不用 coding-agent,直接用
pi-mcp加pi-codemode两个独立包,自己包装成AgentTool。
源码入口(v1.5):packages/coding-agent/src/extensions/mcp/runtime.ts、packages/coding-agent/src/core/mcp-servers.ts、packages/coding-agent/src/extensions/codemode/tool.ts、packages/codemode/README.md、packages/mcp/README.md、packages/coding-agent/docs/mcp.md