跳转至

09 · MCP 与 Code Mode

9.1 两者解决不同问题

MCP 定义如何连接外部工具服务;Code Mode 让模型写一段 JavaScript 去组合工具。一个是连接协议,一个是执行和结果归纳方式。

传统工具调用:查 10 条数据 → 每条结果都进入模型 → 模型再总结。Code Mode:脚本并行查 10 条 → 过滤、排序、统计 → 只输出关键结果。省的是进入对话的中间数据,不会消除真实 API 调用费用。

flowchart LR M[大模型] --> C[codemode 工具] C --> VM[QuickJS WASM 脚本] VM --> P[统一工具管线] P --> L[本地工具] P --> MCP[MCP client] MCP --> EXT[stdio / HTTP server] L --> VM EXT --> MCP MCP --> VM VM --> OUT[text / image / return] OUT --> M

9.2 MCP 的组件边界

pi-mcp 是独立 client,没有依赖官方 MCP SDK。支持 stdio、streamable HTTP 与测试 transport。不支持旧 SSE server transport。

不要将 provider 的 SSE 流与 MCP 的旧 SSE transport 混淆,名称相同但层不同。

配置在 global ~/.pi/agent/mcp.json 或受信项目 .pi/mcp.json。stdio command 是单个可执行程序,args 是参数数组,不是随意 shell 字符串。

{
  "mcpServers": {
    "my-tools": {
      "command": "node",
      "args": ["/absolute/path/to/my-mcp-server.mjs"],
      "description": "查询个人知识库",
      "exposure": "deferred"
    }
  }
}

这是配置结构示例;你必须实现并提供该 server 文件,不能照抄路径就以为服务存在。

9.3 tools exposure 的选择

MCP exposure 模型初始直接看到? 怎么调用
direct 是 普通工具调用,也可 Code Mode
codemode(默认) 否,也不列在 Code Mode 描述 searchTools / describeTool 后脚本调用
deferred 否 tool_search 加入下一轮声明,也能脚本调用
hidden 否 不可达

codemode / deferred 是默认曝光与自动启用 discovery 方式的区别,不是两套绝对不可互通的调用体系;已启用的 tool_search 与脚本搜索可按曝光规则找到这两类工具。

少量常用工具 direct 更直接;大集合适合 deferred / codemode。隐藏 destructive 工具仍需服务端权限,因为 client 的可见性不是业务授权。

toolExposure 对单工具覆盖,精确名称优于 pattern,多个 patterns 首匹配优先。MCP 名称规范化成 JS identifier,碰撞要加 hash 后缀;不要自行假设替换连字符后一定唯一。

9.4 连接何时完成

MCP 在 session_start 后后台连接。首 prompt 只为 direct 工具等待最多 10 秒;其他 server 在脚本命名、searchTools 或 tool_search 时按需等待。

这种设计减少无关 server 的启动阻塞,但你不能认为「Pi 已启动」等同「所有 MCP 已连接」。UI 和应用需要显示连接状态与错误。

stdio 停止会关闭 stdin,SIGTERM 后必要时 SIGKILL 进程组;通过 npx / uvx 包装启动的子服务也要被清理。每次 session 结束都应避免遗留进程。

9.5 Code Mode 的真正环境

Pi 使用 QuickJS 编译为 WebAssembly 的 VM。脚本是 async function body,支持顶层 await / return。没有 Node、fs、network、fetch、process、require、timer;只能通过注入 tools 和 models 到外部。

这个 VM 隔离脚本自身,但一旦注入 tools.bash,脚本通过它仍然获得宿主 shell 能力。不要把「QuickJS sandbox」宣传成「整个 Agent 已安全隔离」。

CLI 内建 Code Mode 默认 VM 内存 256 MB;整段 deadline 默认未设置,宿主可以指定 timeout。独立 CodemodeSandbox 的 host 默认 deadline 为 300000 毫秒,CLI 会以自己的选项覆盖,这两个默认不能混为一谈。无限等待且无可完成 pending I/O 的 Promise 会失败。脚本不能启动另一个 codemode。

9.6 先搜索,再看声明,再调用

const matches = await searchTools("search personal notes", { limit: 3 });
text(matches);

随后针对找到的工具:

const declaration = await describeTool("ACTUAL_TOOL_NAME");
text(declaration);

ACTUAL_TOOL_NAME 是占位。看过参数声明以后再写调用,不用从名称猜 schema。namespace 摘要和说明用 describeNamespace 获取;server instructions 不直接堆进每个工具 description。

9.7 一个有效的组合脚本

已有内建 read 工具时:

