Skip to content

Appendix 11 / 附录

MCP 补遗:Pi 为什么接入外部工具协议 ​

小A在终端里看到一个熟悉的名字:MCP。团队已经有一个本地文件服务、一个文档检索服务,还有远端 issue 系统。有人建议把每个服务分别写成 Pi 扩展。小A问老Z:“Pi 为什么又做了 MCP?把工具都自己实现,不是更可控吗?”

老Z回答:“如果问题是‘维护者当时主观上为什么决定做’,只能由维护者的一手说明回答,不能从代码反推作者动机。本附录回答一个可核实的工程问题:在 Pi v1.0.0 里,MCP 集成解决了什么接入问题、承担了哪些责任、又明确没有解决什么。”

本附录服务概念篇第12章、源码篇源码篇第8章与实现篇实现篇第11章:接外部服务前查它的接入模型,排连接和权限问题时查它的失败语义。依据上游 pi 标签 v1.0.0(提交 a13d35a742c6ef8462812a28fbe1d8c8b7431c32)。重点路径是 packages/mcp/ 的独立客户端、packages/coding-agent/src/extensions/mcp/ 的内置集成,以及 coding-agent 文档的 MCP 章节。这里从实现结构归纳工程价值,不把归纳冒充维护者声明。

先对齐协议代际:Pi v1.0.0 实现的是 2025-11-25 一代 ​

概念篇第12章讲的是 MCP 2026-07-28 版:无状态、每个请求自带版本与能力、server/discover、没有握手与会话。Pi v1.0.0 的客户端实现的不是这一代。packages/mcp/src/protocol/types.ts 写死:

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() 走的是上一代的形状:initialize 请求协商版本、notifications/initialized 完成握手;Streamable HTTP 有服务器会话、服务器到客户端的 GET 流、断线用 Last-Event-ID 恢复。带着“概念篇的无状态模型”去读 Pi 的 MCP 代码会处处对不上,这是两章之间最容易踩的错位。结论要按版本说:Pi v1.0.0 接入的是 2025-11-25 及更早一代的 MCP;概念篇讲的 2026-07-28 代际,Pi 的这份快照尚未实现。

先把“为什么”限定在可证明的范围 ​

MCP 将外部服务声明的工具与资源描述为一种通用协议。Pi 的 @earendil-works/pi-mcp 实现客户端核心、stdio 和 Streamable HTTP 传输、工具调用与资源读取接口;coding-agent 再把发现到的工具适配成 Pi 的工具定义。因而可从代码直接验证的好处是:服务作者可以通过协议暴露能力,Pi 不必为每个服务各写一套线协议客户端。

这不是说 MCP 消灭了集成工作。Pi 仍需处理连接生命周期、配置、认证、结果转换、工具暴露、失败与权限钩子;协议让双方在共同消息形状上交互,宿主仍要负责产品策略。若工具功能很少、服务长期固定且需要紧密专用界面,原生扩展也可能更合适。协议不是唯一答案。

MCP 工具在 Pi 里走哪条路径 ​

text
mcp.json / 扩展注册
        ↓
MCP extension 读取配置并启动连接
        ↓
pi-mcp client + stdio 或 Streamable HTTP
        ↓ tools/list
MCP 工具描述 → Pi ToolDefinition
        ↓ tools/call
CallToolResult → Pi 文本/图片/结构化结果
        ↓
Pi 工具事件、渲染、用量与权限扩展

McpServerConnection 负责协议连接;createMcpToolDefinition() 把服务器提供的名称、说明、输入 schema 和注解适配成 Pi 工具;工具执行时再通过 MCP client 的 callTool() 发请求。Pi 的 Agent loop 看到的是普通工具,循环本身不需要理解 MCP 的 JSON-RPC 消息。工具名规则是 mcp__<server>__<tool>,非字母数字下划线字符替换为 _;归一化后同名的工具全部加哈希后缀;只以 - 和 _ 区分的两个服务器名视为同一服务器。

这个适配边界有实际价值:循环、TUI 和会话无需为每个外部服务重新加一种执行语义。不同 MCP 服务仍需自己的配置和权限判断,但它们可以共用同一套发现与调用路径。

