CLI 与 SDK 的 MCP 装配
逐项对照 CLI 与 SDK 的能力装配来源,讲清扩展加载、工具激活与服务器连接三个环节的差异,给出可运行的 SDK 装配示例。
CLI 与 SDK 的 MCP 装配
在终端里敲 pi,mcp.json 就生效,服务器自动连上,工具自动注册。换成 SDK 写宿主,同样的配置放在那里,服务器一个都不连。
这不是配置没写对,是两条入口的装配机制根本不同。CLI 把 codemode、tool_search、MCP 当作内置扩展装载;SDK 会话默认不装载任何内置扩展,你必须自己把扩展工厂塞进资源加载器,还要自己触发扩展绑定。
这篇把这件事拆成三个可以分别验证的环节:扩展怎么进来、工具怎么被激活、服务器什么时候连。读完你能对着一个跑不起来的 SDK 会话,按固定顺序查出它缺的是哪一环。
读它需要会用 TypeScript,并了解 Pi 的会话生命周期(会话、扩展绑定、会话管理器三者怎么协作)。Pi 迭代很快,动手前先记录 pi --version;本文依据 Pi 官方文档 与 官方仓库 编写。
一、先给这个主题下定义
装配这件事有四个维度,混在一起谈最容易出错。
责任边界里有一条容易被忽略:项目信任在两条入口上不是同一件事。 CLI 会按项目信任流程询问;SDK 里 SettingsManager.create(cwd, agentDir, { projectTrusted }) 的 projectTrusted 默认是 true,项目级 .pi/mcp.json 会被直接读进来。宿主如果想对齐 CLI 的行为,得在第三个参数里显式传 { projectTrusted: false },自己决定信任策略。
二、CLI 与 SDK 的装配对照表
这是本文的核心表。逐项列出三个扩展在两条入口上的差别。
从这张表能读出一条主线:CLI 把"装载"这一步替你做了,SDK 不做。其余环节两条入口的语义完全一致。
内置扩展的让位机制
内置扩展是可替换的。如果你装的扩展注册了同名工具、命令或标志,内置的那个就不加载。pi config 里可以看到内置扩展的开关状态。
-builtin:mcp 能在设置里关掉内置 MCP,--no-mcp 则只对单次运行生效。
注意这只对 CLI 的内置扩展生效。SDK 里通过 extensionFactories 传进去的工厂是以内联扩展身份加载的,路径显示为 <inline:1> 这样的合成路径——它不是 builtin:mcp。所以 settings 里的 -builtin:mcp 和命令行 --no-mcp 对它无效,SDK 宿主是否要 MCP,完全由你传不传那个工厂决定。
三、SDK 侧必须做的三件事
1. 把扩展装进资源加载器
import {
createAgentSession,
createCodemodeExtension,
createMcpExtension,
createToolSearchExtension,
DefaultResourceLoader,
getAgentDir,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
const cwd = process.cwd();
// 资源加载器:SDK 不会替你把内置扩展塞进来,必须显式传 extensionFactories
const resourceLoader = new DefaultResourceLoader({
cwd,
agentDir: getAgentDir(),
extensionFactories: [
createCodemodeExtension({ mode: "on" }),
createToolSearchExtension(),
createMcpExtension(),
],
});
// 必须自己 reload:SDK 只有在内部创建加载器时才会替你 reload
await resourceLoader.reload();
// 扩展加载失败不会抛异常,结果收集在这里
const { errors, warnings } = resourceLoader.getExtensions();
if (errors.length > 0) {
console.error("扩展加载失败:", errors);
}
if (warnings?.length) {
console.warn("扩展告警:", warnings);
}2. 绑定扩展
session.bindExtensions() 发出 session_start,MCP 扩展的处理器在这个事件里读取配置、启动后台连接。漏掉这一步,扩展装了也不会连服务器。 这是最常见的故障。
3. 激活工具
codemode 和 tool_search 注册时是 inactive 的。官方 SDK 文档给的写法是:
const settingsManager = SettingsManager.create(cwd);
// "+name" 是在原有默认工具上追加,不是替换
settingsManager.applyOverrides({ defaultTools: ["+codemode", "+tool_search"] });applyOverrides() 必须在 createAgentSession() 之前调用。 会话创建时就用 defaultTools 算出了初始激活集合,创建之后再改设置不会回溯已建好的会话。
激活语义本身要分清两种写法:
同一份列表里,普通名字构成最终选择集合,+name 和 -name 再按顺序作用。
还有一层自动化:MCP 扩展会按服务器配置的曝光自动激活对应工具——配置里有 codemode 曝光的服务器就激活 codemode,有 deferred 曝光的就激活 tool_search。这条路径替代手写 defaultTools,但前提是 MCP 扩展和对应工具扩展都在场:激活动作只会落在已经注册、且声明了 reachesTools 的那个工具上。 另一个扩展注册了同名工具但没声明 reachesTools,永远不会被激活。
四、激活语义在两条入口上一致,但生效前提不同
defaultTools 里写 +codemode,能不能生效,取决于 codemode 这个工具是否已经被注册。
CLI 里内置扩展随启动装载,工具在,+codemode 生效。SDK 里如果你只写了 defaultTools 却忘了传 createCodemodeExtension(),这个设置不会报错,也不会有任何效果——工具表里根本没有这个名字。
这一点值得单独记:设置里点名一个不存在的工具,是静默失效,不是配置错误。
五、反例:以为"装了 SDK 就等于装了 CLI"
最常见的误判来自一个很自然的推理:CLI 能用 MCP,SDK 也是 Pi,那 SDK 肯定也能用。于是写出这样的代码:
// 反例:以为扩展会自己加载
import { createAgentSession } from "@earendil-works/pi-coding-agent";
const { session } = await createAgentSession();
// 这个列表永远是空的
console.log(session.getAllTools().filter((tool) => tool.name.startsWith("mcp__")));createAgentSession() 不传 resourceLoader 时,内部会创建一个 DefaultResourceLoader——但没有传 extensionFactories。SDK 不像 CLI 那样把内置扩展塞进去,所以这里一个 MCP 扩展都没有。
这个反例的排查路径是固定的,按顺序走:
第一步:查扩展在不在。
createAgentSession() 的返回值里有 extensionsResult,里面的 extensions 列出已加载的扩展、errors 列出加载失败的扩展。如果没有自己建加载器,就看这个返回值;如果自己建了,就看 resourceLoader.getExtensions()。列表为空说明问题在装载环节:要么没传 extensionFactories,要么传了但忘了 await resourceLoader.reload()。
第二步:查绑定发没发。
扩展在列表里,但工具表里没有 mcp__ 工具——那就是 session.bindExtensions() 没调,或者调了但返回前没等 MCP 连上。MCP 服务器是后台连的,bindExtensions() 返回时工具通常还没注册。
第三步:查激活对不对。
工具注册了、也在 getAllTools() 里,但模型调不到——看曝光。默认 codemode 曝光的 MCP 工具不出现在声明给模型的工具集里,它们是从 codemode 脚本里调的。deferred 曝光的要靠 tool_search 加载。想让模型直接看到,得给 direct 曝光。
第四步:查配置读没读到。
用 pi mcp list 单独验证服务器配置。它会连接每个启用的服务器并打印状态、工具和错误,条目无效或服务器没连上时退出码是 1。这条命令不加载扩展,只看文件配置的服务器——所以它能把"配置问题"和"装配问题"分开。
第五步:查配置目录。
MCP 扩展默认从全局 getAgentDir() 读 mcp.json 与 mcp-auth.json,不受 DefaultResourceLoader 的 agentDir 影响。要读别的目录,得给 createMcpExtension() 传 loadConfig。
第六步:查释放。
session.dispose() 与 AgentSessionRuntime.dispose() 不是一回事,后面第六节细说。
断言该看哪个列表
这是本主题最容易记错的一处:
默认曝光是 codemode,这类 MCP 工具既不声明给模型,也不列进 codemode 的描述,脚本靠 searchTools() 找它们。所以服务器连上了,getActiveToolNames() 里依然可能没有 mcp__ 工具——这是正常行为,不是故障。只有 direct 曝光的 MCP 工具才会直接出现在声明集合里。
六、可运行的 SDK 装配示例
下面是完整的装配流程,包含工具表断言和释放逻辑。
// sdk-mcp-assembly.ts
import {
createAgentSession,
createCodemodeExtension,
createMcpExtension,
createToolSearchExtension,
DefaultResourceLoader,
getAgentDir,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
const cwd = process.cwd();
// 第一步:显式装配三个内置扩展,SDK 不会替你做
const resourceLoader = new DefaultResourceLoader({
cwd,
agentDir: getAgentDir(),
extensionFactories: [
createCodemodeExtension({ mode: "on" }),
createToolSearchExtension(),
createMcpExtension(),
],
});
await resourceLoader.reload();
const loadErrors = resourceLoader.getExtensions().errors;
if (loadErrors.length > 0) {
throw new Error(`扩展加载失败: ${loadErrors.map((e) => `${e.path}: ${e.error}`).join("; ")}`);
}
// 第二步:在创建会话之前追加激活项,"+" 是追加语义
const settingsManager = SettingsManager.create(cwd);
settingsManager.applyOverrides({ defaultTools: ["+codemode", "+tool_search"] });
const { session } = await createAgentSession({
resourceLoader,
settingsManager,
sessionManager: SessionManager.inMemory(),
});
try {
// 第三步:绑定扩展。session_start 在这里发出,MCP 扩展此时开始后台连接
await session.bindExtensions({});
// 第四步:断言工具表。服务器在后台连接,需要等工具注册进来
const mcpTools = await waitForMcpTools(session, 10_000);
console.log("MCP 工具:", mcpTools.length > 0 ? mcpTools.join(", ") : "(无)");
console.log("声明给模型的工具:", session.getActiveToolNames().join(", "));
const unsubscribe = session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
try {
await session.prompt("List the files in the current directory.");
} finally {
unsubscribe();
}
console.log();
} finally {
session.dispose();
}
/**
* 轮询等待 MCP 工具注册。官方没有提供"等待 MCP 连接完成"的接口,
* 这是教学用的辅助函数,不是官方 API。
*/
async function waitForMcpTools(
session: { getAllTools(): Array<{ name: string; exposure: string }> },
timeoutMs: number,
): Promise<string[]> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const names = session
.getAllTools()
.filter((tool) => tool.name.startsWith("mcp__"))
.map((tool) => `${tool.name}(${tool.exposure})`);
if (names.length > 0) return names;
if (Date.now() >= deadline) return [];
await new Promise((resolve) => setTimeout(resolve, 200));
}
}释放:为什么 session.dispose() 不够
MCP 的连接清理挂在 session_shutdown 事件上:中止会话、关闭所有连接、清空服务器列表。而 session.dispose() 的官方描述是中止进行中的工作、失效扩展上下文、与 agent 断开、移除事件监听器——它不发出 session_shutdown。
后果是:一个只调 session.dispose() 的 SDK 宿主,stdio 服务器的子进程不会走 Pi 的关闭路径(关闭 stdin、SIGTERM、再对进程组发 SIGKILL)。要用运行时:
import {
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
createCodemodeExtension,
createMcpExtension,
createToolSearchExtension,
getAgentDir,
SessionManager,
} from "@earendil-works/pi-coding-agent";
// 用运行时的原因:只有 runtime.dispose() 会发 session_shutdown,MCP 的连接清理挂在这个事件上
// session.dispose() 不发这个事件,stdio 服务器的子进程会残留
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({
cwd,
// 运行时重建服务时,扩展工厂要一起传,否则新会话里又没有 MCP 了
resourceLoaderOptions: {
extensionFactories: [
createCodemodeExtension({ mode: "on" }),
createToolSearchExtension(),
createMcpExtension(),
],
},
});
return {
...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: process.cwd(),
agentDir: getAgentDir(),
sessionManager: SessionManager.create(process.cwd()),
});
try {
// 绑定扩展发出 session_start,MCP 扩展在这时开始后台连接
await runtime.session.bindExtensions({});
await runtime.session.prompt("List the files in the current directory.");
} finally {
// dispose() 先发 session_shutdown(MCP 在此关闭连接),再 dispose 会话
await runtime.dispose();
}运行时还带来另一条好处:替换会话(newSession()、fork()、switchSession())后会话对象会换掉,bindExtensions() 和订阅都要在新会话上重做。
七、哪些是官方行为,哪些是源码事实,哪些是教学设计
按要求把三类信息分开。
你现在能做什么
- 写出一个 SDK 宿主,把三个扩展工厂传进
extensionFactories,并断言session.getAllTools()里出现了mcp__开头的工具名。 - 用
pi mcp list的退出码写一个断言,把"配置问题"和"装配问题"在 CI 里分开定位。 - 故意删掉
await session.bindExtensions({}),观察工具表里没有mcp__工具、没有连接尝试,然后补回来。 - 故意把
defaultTools写成["codemode"](替换语义),观察read、bash、edit、write全部消失,再改成["+codemode"]修好。 - 在
defaultTools里写一个不存在的工具名,确认它静默失效而不报错。 - 把 SDK 宿主的释放路径从
session.dispose()换成runtime.dispose(),验证 stdio 子进程被回收。