// @options: {"max_output_tokens": 1500, "timeout_ms": 30000}
const paths = ["package.json", "README.md"];
const results = await Promise.allSettled(paths.map(path => tools.read({ path })));
for (let i = 0; i < results.length; i++) {
  const result = results[i];
  if (result.status === "fulfilled") text({ path: paths[i], preview: result.value.slice(0, 800) });
  else text({ path: paths[i], error: String(result.reason) });
}

read 在脚本里返回文字,所以此例可以 slice。不同工具返回类型不同:MCP 是 CallToolResult;bash 是包含 output、exit_code 等的结构对象。不要给所有结果都直接 .slice()。

Promise fulfilled 不一定是业务成功:MCP 工具声明 outputSchema,因此脚本得到 structuredContent 中的 MCP CallToolResult;服务端正常返回 {isError:true} 时可能仍然 resolve。toScriptValue() 先取 structuredContent,再处理普通失败 throw。无结构结果的参数错误或 hook block 仍会 reject。

// ACTUAL_MCP_TOOL 必须换成 describeTool 确认过的实际工具。
const result = await tools.ACTUAL_MCP_TOOL({ /* 已验证参数 */ });
if (result.isError) {
  text({ ok: false, content: result.content });
} else {
  text({ ok: true, content: result.content });
}

同理,bash 的 exit_code 需要单独检查。MCP 的 script result 不是原始协议对象的无损转发,_meta 不承诺透传。

图片要 image(block),不能把 base64 用 text 打印。只有被输出的内容进入主模型;中间调用还可能有宿主记录,但不是每条都成为主 transcript 消息。

9.8 store/load 与外部副作用

独立 CodemodeSandbox 自己不持久化;宿主传 store,成功时得到 storeWrites。Coding Agent 内建扩展把成功写入记录成 codemode-store custom entry,恢复和分支按路径重放。

单值最多 262144 JSON 字符,总值最多 1048576。存 ID、游标、摘要,别存图片或整个数据库。

失败脚本不提交 store writes,但此前真实工具副作用不会回滚。脚本末尾异常不能撤销已经发送的消息或改过的文件。事务需要业务工具自己提供。

9.9 嵌套调用必须经过统一执行路径

SDK 的 ctx.executeTool 经过 NestedToolCallRunner → runToolCall → 校验 / hooks。每个子调用有 parentToolCallId,bounded nestedCalls 记录到父结果。

自己用 CodemodeSandbox 时,不应简单 tool.execute(...) 绕过权限。官方 Core 集成示例用 runToolCall 复用 hooks;课程验证也应覆盖直接和嵌套两种入口。

9.10 SDK 显式启动

import {
  createAgentSession, createCodemodeExtension, createMcpExtension,
  createToolSearchExtension, DefaultResourceLoader,
  SessionManager, SettingsManager, getAgentDir,
} from "@earendil-works/pi-coding-agent";

const cwd = process.cwd();
const settingsManager = SettingsManager.inMemory({ defaultTools: ["+codemode", "+tool_search"] });
const resourceLoader = new DefaultResourceLoader({
  cwd, agentDir: getAgentDir(), settingsManager,
  extensionFactories: [createCodemodeExtension({ mode: "on" }), createToolSearchExtension(), createMcpExtension()],
});
await resourceLoader.reload();
const { session } = await createAgentSession({ cwd, resourceLoader, settingsManager, sessionManager: SessionManager.inMemory(cwd) });
try {
  await session.bindExtensions({});
  await session.prompt("用 codemode 读取 package.json 的 name。");
} finally { session.dispose(); }

tools allowlist 如果只列 read/bash 等,会限制额外注册工具;上例用 defaultTools 的 + 增量,不无意隐藏 MCP。

9.11 OAuth 与重试

1.0 MCP 凭据按 server name + URL 保存;同 URL 不同账户可隔离。RFC 9207 issuer 检查有前提:有 metadata,并且响应含 iss 或服务端声明 authorization_response_iss_parameter_supported 时,iss 必须等于 metadata.issuer;声明支持却缺失 iss 也拒绝。服务端未声明且响应无 iss 时,不执行这一比较。step-up scopes 合并已有授权。配置 oauth.authServerMetadataUrl 是指定信任文档来源,需要核对服务实际 issuer。

重试按操作类别决定:

情况 v1.0 处理
后台建连失败 连接层的重连与 backoff,不等于业务工具重放
标记 readOnly 的请求,首次 transient HTTP error 等待后重试一次;不是所有 5xx,501 被排除
普通 tool call 遇 transient error 不按 readOnly 分支重发,避免重复副作用
首次 McpSessionExpiredError 建新 session 重试一次,tool call 也适用;代码以旧 session 被拒、请求未执行为前提
需要重新登录 断开并标 needs-auth,向调用方明确失败

业务幂等策略仍不可省略;不能把“连接恢复”解释成任意未知执行状态都可以再发工具。

来源:MCP 文档、Code Mode 文档、Sandbox、嵌套工具。