连接方式统一,部署边界仍然不同 ​

Pi 支持两种传输,都实现同一个 McpTransport 接口:

传输服务形态生命周期与失败边界
stdioPi 启动本地子进程,用标准输入/输出交换 JSON-RPC 消息子进程启动失败、退出、管道断;停止时先关 stdin,再尝试按平台终止进程树:POSIX 向进程组发信号,Windows 使用 taskkill /T /F。这是实现尝试,不保证包装器或子进程不留孤儿
Streamable HTTP连接远端 MCP 服务网络错误、认证、超时、HTTP 状态、会话过期;瞬时状态(408/429/5xx)下连接阶段按 250ms、1s 间隔重试

旧式 SSE 传输不支持:配置里写 sse 会被直接拒绝,文档给出的迁移方向是改用服务器通常同时提供的 /mcp streamable HTTP 端点。

两种传输共享客户端契约,却不会抹平本地与远端的信任差别:stdio 配置会启动本地进程;HTTP 配置会把请求发送到指定服务。信任一个服务器,就是信任它实际执行或能触达的那一侧能力。还有一个容易被忽略的方向:Pi 作为客户端会声明 roots 能力,把当前会话目录的路径发给服务器(connectOnce 里 roots: [{ uri: pathToFileURL(cwd) }])。目录路径因此会离开本机进入远端服务器视野——这是客户端能力,不是文件沙箱。

配置兼容常见的 mcpServers 结构。用户级服务器来自 ~/.pi/agent/mcp.json,项目级服务器来自 .pi/mcp.json;项目配置只有在项目资源通过 Pi 的信任门槛后才读取,同名项目条目覆盖用户条目。服务器默认单请求超时 60 秒,进度通知会重置计时;enabled: false 保留条目但不连接。扩展也能用 pi.registerMcpServer() 在当前会话注册服务器,文件配置同名时优先;pi mcp 命令行不加载扩展、只看文件配置。反过来,一个注册了 /mcp 的扩展(如 pi-mcp-adapter)会整体替换内置 MCP 支持——Pi 不再读 mcp.json;SDK 会话默认不加载内置扩展,要用 MCP 必须显式加入 MCP、codemode 和 tool-search 三个扩展。

多工具服务不能全塞进每轮请求 ​

若服务器有数十甚至数百个工具,把全部名称、描述和 schema 都交给模型,会增加提示词长度,也让模型更难找到相关能力。Pi 因而为 MCP 工具提供四级暴露策略:

暴露级别模型如何使用用途
direct工具直接出现在模型工具列表中少量高频工具
codemode(默认)不出现在模型声明里,也不列入 codemode 描述;脚本用 searchTools() 等发现后调用默认设置,适合批处理、筛选和跨工具组合
deferred先由 tool_search 加载进下一次请求,之后可直接调用大型目录,按需直接调用
hidden已注册但不可达明确禁用的工具

两个细节容易误读。第一,两种间接机制是互达的:codemode 脚本同样能调用 deferred 工具,tool_search 也能加载 codemode 工具——级别决定激活哪个发现入口,不是把工具锁死在单一通道里(codemode-deferred 还接受为 codemode 别名)。第二,MCP 配置层的 codemode 注册进 pi 工具层时映射为 deferred,两套命名空间同名不同义(工具暴露体系详见附录第10篇 Codemode 补遗)。

toolExposure 可逐项覆盖服务器默认:键是精确工具名或 * 通配,精确名优先。例如服务器整体 deferred,可让 search_code 直接可见、delete_* 隐藏。暴露控制解决的是“哪些调用入口被呈现给模型”,不是对服务能力做安全隔离。

还有一个静默耦合要记下:只要有 codemode 暴露的服务器连接,Pi 就会激活 codemode 工具(除非设了 autoEnableCodemode: false);有 deferred 服务器则激活 tool_search。换句话说,MCP 配置会改变会话的工具面。若两者都不可用又存在间接暴露的工具,非 direct 工具就无法被调用,Pi 在启动时告警一次。

