10-MCP与CodeMode
10 MCP 与 CodeMode
读这一篇的价值:这两个机制是 Pi “工具面无限扩展但不爆上下文”的核心手段;MCP 解决“怎么接外部能力”,CodeMode 解决“接完以后怎么不把大量工具声明和大输出塞进上下文”。
1. MCP:连接外部工具服务
Pi 通过 stdio 或 streamable HTTP 连 MCP 服务器,把它们的工具与资源暴露给模型。旧版 SSE 传输不支持(很多服务器的 SSE 端点同时提供 streamable HTTP,通常在 /mcp 而不是 /sse)。
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 add -l tools --env API_KEY='${TOOLS_KEY}' -- uvx tools-mcp
pi mcp list
- 默认写用户级配置;
--local/-l写项目配置。 - 会话内用
/mcp查看连接、登录、重连、改曝光、启停;外部改了配置后/reload。 - 不启动会话也能用
pi mcp add|remove|list|login|logout;shell 命令不加载扩展。 pi mcp list会连所有启用的服务器;有非法项或启用的服务器未连上时退出码为 1。
配置文件
用户级 ~/.pi/agent/mcp.json,项目级 .pi/mcp.json(授项目信任后才读)。同名时项目条目替换用户条目。格式与主流 MCP 客户端一致:
{
"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"
}
}
}
- stdio 用
command/args/env/cwd;相对cwd相对会话目录解析,~/前缀在command、参数、cwd里都表示家目录。 - HTTP 用
url/headers/oauth。 - 两者都支持:
timeout(每次请求秒数,默认 60,进度通知会重置)、enabled: false(保留条目不连接)、exposure/toolExposure、description(一句话说明服务提供什么)。 env与headers的值支持${VAR};也支持!command,但必须占满整个值,例如"Authorization": "!echo Bearer $(gh auth token)"。- 凭据类服务器放用户级文件;项目文件只放项目确实需要的服务器,且只用已授信的。
配置规则(违反会报错并跳过该条,但不阻碍其他服务器连接):
- 服务器名只能含字母、数字、
_、-;工具名形如mcp__<server>__<tool>,其中非字母数字下划线的字符全部换成_;转换后同名的工具会追加 hash 后缀。名字只在-和_上不同的算同一个服务器(第二个被拒),且mcp.json里条目会覆盖注册的服务器。 type可选,可为stdio/http/streamable-http;有command即 stdio,有url即 streamable HTTP。command是单个可执行文件,args才是它的参数(不是 shell 命令字符串)。sse被拒绝。
连接时机与等待策略
- 会话启动时后台连所有启用的服务器;服务器的工具连上后才出现。
- 第一次提问只最多等 10 秒:等那些有
direct工具的服务器(它们的工具必须写进本次请求)。 - 其他服务器在需要时才等:codemode 脚本等它显式提到的
mcp__<server>;调searchTools()或读ALL_TOOLS时等全部;tool_search与资源工具也等全部。 - HTTP 网络错误与 408 / 429 / 5xx 重试两次;连接丢弃显示为 disconnected,下次调用时重连;服务器声明工具列表变化时,新增工具被加入、撤销的工具变为不可达。
- 停 stdio 服务器:先关 stdin,再 SIGTERM,再对进程组发 SIGKILL(所以
npx/uvx包装启动的子进程也会一起停)。
诊断:/mcp 显示完整连接错误与失败 stdio 服务器的 stderr 尾部;服务器日志通知落到 ~/.pi/agent/mcp.log(超过 5 MB 轮转为 mcp.log.1)。
从其他客户端迁移
| 客户端 | 转换方式 |
|---|---|
| Claude Desktop / Claude Code / Cursor | 直接搬 mcpServers 条目 |
| VS Code | 把顶层 servers 里的条目搬过来,${input:...} 换成 ${NAME} |
| Codex | 把 [mcp_servers.<name>] 的 TOML 字段转成 JSON 字段 |
| OpenCode | "type":"local" → stdio 条目(command 数组拆成 command + args,environment → env,{env:NAME} → ${NAME});"type":"remote" → URL 条目 |
换过去后跑 pi mcp list 验证。
OAuth 认证
需要 OAuth 的远程服务器(如 Sentry)不用在配置里写凭据:
{ "mcpServers": { "sentry": { "url": "https://mcp.sentry.dev/mcp" } } }
- 服务器拒绝未认证连接时,
/mcp会显示需登录;选 “Sign in”、/mcp login sentry或pi mcp login sentry。浏览器在另一台机器(比如 SSH 场景)时,把重定向后的 URL 粘回登录界面。 - Pi 自我注册为
pi,令牌存~/.pi/agent/mcp-auth.json,过期或被拒时自动刷新;服务器后来要求更多 scope 会再次要求登录;登出删除凭据。 - 凭据属于“服务器名 + URL”:同 URL 不同名分别登录;同名同 URL 出现在不同
mcp.json则共享一次登录。 - OAuth 只对没有
Authorization头的 HTTP 服务器生效。服务器不支持动态客户端注册时,配oauth.clientId/clientSecret/callbackPort(或callbackUrl,必须是 localhost / 127.0.0.1 /[::1]上的 HTTP);clientSecret可选且支持环境变量或命令。 - 服务器不宣传所需 scope 时用
scope显式列出(空格分隔)。 - 有些服务器只接受已知客户端注册,用
oauth.clientName换个名字发送(只在注册时发送;想换名重注册得先登出)。 - Pi 按 RFC 9728 找授权服务器、按 RFC 8414 校验 issuer;服务器宣传了错的或没有授权服务器时,用
oauth.authServerMetadataUrl指定元数据文档(要求 HTTPS,localhost 除外)——Pi 直接信任该文档,只指向你信任的。
工具曝光(关键设计)
每个服务器工具有一个曝光模式,决定模型以什么途径触达它:
| 曝光 | 行为 | 典型场景 |
|---|---|---|
codemode(默认) | 可在 codemode 脚本里调用;既不向模型声明,也不列在 codemode 描述里,脚本用 searchTools() / describeTool() / ALL_TOOLS 找 | 通用 MCP 服务器,尤其是需要脚本组合/过滤调用的 |
deferred | 不声明,直到 tool_search 找到匹配并声明给下一次模型调用 | 工具很多、发现后希望直接调用的服务器 |
direct | 像内置工具一样向模型声明,同时也能从 codemode 调用 | 小而常用的工具集 |
hidden | 已注册但不可达 | 应该保持不可用的服务器或工具 |
- 有
codemode/deferred工具的服务器会列在系统提示词的mcp_servers段里,含“如何触达”与描述/服务器说明的一行。该段在每次提问开始时更新;变更时 Pi 追加新段而不是改工具声明,以保住前面的缓存。 - 服务器有一个
codemode曝光工具连上时 Pi 自动激活codemode;有deferred时激活tool_search。想让模型直接看到某个工具,就给toolExposure里那个工具direct。 toolExposure按单个工具覆盖服务器曝光;键是精确服务器工具名或带*的模式,精确名优先于模式,模式里第一个匹配生效。hidden的服务器也能只暴露选定工具:
{
"mcpServers": {
"github": {
"url": "https://api.githubcopilot.com/mcp/",
"exposure": "deferred",
"toolExposure": { "search_code": "direct", "get_*": "codemode", "delete_*": "hidden" }
}
}
}
codemode与deferred工具两种间接机制都能到:codemode 脚本可调,tool_search也可加载。codemode 调用不依赖活动工具集,所以/tree、resume、fork 之后依然可用;tool_search加载的工具会记在转录里,在该分支上保持已声明。- 想在没有 MCP 服务器时也保持
codemode活动:"defaultTools": ["+codemode"]。想禁止自动激活:在mcpServers同级设"autoEnableCodemode": false(项目值覆盖用户值)。两个工具都没活动、非 direct 工具无法调用时 Pi 会警告一次。 - 超过 20 KB 的文本结果到达模型时中间被挖掉并标
…N chars truncated…,完整文本存到结果里给出的临时文件路径。codemode 脚本拿到的是完整结果,可自己压缩后再返回。
资源(resources)
服务器提供资源时,Pi 加上 Codex / OpenCode 用的三个工具:
list_mcp_resources:列出资源(JSON:{ server?, resources, nextCursor? });带server列单个的下一页,不带则列所有服务器的资源。list_mcp_resource_templates:列出服务器没有直接列出的 URI 模板。read_mcp_resource:按server+uri读取;文本以文本到达,图片以图片到达,其他二进制存临时文件并把路径给模型;脚本收到{ server, uri, contents }。
它们的曝光取所覆盖服务器里最宽的那个(direct 优先,其次 codemode / deferred)。MCP Apps 的资源(ui:// URI 或 text/html;profile=mcp-app)与资源图标被忽略,因为 Pi 不渲染它们。读/列资源在瞬时 HTTP 错误(408 / 429 / 5xx)后重试一次;工具调用不重试,因为服务器可能已经执行过了。
权限与扩展
- 每次 MCP 调用都过 Pi 的工具管道,所以扩展的
tool_call/tool_result处理器(包括权限门)对 MCP 工具同样生效;从 codemode 脚本发起的调用会带 codemode 的调用 ID 作为parentToolCallId。 pi.getAllTools()会报告各服务器声明的readOnlyHint/destructiveHint/idempotentHint/openWorldHint,权限扩展可以据此决定哪些调用要确认;资源工具标记为只读。- 扩展可以
pi.registerMcpServer(name, config)为当前会话加服务器(配置形状同mcpServers条目,额外支持exposure/toolExposure/description/enabled/timeout),改动只对本会话生效;文件里同名服务器优先。 - 装了注册
/mcp的扩展(如pi-mcp-adapter)会替换内置 MCP 支持:Pi 不再读mcp.json、不连它的服务器,/mcp归该扩展。不想被替换就禁用内置mcp(pi config里 Built-in 下禁用,或设置"extensions": ["-builtin:mcp"])。同理,注册codemode/tool_search的扩展会替换同名内置工具。shell 级pi mcp命令永远用内置实现。 - SDK 会话不加载内置扩展:要用 MCP 就得往 resource loader 里加 MCP 扩展(
codemode服务器还要加 codemode 扩展,deferred服务器要加 tool-search 扩展)。
2. CodeMode:把工具调用变成一个程序
codemode 让模型写一段 JavaScript 去调 Pi 的其他工具和非 LLM 模型(分类器、图像模型)。只有脚本的输出进入模型,所以它能并行调用、在模型看到之前过滤大结果。这是 Pi 应对“工具数量爆炸 / 输出量爆炸”的架构级答案,不是语法糖。
脚本形态
工具输入是原始 JavaScript 源码(不是 JSON、不是 Markdown 代码围栏)。它在 QuickJS 沙箱里以 async 函数体运行,所以顶层 await 与 return 可用。沙箱没有 Node API、文件系统、网络、定时器;脚本只能通过 tools 与 models 触达外界。
// @options: {"max_output_tokens": 2000, "timeout_ms": 60000}
max_output_tokens(默认 10000)限制输出;超长输出保留首尾,完整文本写入临时文件并把路径放进结果。timeout_ms是整个脚本的硬截止,默认未设;生图可能耗时数分钟,生图脚本不要设短超时。- 结果以
Script completed或Script failed开头,带耗时与输出;失败脚本保留部分输出,随后是Script error:与错误。工具调用是真实副作用:失败前已发生的调用不会回滚。脚本结束时还在跑的调用会被取消,未 await 的 promise 被丢弃。
全局对象
| 全局 | 作用 |
|---|---|
tools.<name>(args) | 调工具(标识符里非法字符换成 _,如 tools.mcp__dev_radius__search) |
text(value) | 往输出加文本;字符串原样,其他值转 JSON |
image(value) | 往输出加图片:base64 data: URL、{ image_url } 对象,或 { type: "image", data, mimeType } 块(MCP 工具与 models.generateImages() 返回的格式);不支持远程 URL;接受 PNG/JPEG/GIF/WebP |
console.log(...) | 同 text();info/warn/error/debug 一样 |
return value | 顶层 return 的值如同 text() 加入输出 |
exit() | 成功结束脚本 |
store(key, value) / load(key) | 跨 codemode 调用保存小 JSON 值 |
ALL_TOOLS | 所有可调用工具({ name, description }),含描述里没列出的 |
searchTools(query, { limit?, namespace? }) | BM25 相关性排序(默认 limit 8),resolve 成 { name, description }[] |
describeTool(name) | resolve 成工具描述与 TypeScript 声明,或 undefined |
describeNamespace(name) | resolve 成 { name, description?, instructions?, tools },如某个 MCP 服务器 |
models | 列出并运行非 LLM 模型 |
调用语义
- 有 output schema 的工具 resolve 成结构化值。
bashresolve 成{ output, truncated, full_output_path?, exit_code, wall_time_seconds }(非零退出码也一样),其output不受模型看到的 2000 行 / 50KB 限制:最多 1 MiB,超长保留首尾各 512 KiB 并置truncated、把完整输出写进full_output_path。 - MCP 工具 resolve 成完整
CallToolResult(含isError与structuredContent);image(result.content[0])可以转发图片块。 - 其他工具(
read/edit/write等)resolve 成文本输出。 - 失败、被阻断或参数非法的调用以携带该工具错误文本的
Errorreject;用Promise.allSettled()保住已经成功的结果。 codemode描述里按命名空间分组列出带 TypeScript 声明的工具,共享 3000 估算 token 预算(codemode.inlineBudget)。deferred曝光(含默认codemode曝光的 MCP 工具)不在描述里列,所以 MCP 服务器陆续连上时描述不变。codemode.mode决定其他工具怎么呈现:on(默认)已声明工具保持声明,其描述里附上“如何从脚本调用”;only把它们对模型隐藏、改列在codemode描述里,模型只能经脚本到达。
跨调用存储
store(key, value) 存 JSON 值,存 undefined 等于删键;load(key) 取值或 undefined。只有脚本成功时写入才保留:每个成功存值的脚本会往会话追加一条 codemode-store 自定义条目,所以恢复的会话能保住这些值,且每个分支只看到自己路径上写入的值。存储是给小状态用的(ID、游标、摘要):单值 JSON 最多 262144 字符,全部值合计最多 1048576 字符。不要用它存图片数据。
非 LLM 模型
models 用会话凭据访问模型目录并运行分类器与图像模型;chat 模型只被列出、不能在脚本里运行。关键 API:
getModelsOfType(type, provider?): Promise<ModelInfo[]> // 已知的全部
getAvailableOfType(type, provider?): Promise<ModelInfo[]> // 凭据可用的
getModelOfType(type, provider, id): Promise<ModelInfo | undefined>
classify(model, context): Promise<ClassifierResult>
generateImages(model, context): Promise<ImagesResult>
classify()/generateImages()只用model的provider与id,所以传{ provider, id }也行。它们不会因供应商错误而 throw:要自己查stopReason("stop"/"error"/"aborted")与errorMessage。- 每个脚本最多 4 个这类调用同时在跑,更多的等待空位,所以对很多项
Promise.all()是安全的。它们的 usage 加到codemode工具结果上,并计入会话成本。 - 模型 ID 因供应商而异(例如
typesafe/jev-latest与openrouter/typesafe/jev-1.13);用getAvailableOfType(type)找当前凭据下可用的 ID。 - 分类问题三种形态:
choice(criteria是标签→含义的映射)、score(criteria从最低到最高描述每级,答案是期望级别索引)、bool(criteria.true/criteria.false)。一次调用可答多个问题。
const jev = await models.getModelOfType("classifier", "typesafe", "jev-latest");
const results = await Promise.all(
messages.map((message) =>
models.classify(jev, {
state: { message },
questions: {
sentiment: { type: "choice", instructions: "How does the user feel about the product?",
criteria: { positive: "Satisfied or happy", negative: "Unhappy or frustrated", neutral: "Neither" } },
urgency: { type: "score", instructions: "How urgently does this need a reply?",
criteria: ["no reply needed", "reply this week", "reply today"] },
},
}),
),
);
return results.map((result, i) =>
result.stopReason === "stop"
? { message: messages[i], sentiment: result.answers.sentiment.choice, urgency: result.answers.urgency.score }
: { message: messages[i], error: result.errorMessage },
);
生图脚本示例(注意超时与输出方式):
// @options: {"timeout_ms": 300000}
const painter = await models.getModelOfType("image", "openrouter", "google/gemini-2.5-flash-image");
const result = await models.generateImages(painter, {
input: [{ type: "text", text: "A red fox in the snow, watercolor" }],
});
if (result.stopReason !== "stop") return result.errorMessage;
for (const block of result.output) {
if (block.type === "image") image(block);
else text(block.text);
}
不要把图片的 base64 data 用 text() / console / return 输出:它很大且模型读不懂文本;生成结果不落盘,需要保留就用工具写文件。
限制
- 脚本 VM 内存 256 MB,超了抛
InternalError: out of memory——应过滤/聚合大结果而不是堆积。 - 等待一个永远不会 settle 的 promise(且没有待完成的工具调用)会立刻失败,因为没有定时器。
- 脚本不能启动其他
codemode脚本。
3. 两者如何配合
典型链路:MCP 服务器以 codemode 曝光接入 → 模型写一段脚本 → 脚本用 searchTools() / describeTool() 找到它 → 并行调用多个 MCP 工具(Promise.allSettled)→ 在脚本里过滤/汇总 → 只把结果(而不是原始响应)返回给模型。大输出、几十个工具声明、多轮往返都被挡在上下文之外。
需要直接调用时,再通过 toolExposure 把个别工具提升为 direct;需要模型自己发现时用 deferred + tool_search。
溯源
- 官方
docs/mcp.md、docs/codemode.md(另见docs/models.md#use-classifier-models/#use-image-models)