内置 MCP
从配置到暴露策略:MCP 现在是内置的,而且默认不直接给模型
MCP 是这条专栏里最容易写出「事实错误」的一篇。旧资料里有一整节论证「Pi 不内置 MCP」,理由是那时候 Pi 的包里确实没有 MCP 相关的代码。现在那个前提已经不成立了——MCP 是内置的,而且它的默认行为恰好是旧资料里争论不休的那个问题的答案。
所以先把结论放在前面:
MCP 现在是内置的(
builtin:mcp扩展)。但 MCP 工具默认不声明给模型——默认 exposure 是codemode,模型看不到这些工具的定义,只能在 codemode 脚本里通过tools.mcp__<server>__<tool>()调用。
这两句话放在一起,就是本文要讲清楚的东西。旧文章「MCP 工具定义吃掉太多上下文」的论证方向没错,错的是解法:Pi 不是靠「不内置」来回避这个问题,而是靠 exposure 模型把「工具存在」和「工具定义给模型看」拆成两件事。
(顺便澄清一句:旧资料里说 Pi 不内置 MCP,那是当时的版本。写这一篇时 Pi 的基线是 v1.3.0-16-g8efd314fa,MCP 已经是四个内置扩展之一。)
8.1 MCP 现在是内置的
8.1.1 它在哪
内置扩展一共四个,定义在 packages/coding-agent/src/extensions/index.ts:8-13:
export const builtInExtensions: InlineExtension[] = [
{ name: "llama.cpp", factory: llamaExtension, builtin: true },
// Replaceable: an extension that registers `codemode`, `tool_search`, or `/mcp` (such as a third-party
// MCP extension) takes over instead of running alongside the built-in one.
{ name: "codemode", factory: codemodeExtension, replaceable: true, builtin: true },
{ name: "tool-search", factory: toolSearchExtension, replaceable: true, builtin: true },
{ name: "mcp", factory: mcpExtension, replaceable: true, builtin: true },
];四个里有三个带 replaceable: true。MCP 是其中之一,这意味着两件事:
- 内置的 MCP 支持可以被另一个扩展整个顶掉。你装一个注册了
/mcp的扩展(官方文档举例pi-mcp-adapter),Pi 就不再读mcp.json、不再连接它的服务器,/mcp归那个扩展管。机制细节在 扩展开发 里讲。 codemode和tool_search也是可替换的,这一点在 9.5 节 回链时会用到——因为 MCP 默认的 exposure 就是codemode,MCP 集成和 codemode 扩展是咬合在一起的,不是两个独立功能。
mcp 这个内置扩展的实现放在 packages/coding-agent/src/extensions/mcp/ 目录下,主体是 index.ts(约 1300 行),负责会话生命周期、连接管理、工具注册和 /mcp 管理界面。真正跑协议的是另一个包 packages/mcp/,packages/coding-agent/package.json 里声明了对它的依赖。
这个分层值得留意:MCP 客户端是独立包(pi-mcp),不是 coding-agent 的私有实现。你要看协议细节(JSON-RPC 帧、传输层、OAuth 流程),去 packages/mcp/src/;要看 Pi 怎么把 MCP 接到自己的工具系统上(exposure、命名、等待策略),去 packages/coding-agent/src/extensions/mcp/。这条界线在后面的 8.4 和 8.6 节还会用到。
8.1.2 内置意味着什么
「内置」在 Pi 里不是一句宣传语,它具体意味着这些东西开箱即用,不需要装任何东西:
反过来,它也可以被关掉。三种粒度:
# 只这一次运行不要 MCP
pi --no-mcp{
"extensions": ["-builtin:mcp"]
}第三种是在 pi config 的交互式 TUI 里把 Built-in 分组下的 mcp 关掉。
注意 --no-mcp 的作用域:它只影响内置实现。如果你的 MCP 支持已经被某个扩展接管了,--no-mcp 对那个扩展不起作用(docs/cli.md 的 Resource options 段明确说了这一点)。
8.2 添加与管理服务
8.2.1 五个子命令
MCP 的命令行入口全部在 packages/coding-agent/docs/cli.md:324-329 汇总:
add 的选项:--env KEY=VALUE(可重复)、--cwd <dir>、--header KEY=VALUE(可重复)、--bearer-token-env-var <NAME>、--oauth-client-id、--oauth-client-secret、--oauth-callback-port、--oauth-client-name、--exposure <mode>、--description <text>。
两个细节容易踩:
第一,add 和 remove 默认写用户级文件,加 -l / --local 才写项目级。
第二,add 不连接服务器。 它只是往 JSON 文件里写一条记录。写完要跑 pi mcp list 才验证得到——这个设计让 add 可以安全地出现在脚本里。
# 加一个本地 stdio 服务器
pi mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem .
# 加一个远程服务器,token 从环境变量读
pi mcp add docs --url https://example.com/mcp --bearer-token-env-var DOCS_TOKEN
# 加到项目级(当前目录的 .pi/mcp.json)
pi mcp add -l tools --env API_KEY='${TOOLS_KEY}' -- uvx tools-mcp
# 验证
pi mcp listpi mcp list 在「配置项非法」或「启用的服务器没连上」时以状态码 1 退出。这是它作为 CI 检查项的价值所在:agent 可以通过 bash 调它,用退出码判断配置是否可用。
pi mcp login 的 --timeout 默认是 300 秒,它限制的是整个登录过程(包括向授权服务器发请求)。
8.2.2 会话内的 /mcp
/mcp 不带参数时打开一个管理界面:列出所有服务器,带状态、工具数、exposure 和配置来源;需要处理的服务器排在最前面。选中一个服务器后可以:查看它的工具、重新连接、登录/登出、改 exposure、启用/停用。
不带界面时(print 模式、RPC 模式),/mcp 打印状态列表。/mcp login <server>、/mcp logout <server>、/mcp reconnect <server> 三个子命令在两种模式下都能用。
一个实用细节:exposure 和启用状态的修改会写回定义这个服务器的那个文件,而且不会破坏文件里的其它内容。在受信任的项目里,"Enable in this project" / "Disable in this project" 会给用户级服务器加一条项目级覆盖,之后对该服务器的修改就写到这条覆盖里。
8.2.3 连接状态机
每个服务器连接有一个状态,定义在 packages/coding-agent/src/extensions/mcp/runtime.ts:57:
type ServerState = "connecting" | "connected" | "disconnected" | "needs-auth" | "failed" | "closed";disconnected 这一态的设计值得说一句:它不是错误态。HTTP 服务器重启、stdio 服务器崩了,Pi 不试图「恢复」连接,而是把状态标成 disconnected,在下一次工具调用时懒重连。用户看到的提示是 "disconnected, reconnects on next call"。
失败时错误信息里带 stdio 服务器的 stderr 尾巴,最多 2000 字符(runtime.ts:49)。这行日志经常是唯一能看出「服务器为什么起不来」的地方。
8.2.4 连接时机:谁等谁
这是 MCP 集成里设计得最讲究的一块,也是 8.4 节的前提。
会话启动时,所有启用的服务器在后台连接。 但不是所有服务器都在第一个 prompt 前被等:
第一个 prompt 最多等 10 秒,且只等那些有
direct工具的服务器。
10 秒这个上限在 packages/coding-agent/src/extensions/mcp/index.ts:93:
const DEFAULT_STARTUP_WAIT_MS = 10_000;为什么只等 direct 的?因为 direct 工具的工具定义要出现在这次请求的 tools 数组里——不等它连上,模型这次就看不到它。而 codemode 和 deferred 的工具本来就不声明给模型,等不等都不影响这次请求的形状。
超时不是错误,Pi 只是提示一句 "MCP servers are still connecting; their tools become available once connected."。
其余的服务器在需要的时候才等。 规则在 index.ts:1191-1209 的 tool_call 处理器里,靠工具声明的 reachesTools 字段判断这次调用可能用到哪些服务器:
这个「谁等谁」的机制是 exposure 模型能成立的技术前提:工具晚到没关系,需要它的时候再等它。一个挂了 30 秒才连上的服务器,不会拖住你的第一个 prompt。
8.2.5 重试与日志
HTTP 网络错误和临时状态码(408、429、5xx)会重试。连接阶段的退避是两次:250 毫秒、1000 毫秒(runtime.ts:51),也就是最多三次尝试。
工具调用本身不重试——服务器可能已经真的执行了。只有只读请求(资源读取、资源列举)会在临时 HTTP 错误后重试一次。
服务器发来的日志通知(notifications/message)追加到 ~/.pi/agent/mcp.log,格式是 <时间> [<服务器>] <级别> <logger>: <消息>。文件超过 5 MB 后轮转为 mcp.log.1(packages/coding-agent/src/extensions/mcp/log.ts:10)。
停止一个 stdio 服务器的顺序是:关闭 stdin → 发 SIGTERM → 对进程组发 SIGKILL。对进程组发信号是刻意的:这样 npx、uvx 这类包装器也不会把真正的服务器进程留在后台。
8.3 配置文件与项目信任
8.3.1 两级文件
项目级的读取受 project trust 管,机制跟其它项目资源一样:信任决定存在 ~/.pi/agent/trust.json 里,判定在启动期发生一次。细节在 工具系统与信任边界 里讲过。
这条信任边界有一个直接后果:pi mcp list 在一个未信任的项目里看不到 .pi/mcp.json 里的服务器,而且会明确告诉你原因:
<项目>/.pi/mcp.jsonis ignored because the project is not trusted. Start pi in the project to trust it.
这不是 bug,是设计。
8.3.2 文件格式
格式跟别的 MCP 客户端一致,用 mcpServers 顶层键:
{
"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"
}
}
}顶层还可以放一个 autoEnableCodemode 布尔值,默认 true,含义见 8.4.6 节。
8.3.3 项目条目有两种含义
这是配置里最容易搞混的一条规则,定义在 packages/coding-agent/src/extensions/mcp/config.ts:77-79:
/** Whether an entry overrides a server defined elsewhere instead of defining one. */
function isOverride(value: Record<string, unknown>): boolean {
return value.command === undefined && value.url === undefined && value.type === undefined;
}也就是说,项目条目里有没有 command / url / type,决定了它是「重新定义」还是「覆盖」:
覆盖时能改的字段是白名单(config.ts:74):
const OVERRIDE_KEYS = ["enabled", "exposure", "toolExposure"];写别的键会报错。所以「在某个项目里把公司的内部服务器关掉」是这样写的:
{
"mcpServers": {
"internal-tools": { "enabled": false }
}
}这条规则的意义是:项目文件不能替换用户级服务器的凭据来源。一个仓库可以决定「在这个项目里别用它」,但不能决定「让它连到我的另一台服务器、带上我的 token」。后者的字段(env、headers、auth)根本不允许出现在覆盖条目里。
还有一个限制:项目文件里不允许出现 auth(config.ts:133-136)。auth 指的是「用某个 Pi 供应商的 token 去认证」,理由同样是——不能让一个仓库决定凭据往哪儿发。
8.3.4 校验规则
配置读取时逐条校验,非法的条目会被报告并跳过,不影响其它服务器连接。规则如下:
SSE 被拒绝这一条值得单独说。SSE(Server-Sent Events)是 MCP 的旧传输方式,很多服务文档里还写着它。Pi 的建议写在官方文档里:这类服务通常同时提供 streamable HTTP,路径常常是 /mcp 而不是 /sse。迁移办法就是把 URL 改掉,不是换客户端。
!command 前缀是一个容易忽略但很实用的能力:
{
"mcpServers": {
"github": {
"url": "https://api.githubcopilot.com/mcp/",
"headers": { "Authorization": "!echo Bearer $(gh auth token)" }
}
}
}!command 会执行一条 shell 命令,把它的 stdout 当成值。但有一个限制:命令必须构成整个值,不能是字符串的一部分(docs/mcp.md:81)。
值只有两种形态,二选一,不能混:
模板里字面量和环境变量可以自由混排;! 开头的命令形态则必须是整条值——源码里 parseConfigValueReference() 就是先判 config.startsWith("!") 再决定走哪条路(src/core/resolve-config-value.ts:80-85)。
8.3.5 迁移
官方文档给了一张从别的客户端迁移的对照表:
迁完跑一次 pi mcp list 验证。
8.3.6 扩展也可以注册服务器
扩展可以通过 pi.registerMcpServer(name, config) 在当前会话里加服务器,config 形状和 mcpServers 条目一样。规则:
- 加载期注册的服务器在
session_start时和mcp.json的服务器一起连接;之后注册的立即连接。 - 注册不持久化。 每次加载扩展都要重新注册。
mcp.json里的同名服务器优先级更高,/mcp里会列出被覆盖的情况。- 扩展注册的名字非法、配置非法,或者和另一个扩展注册了同名,抛异常。
pi mcp系列子命令不加载扩展,所以只看得到文件配置的服务器。
详见 扩展开发。
8.4 暴露策略:为什么默认是 codemode
这是本文的核心章节。前面三节讲的都是「MCP 怎么接进来」,这一节讲「MCP 接进来之后,模型到底看得见什么」。
8.4.1 问题的形状
先说清楚要解决的是什么。
一个典型的 MCP 服务器(比如 GitHub、Jira、Figma)会带几十个工具。每个工具的声明包含:工具名、描述、参数 JSON Schema。这份声明要放进每一次模型请求的 tools 数组里——不管这一轮用不用它。
粗算一下:40 个工具,每个工具名加描述加参数 schema 大约几十到一百多 token,一次性开销就是几千 token。而且这是每轮都付的固定税,不是用一次付一次。
这个成本有一个不对称的地方:声明的成本恒定,使用的收益却是偶发的。一轮对话里模型可能只用了其中一个工具,但那 40 个声明每轮都在。
于是有了旧文章里的那个论证:「MCP 工具定义吃掉太多上下文」。
旧文章的错不在这个论证,而在于它给出的解法。 「Pi 不内置 MCP」是一个回避方案:不用就没有这个成本。但它把一个工程问题换成了一个功能缺失——用户想要接 MCP,还是得接。
Pi 的解法是把两件事拆开:
「这个工具存在」和「这个工具的定义给模型看」不是同一件事。
这就是 exposure 模型。它是 packages/coding-agent/src/core/mcp-servers.ts:52 定义的四个取值:
export type McpExposure = "codemode" | "deferred" | "direct" | "hidden";8.4.2 四种 exposure 的行为
四行的意思逐条展开:
codemode——默认,也是最反直觉的一个。 工具注册了,脚本能调,但模型既看不到它的定义,在 codemode 的工具清单里也看不到它。模型知道「有个叫 docs 的 MCP 服务器」,但不知道它有哪些工具。想用,就得先写一段脚本去搜。
deferred——中间态。 工具同样不声明给模型,但 tool_search 能把它搜出来并声明给下一次模型调用,之后模型就能直接调用它。这是最接近传统 MCP 客户端体验的选项。
direct——传统做法。 工具定义直接进 tools 数组,和 read、bash 没有区别。上下文成本就是那几千 token。
hidden——注册但不可达。 工具在注册表里,但模型够不着,脚本也调不了。用途是「暂时关掉某几个工具」,配合 toolExposure 做细粒度开关。
还有一条别名:codemode-deferred 是 codemode 的别名,配置里写了会被自动改写成 codemode(core/mcp-servers.ts:57):
const MCP_EXPOSURE_ALIASES: Readonly<Record<string, McpExposure>> = { "codemode-deferred": "codemode" };8.4.3 为什么默认是 codemode 而不是 deferred 或 direct
先排除 direct:它是「不做任何优化」的那个选项,作为默认值等于每次请求都付全部上下文税。Pi 把它留给「工具很少、天天用」的小服务器。
再比较 codemode 和 deferred。这两个都省上下文,区别在省下来的那部分谁来付:
deferred:模型搜一次工具,搜索本身进上下文(tool_search的结果会告诉模型加载了哪些工具),然后模型直接调用被加载的工具。成本是「搜索 + 被加载工具的定义」。codemode:模型写一段脚本,脚本在沙箱里调用工具,只有脚本的输出回到模型。中间结果不进上下文。
这个差别在工具输出很大的时候是决定性的。举个具体的例子:一个 MCP 服务器提供「列出工单」工具,返回 500 条记录。
deferred:模型调这个工具,500 条记录全部进上下文,然后模型自己想办法筛。codemode:脚本调这个工具,在沙箱里筛出 5 条,return这 5 条。500 条只在沙箱里活过一瞬间。
codemode 还有第二个好处:并发。脚本里可以 Promise.allSettled 一次调十个工具。模型一次一次调就是十轮往返。
所以默认值的取向是:默认假设「工具输出可能很大,而且模型对多个结果的组合方式有自己的判断」,于是把筛选权交给脚本,把选择权留给模型。
这不是说 codemode 永远更好。它的代价是模型得先想清楚「我要写什么脚本」,对小工具集来说这是多余的绕路。所以 direct 和 deferred 都留着。
8.4.4 怎么选
8.4.5 逐工具覆盖:toolExposure
服务器级的 exposure 管的是全部工具。toolExposure 管单个工具,键是工具名或模式:
{
"mcpServers": {
"github": {
"url": "https://api.githubcopilot.com/mcp/",
"exposure": "deferred",
"toolExposure": {
"search_code": "direct",
"get_*": "codemode",
"delete_*": "hidden"
}
}
}
}匹配规则(core/mcp-servers.ts:234-242):
- 精确名优先于模式。
- 模式之间,对象里第一个匹配的赢。
- 都没匹配上,就用服务器的
exposure。
模式里的 * 匹配任意字符。实现是一个把 * 拆成 .* 的正则(core/mcp-servers.ts:12-18)。
一个能派上用场的组合是 hidden + 少量 direct 覆盖:默认全部屏蔽,只放行两三个要用的。
{
"mcpServers": {
"github": {
"url": "https://api.githubcopilot.com/mcp/",
"exposure": "hidden",
"toolExposure": { "search_code": "direct" }
}
}
}pi mcp list 会标出哪些工具的 exposure 与服务器不同;/mcp 的 Tools 视图显示的是生效后的 exposure。
8.4.6 激活规则:谁负责让工具可达
这是 exposure 模型里最容易漏掉的一环。
工具的 exposure 决定了它「对谁可见」,但还需要一个激活的工具去把它接上。Pi 的规则是(packages/coding-agent/src/extensions/mcp/index.ts:501-536):
codemode 和 tool_search 这两个内置扩展默认是关闭的(注册时 defaultActive: false)。MCP 扩展会在需要时把它们打开。
注意一个细节:激活发生在读取配置时,而不是等服务器连上。源码注释写得很清楚:
// Codemode or tool_search is activated from the config: the first prompt does not wait for
// servers whose tools are not declared to the model, and scripts or searches wait for them.这样第一个 prompt 不用等 codemode 服务器连上。
autoEnableCodemode 可以关掉自动激活。 它是 mcp.json 的顶层键,默认 true,项目级覆盖用户级:
{
"autoEnableCodemode": false,
"mcpServers": { "...": {} }
}关掉之后,如果你又用了 codemode exposure 的服务器,Pi 会警告一次:
MCP tools are only reachable from the codemode or tool_search tool, but neither is active; they cannot be called.
(autoEnableCodemode 为 false 时,警告文案里会额外加上原因。)
另一个绕路办法是自己在 settings.json 里加 "defaultTools": ["+codemode"],这样即使没有 MCP 服务器,codemode 也一直开着。
8.4.7 模型怎么知道有这些服务器
codemode 和 tool_search 的工具描述里都不列那些没声明的工具(否则描述会随服务器连接而变化,缓存就废了)。模型对 MCP 服务器的认知来自系统提示里的一个独立段落:mcp_servers。
段落的开头解释「这些服务器的工具没有直接给你」,后面每行一个服务器,写明它的 namespace 和一行描述:
MCP servers whose tools are not declared to you. Call the tools of `codemode` servers from codemode scripts.
- mcp__docs (codemode): Search and read the product documentation
- mcp__github (codemode): Repositories, issues, pull requests段落的规模有两个上限(index.ts:158-163):
放不下的时候,描述会被截短;连服务器行都放不下时,最后几个服务器被省略,并留下一行 "… N more servers; find their tools with searchTools()"。
段落是追加的,不是替换的。 这是个缓存友好的设计:如果段落变了(比如某个服务器连上了、描述变得可用了),Pi 把新的段落追加到对话里,而不是去改之前的工具声明。这样早先的消息仍然命中缓存。
服务器描述来自配置的 description 字段;没配的话,服务器连上之后取服务器 instructions 的第一行。
8.4.8 资源工具的 exposure
有资源的服务器(提供 MCP resources)会额外注册三个工具,名字是 Codex 和 OpenCode 用的那套:
这三个工具的 exposure 取它能到达的所有服务器里最宽的那个:direct > codemode/deferred > hidden。也就是说,有一个 direct 服务器带资源,三个资源工具就都是 direct。
一个实际的边界:MCP Apps 的资源被跳过了。 识别方式是 ui:// URI 或 text/html;profile=mcp-app 的 MIME 类型,因为 Pi 不渲染它们。资源图标也跳过。
8.4.9 --tools 和 MCP 工具的关系
这是 6.x 系列文章里已经讲过的一条,这里再确认一次,因为它和 exposure 容易混:
--tools选的是声明给模型的工具。它不负责移除 MCP 工具——MCP 工具的可达性由 exposure 决定。
# 每一个 MCP 工具仍然可以从 codemode 脚本里调用
pi --tools read,codemode要让 --tools 也筛 MCP 工具,列表里得出现以 mcp__ 开头的条目:
# 只保留 radius 这个服务器的工具
pi --tools read,bash,codemode,'mcp__radius__*'彻底去掉 MCP 工具用 --exclude-tools 'mcp__*',或者 --no-mcp。
细节在 工具系统与信任边界。
8.4.10 MCP 结果的两条路径
同一个工具结果,走两条路到模型手里,形态完全不同:
20 KB 这个上限在 packages/coding-agent/src/extensions/mcp/tools.ts:51:
export const MCP_OUTPUT_MAX_BYTES = 20 * 1024;脚本拿到的是完整的 CallToolResult——content、structuredContent、isError 都在。isError 的结果在脚本里会 resolve(不是 reject),但在直接调用时是错误结果。这条差异在写脚本时很重要:用 Promise.allSettled 处理,或者检查 isError。
服务器 instructions 不进任何工具描述。 脚本要用 describeNamespace("mcp__<server>") 读它,那个调用还会返回服务器的工具名列表。
8.5 工具命名与冲突
8.5.1 命名规则
每个 MCP 工具注册为 mcp__<server>__<tool>。然后做一次净化:
// packages/coding-agent/src/extensions/mcp/tools.ts:84-93
export function createMcpToolName(
server: string,
tool: string,
isTaken: (name: string) => boolean = () => false,
): string {
const name = `mcp__${server}__${tool}`.replace(/[^A-Za-z0-9_]/g, "_");
if (name.length <= MAX_TOOL_NAME_LENGTH && !isTaken(name)) return name;
const hash = createHash("sha256").update(`${server}\0${tool}`).digest("hex").slice(0, 8);
return `${name.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
}三条规则:
- 除字母、数字、
_外的字符全部换成_。 所以服务器dev-radius的工具search叫mcp__dev_radius__search。 - 名字最长 64 个字符(
MAX_TOOL_NAME_LENGTH,tools.ts:49)。超长的会截断并加一个 sha256 前 8 位十六进制的后缀。 - 冲突的名字全部加哈希后缀,而不是「后来者改名」。
第三条是刻意的。源码注释解释了原因(index.ts:400-401):
Like Codex, all tools whose names sanitize to the same name get the hash suffix, so which one would keep the plain name does not depend on the order of the list.
换句话说:如果服务器同时有 a-b 和 a_b 两个工具,它们净化后都是 mcp__a_b,那么两个都拿到哈希后缀。这样「哪个保留干净名字」不取决于列表顺序,重连服务器、工具顺序变化都不会让名字漂移。
8.5.2 服务器名冲突
服务器名本身也规范化——mcpNamespace() 把 - 换成 _:
// packages/coding-agent/src/core/mcp-servers.ts:154-157
export function mcpNamespace(server: string): string {
return `mcp__${server.replace(/-/g, "_")}`;
}所以只差 - 和 _ 的两个服务器名算同一个服务器。配置加载时会检测这种冲突并报错(config.ts:127-131):
// Names that differ only in `-` and `_` would share a namespace.
const clash = [...servers.keys()].find((other) => other !== name && mcpNamespace(other) === mcpNamespace(name));
if (clash) {
errors.push(`${path}: server "${name}" conflicts with "${clash}"`);
continue;
}规则是:第二个被拒绝。而一个 mcp.json 里的服务器会覆盖一个已注册的同名服务器(注册的来源是扩展,见 8.3.6 节)。
8.5.3 命名空间与别名
每个服务器对应一个 namespace,名字是 mcp__<server>(- 换成 _),它被用来:
- 在 codemode 描述里分组显示(一个服务器一个标题,而不是几十行);
describeNamespace()查服务器说明和工具名;searchTools()的namespace过滤。
namespace 有别名。服务器叫 docs 时,describeNamespace("mcp__docs") 和 describeNamespace("docs") 都能找到它。源码里接受四种写法:mcp__dev-radius、mcp__dev_radius、dev-radius、dev_radius。
别名机制在脚本标识符层面也生效:MCP 工具名 mcp__dev-radius__search 在 codemode 脚本里写成 tools.mcp__dev_radius__search(非法标识符字符变 _)。
8.6 认证
8.6.1 三条路
MCP 的认证有三种互斥的形态,选哪条由配置决定:
OAuth 只适用于没有 Authorization 头的 HTTP 服务器(runtime.ts:82-87):
function usesOAuth(entry: McpServerEntry): boolean {
const { config } = entry;
if (!("url" in config) || config.auth) return false;
return !Object.keys(config.headers ?? {}).some((header) => header.toLowerCase() === "authorization");
}8.6.2 OAuth 流程
OAuth 是 Pi 内置的,不需要任何外部依赖。流程分四段:
第一段:发现授权服务器。 Pi 通过服务器的 protected resource metadata(RFC 9728)找到授权服务器,再检查授权服务器的 metadata 是否声明了预期的 issuer(RFC 8414)。
现实问题在于:有些服务器宣告错误的授权服务器,或者根本不宣告。这时 sign-in 会打开一个不存在的页面。解法是手动指定:
{
"mcpServers": {
"example": {
"url": "https://mcp.example.com/mcp",
"oauth": { "authServerMetadataUrl": "https://example.okta.com/.well-known/openid-configuration" }
}
}
}这个 URL 必须用 HTTPS(回环地址除外),而且Pi 完全信任它——文档的原话是 "uses that document instead of discovery and trusts it as configured, so only point it at a document you trust"。
第二段:动态客户端注册(DCR)。 默认路径。Pi 向授权服务器的 registration_endpoint 注册自己,拿到 client_id。授权服务器不支持 DCR 时报错。
第三段:PKCE。 用 WebCrypto 生成 verifier 和 challenge,挑战算法固定为 S256(packages/mcp/src/oauth/flow.ts:185-193):
if (metadata?.code_challenge_methods_supported && !metadata.code_challenge_methods_supported.includes("S256")) {
throw new Error("Authorization server does not support PKCE S256");
}
const { verifier, challenge } = await pkce();
...
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");第四段:回调。 授权完成后,授权服务器把用户重定向到 http://127.0.0.1:<port>/callback。Pi 起一个本地回环服务器接。如果浏览器在另一台机器上(比如你在 SSH 里),把重定向后的 URL 粘贴进登录界面就行。
8.6.3 客户端的三种身份
第一种,如果你想换个名字让某些只认已知客户端的服务器接受:
{
"mcpServers": {
"figma": { "url": "https://mcp.figma.com/mcp", "oauth": { "clientName": "Claude Code" } }
}
}名字只在注册时发送。想换名字重新注册,先登出。
第三种(cimd)是给「授权服务器允许你用一个 URL 当客户端标识」的场景准备的。Pi 的文档 URL 是 https://pi.dev/oauth/client.json,重定向 URI 是 http://127.0.0.1:<port>/callback。
如果授权服务器在授权响应里不发 iss 参数(RFC 9207),Pi 会改用带服务器 ID 的专用文档:https://pi.dev/oauth/<id>/client.json,回调路径也变成 http://127.0.0.1:<port>/callback/<id>。
cimd 有约束:不能和 clientId 或 clientName 同时用;回调 URL 必须在 localhost 或 127.0.0.1 上,路径是 /callback。
8.6.4 回调 URI 的规则
callbackUrl 可以完全自定义,但有硬性约束(core/mcp-servers.ts:131-135):
export function isLoopbackRedirectUri(value: string): boolean {
if (!URL.canParse(value)) return false;
const url = new URL(value);
return url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname) && url.search === "" && url.hash === "";
}端口可以省略。 这时 Pi 用 callbackPort,或者随便找一个空闲端口,然后把它加进 URI——这是 RFC 8252 明确允许回环重定向的做法。
8.6.5 scope 与凭据存储
scope 默认取服务器宣告的值。 服务器不宣告时,可以手动设:
{
"mcpServers": {
"example": {
"url": "https://mcp.example.com/mcp",
"oauth": { "scope": "read:issues write:issues" }
}
}
}scope 用空格分隔。服务器后来要求更多 scope 时,新加的会追加到配置值后面。
凭据存在 ~/.pi/agent/mcp-auth.json,键是服务器名 + URL:
- 同一个 URL 挂不同名字(比如一个账号一个名字)→ 分别登录。
- 同一个名字 + 同一个 URL 出现在不同的
mcp.json里 → 共享一次登录。
凭据会刷新。服务器后来要求更多 scope,Pi 会再要求登录一次。登出会删掉存储的凭据。
8.6.6 什么时候需要登录
服务器拒绝未认证的连接时,/mcp 会显示它需要登录。可以:
- 在
/mcp里选 "Sign in"; - 跑
/mcp login sentry; - 跑
pi mcp login sentry。
Pi 打开授权页面等批准。运行中的会话在下一轮就会用上新凭据——不需要重启。
有个细节做得不错:如果另一个进程(比如 agent 自己在 bash 里跑的 pi mcp login)存了凭据,正在运行的会话会在下一轮开始时发现并重连。源码在 index.ts:1212-1214 的 turn_start 处理器里。
8.6.7 auth.provider:复用供应商凭据
第三种认证方式是把 Pi 已登录的某个供应商的 token 直接发给 MCP 服务器:
{
"mcpServers": {
"internal": { "url": "https://mcp.internal.example/mcp", "auth": { "provider": "github" } }
}
}两个限制:
- 项目级
mcp.json里不允许用。 否则一个仓库就能决定凭据往哪儿发。 - URL 必须是 HTTPS,或者回环地址。
凭据不缓存——每次请求都从 Pi 的供应商凭据存储里取,所以供应商侧的刷新自动生效。
8.6.8 MCP 调用也走 Pi 的工具管线
最后一条容易被忽略但很重要:每一次 MCP 调用都走 Pi 的工具管线。
这意味着扩展的 tool_call / tool_result 处理器(包括你自己写的权限门禁)对 MCP 工具同样生效。MCP 工具不是一条绕过审计的后门。
另外:
- 从 codemode 脚本发起的 MCP 调用会带上 codemode 的调用 ID 作为
parentToolCallId,所以你能区分「模型直接调的」和「脚本里调的」。 pi.getAllTools()会报告每个服务器声明的注解:readOnlyHint、destructiveHint、idempotentHint、openWorldHint。权限扩展可以用这些提示决定哪些调用需要确认。资源工具被标为只读。
机制细节见 扩展开发。
8.7 事实来源
本文所有断言都可在基线 v1.3.0-16-g8efd314fa 的仓库里复算:
完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。
下一篇 9 Codemode:脚本化工具 展开本文 8.4 节里那个「脚本在沙箱里调工具」的宿主。想先理解 exposure 模型为什么存在,回 6 工具系统与信任边界;想自己接管这套机制,回 10 扩展开发。