Pi 默认在后台连接启用的服务器。在本快照实现中,首个提示最多等待 10 秒,且只等待包含 direct 工具的服务器;其余按需等待:codemode 脚本等它点名的服务器,调用 searchTools() 或读 ALL_TOOLS 时等全部;tool_search 与资源工具也等全部启用服务器。这避免了一个慢速的非直接服务器阻塞第一次对话,但也意味着某个能力可能在启动后才可用。

服务器推送工具列表变化时,Pi 增量更新注册。由于 Pi 工具注册接口没有删除操作,被撤回的工具会重注册为 hidden,而不是从注册表物理消失。这是一种宿主内的可达性收束,不意味着底层服务器已撤销凭据或停止对应功能。

MCP 不只有 Tool,也包括 Resource ​

MCP server 还可以列出 Resources 与 Resource Templates。Pi 把它们包成 list_mcp_resources、list_mcp_resource_templates 和 read_mcp_resource 三个工具:模型通常先列举发现 URI,再显式读取(read_mcp_resource 本身不校验该 URI 是否曾出现在列表里,拿到 server + uri 就直接读)。结果里文本进上下文、图片作图片,其他二进制落临时文件并给路径;模型侧文本超过 20 KiB 时掐中间留首尾并附全文临时文件,codemode 脚本则拿到完整的 CallToolResult。资源工具的暴露级别取可达资源服务器中最宽的一档。

这里有一条可核对的重试分界。McpServerConnection.withClient() 只给只读请求(列举、读取资源)在瞬时 HTTP 错误后重试一次;工具调用不因瞬时错误重试——“服务器可能已经执行了它”。唯一例外是会话过期:在 pi 检测到的会话过期路径中,HTTP 404 被判定为服务器已不认识这个会话,因此该请求未执行;不能把所有 HTTP 404 都推广为“请求未执行”,于是换新会话重试一次,旧客户端不关闭、让其他在途请求各自撞 404 后同样重连。这条“结果未知 vs 可证明未执行”的区分,是远程副作用调用重试设计的教科书样例。

MCP Prompts 没有进入 Pi 内置集成:客户端没有 prompts/list 适配,coding-agent 的 MCP 扩展也没有对应入口;MCP Apps 的 ui:// 或 text/html;profile=mcp-app 资源被过滤,因为 Pi 不渲染它们,资源图标同样省略。客户端 README 一句话划出了范围:批量 JSON-RPC、旧式 HTTP+SSE、servers、sampling 和 tasks 在初始核心之外。因此“Pi 支持 MCP”要读成它实现了文档所列的客户端子集与宿主集成,不是全部协议能力。

认证与信任边界 ​

远端 HTTP 服务器可用 MCP OAuth:Pi 通过受保护资源元数据(RFC 9728)发现授权服务器,走 PKCE 授权码流程、动态客户端注册,token 存 ~/.pi/agent/mcp-auth.json 并按服务器名称+URL 分开记账;刷新在过期或被拒时发生,并发 401 共享同一次刷新;服务器追加 scope(insufficient_scope 挑战)时重新征求登录并保留已授予 scope。OAuth 只作用于没写 Authorization 头的 HTTP 服务器。两种偏差通道:不支持动态注册的服务器可配 clientId/clientSecret;宣告错授权服务器或不宣告的,可设 authServerMetadataUrl 直接指向元数据文档——Pi 跳过发现、按配置信任它,文档明确提醒只指向你信任的文档。另一条通道是 auth: { "provider": ... }:HTTP 服务器直接用某个已登录 Provider 的当前 token 作 bearer,每次请求实时读取;这条配置只允许出现在用户级文件和扩展里,项目配置不允许。

但是,认证只回答“这个请求凭什么凭据通过服务端验证”,不回答“这个工具是否适合交给 Agent 调用”。MCP 注解如 readOnlyHint、destructiveHint、idempotentHint、openWorldHint 是服务端自报的提示,Pi 原样透传给扩展(pi.getAllTools()),不构成任何保证,也不默认触发确认。MCP 工具经过 Pi 的 tool_call/tool_result 扩展事件,权限扩展可以在那里拦截;codemode 脚本发起的嵌套调用带 parentToolCallId,让权限扩展能把调用归属到发起它的脚本。是否装了能拦截的扩展、它如何失败,仍是独立的宿主配置问题。

信任还包括本地配置面,而且比“读一个 JSON”更宽:

  • 项目 .pi/mcp.json 获信任后,stdio 条目会让 Pi 启动其配置的命令——项目配置不是无害清单,而是会影响进程启动的配置面。
  • env 与 headers 的值除 ${VAR} 外还支持 !command:整值以 ! 开头时 Pi 通过 shell 执行该命令取输出。受信任项目的一条 ! 配置就是本地命令执行。
  • 根能力会把会话目录路径发给远端服务器。

信任边界速查

  • MCP schema 描述参数形状,不证明服务可信,也不定义用户权限。
  • OAuth token 证明认证身份,不证明 Agent 调用在业务上合适。
  • readOnlyHint 等注解是服务端提示,不是可信的安全标签。
  • 项目配置获信任后可启动本地进程、执行 !command、连向远端服务。
  • 工具结果与资源内容仍是外部数据,可能含提示注入。

能从实现归纳出什么工程价值 ​

从 v1.0.0 的代码可以支持以下归纳:

  1. 减少逐服务编写协议适配器的需要。 服务用 MCP 描述工具和调用边界;Pi 编写一套客户端、传输和到 Pi 工具的适配层,就能接入多种兼容服务器。
  2. 让工具能力可组合。 MCP 工具进入 Pi 工具管线后,可以被 Agent、Codemode、tool search、渲染器与扩展钩子共同使用。
  3. 按需控制目录规模。 direct/codemode/deferred/hidden 加逐工具覆盖,让宿主能控制提示词成本与调用可达性。
  4. 在一套接入模型中覆盖本地与远端。 stdio 适合本地服务,Streamable HTTP 适合远端;两者共享客户端契约,但保留各自的错误和信任边界。
  5. 让资源读取成为显式动作。 Resource 不是自动灌进每轮上下文,而是通过列举和读取工具按需获得,结果体积也受控。

这些是对已实现结构与配置的工程解释,不是“维护者为什么做 MCP”的历史陈述。若要引用作者动机,应另找上游访谈、设计说明或发布记录。

收益与代价 ​

协议化让集成具备复用性,也增加了运行时责任。Pi 需要管理多服务连接、工具名称冲突、schema 兼容、列表变化、OAuth、失败诊断、资源转换和暴露策略。用户也要决定每个服务器是否可信、每个工具是否应直接对模型可见、失败后是否可以重试。

因此,Pi 加 MCP 不是“所有外部工具都已经安全接好”。它提供一条可复用的发现与调用路径,并让外部工具沿既有工具管线接入。协议负责互通,Pi 负责客户端与宿主集成;权限与信任需要逐服务、逐工具建立。

源码走查 ​

  1. 从 packages/mcp/src/protocol/types.ts 的版本常量开始,对照 client.ts 的 connect()、listTools() 和 callTool(),确认 Pi 实现的是哪一代 MCP、握手与请求关联归谁管。
  2. 读 extensions/mcp/runtime.ts 的 createDefaultTransport() 与 open()/getClient()(connectOnce() 是其私有实现步骤),对照 stdio 与 Streamable HTTP 的失败路径;在 transports/stdio.ts 确认进程树终止的平台差异。
  3. 精读 withClient():哪类请求瞬时错误重试一次、工具调用为何不重试、McpSessionExpiredError 为什么是例外。
  4. 读 extensions/mcp/tools.ts 的 createMcpToolDefinition() 与 toToolExposure(),记录 MCP schema、注解如何映射到 Pi 工具,以及两套暴露命名空间的对应关系。
  5. 回到 extensions/mcp/index.ts 查 registerTools()、syncResourceTools()、ensureDiscoveryActive()、waitForDirectServers(),画出“后台连接→按需等待→工具面随配置变化”的时间线。
  6. 对照 extensions/mcp/config.ts 与 oauth.ts、packages/mcp/src/oauth/flow.ts:auth.provider 的允许范围、凭据按名称+URL 分账、authServerMetadataUrl 绕过发现、step-up scope 并集各落在哪一行。
  7. 拿一份含 !command 与 stdio 条目的项目 .pi/mcp.json,列出它在项目获信任后实际授予了哪些本地能力。