跳到主内容
创建时间:最后更新:作者:白川

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 官方文档 与 官方仓库 编写。

一、先给这个主题下定义

装配这件事有四个维度,混在一起谈最容易出错。

维度这个主题里的具体含义
输入
mcp.json(~/.pi/agent/mcp.json 与项目 .pi/mcp.json)、宿主传入的 extensionFactories、defaultTools 设置
输出
注册进工具表的 mcp__<服务器>__<工具>、资源工具(list_mcp_resources、list_mcp_resource_templates、read_mcp_resource)、/mcp 命令与状态、系统提示里的 mcp_servers 段、启动后一次性报告的连接错误
状态
服务器连接状态(connecting / connected / failed / needs-auth / disconnected / closed)、工具曝光(codemode / deferred / direct / hidden)、扩展加载结果里的 errors 与 warnings
责任边界
Pi 负责:解析配置、连接服务器、注册工具、执行调用、关闭连接。宿主负责:把扩展装进来、触发绑定、决定项目信任、决定配置目录、保证释放

责任边界里有一条容易被忽略:项目信任在两条入口上不是同一件事。 CLI 会按项目信任流程询问;SDK 里 SettingsManager.create(cwd, agentDir, { projectTrusted }) 的 projectTrusted 默认是 true,项目级 .pi/mcp.json 会被直接读进来。宿主如果想对齐 CLI 的行为,得在第三个参数里显式传 { projectTrusted: false },自己决定信任策略。

二、CLI 与 SDK 的装配对照表

这是本文的核心表。逐项列出三个扩展在两条入口上的差别。

扩展CLI 怎么来SDK 怎么来激活方式连接时机装错时的症状
codemode
内置扩展 builtin:codemode,随 CLI 启动装载
必须显式加 createCodemodeExtension() 到 extensionFactories
注册时 inactive;由 defaultTools 的 +codemode 或 MCP 扩展按曝光自动激活
不涉及连接
工具表里有 codemode 但不可用;MCP 工具永远调不到
tool_search
内置扩展 builtin:tool-search
必须显式加 createToolSearchExtension()
同上,+tool_search 或 deferred 曝光的服务器自动激活
不涉及连接
模型找不到 deferred 工具
MCP(含 /mcp 命令)
内置扩展 builtin:mcp;-builtin:mcp、--no-mcp 可关
必须显式加 createMcpExtension()
命令随扩展注册即用
在 session_start 触发后后台连接
工具表里没有 mcp__ 开头的工具;没有 /mcp
扩展绑定
CLI 各模式自动调 session.bindExtensions()
宿主必须自己调
——
bindExtensions() 发出 session_start,MCP 扩展在此开始连接
扩展加载了但服务器一个没连
默认工具集合
settings.json 的 defaultTools,或命令行 --tools
settings.json 的 defaultTools,或 settingsManager.applyOverrides()
——
——
defaultTools 写了 +codemode 却没生效

从这张表能读出一条主线: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. 把扩展装进资源加载器

typescript
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 文档给的写法是:

typescript
const settingsManager = SettingsManager.create(cwd);
// "+name" 是在原有默认工具上追加,不是替换
settingsManager.applyOverrides({ defaultTools: ["+codemode", "+tool_search"] });

applyOverrides() 必须在 createAgentSession() 之前调用。 会话创建时就用 defaultTools 算出了初始激活集合,创建之后再改设置不会回溯已建好的会话。

激活语义本身要分清两种写法:

写法语义结果
["+codemode", "+tool_search"]
追加
保留 read、bash、edit、write,再加上这两个
["codemode"]
替换
只剩 codemode,read、bash、edit、write 全被换掉

同一份列表里,普通名字构成最终选择集合,+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 肯定也能用。于是写出这样的代码:

typescript
// 反例:以为扩展会自己加载
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() 不是一回事,后面第六节细说。

断言该看哪个列表

这是本主题最容易记错的一处:

列表内容判断什么
session.getAllTools()
全部已注册工具,含未声明给模型的
MCP 工具有没有接上
session.getActiveToolNames()
声明给模型的工具
模型能直接调什么

默认曝光是 codemode,这类 MCP 工具既不声明给模型,也不列进 codemode 的描述,脚本靠 searchTools() 找它们。所以服务器连上了,getActiveToolNames() 里依然可能没有 mcp__ 工具——这是正常行为,不是故障。只有 direct 曝光的 MCP 工具才会直接出现在声明集合里。

六、可运行的 SDK 装配示例

下面是完整的装配流程,包含工具表断言和释放逻辑。

typescript
// 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)。要用运行时:

typescript
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() 和订阅都要在新会话上重做。

七、哪些是官方行为,哪些是源码事实,哪些是教学设计

按要求把三类信息分开。

分类内容
官方已定义
CLI 加载 codemode、tool_search、MCP 三个内置扩展;SDK 会话不加载,需自行加入 extensionFactories;codemode 与 tool_search 注册时 inactive;defaultTools 的 +name 追加、普通名字替换;MCP 扩展在 session_start 连接服务器,因此 SDK 侧要调 bindExtensions();pi mcp list 在条目无效或服务器未连上时退出码为 1;pi mcp 系列 shell 命令不加载扩展;session.dispose() 的职责范围不含 session_shutdown
源码事实(文档未逐字确认,以官方仓库当前实现为准)
MCP 扩展默认从全局 getAgentDir() 读 mcp.json 与 mcp-auth.json,不受 DefaultResourceLoader 的 agentDir 影响,需要改目录得传 loadConfig;MCP 服务器曝光 codemode 时映射为工具曝光 deferred,因此不进入声明给模型的集合;SDK 传入的工厂以内联扩展身份加载,路径为 <inline:N>,-builtin:mcp 与 --no-mcp 对其无效;扩展加载失败被收集为 errors 而非抛出;自动激活要求工具已注册且声明 reachesTools
教学设计
第五节的六步排查顺序;waitForMcpTools() 轮询辅助函数;把"注册表"与"声明集合"两个列表并排对照讲;用"反例"承载"入口相同能力不同"的误判

你现在能做什么

  • 写出一个 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 子进程被回收。

排障速查

现象检查点处理
SDK 里一个 mcp__ 工具都没有
扩展装了吗
extensionFactories 里加 createMcpExtension(),并 await resourceLoader.reload()
扩展在,工具还是没出现
绑定了吗
调 await session.bindExtensions({});MCP 是后台连接,工具稍后才注册
getAllTools() 有 mcp__,模型却调不到
曝光
默认 codemode 不声明给模型;改 direct,或用 tool_search 发现
defaultTools 写了 +codemode 没效果
工具注册了吗
没传 createCodemodeExtension() 时这个名字是静默失效的
默认工具被换掉了
defaultTools 写法
普通名字是替换语义,+name 才是追加
applyOverrides 改了却没生效
调用时机
必须在 createAgentSession() 之前调用
CLI 正常,SDK 不正常
装配差异
对照第二节的表逐项补:三个扩展工厂 + 一次绑定
服务器连不上
配置还是环境
pi mcp list;stdio 服务器看 stderr 尾部,日志在 ~/.pi/agent/mcp.log
项目级 .pi/mcp.json 不生效
项目信任
CLI 侧要信任项目;SDK 侧 projectTrusted 默认 true,需自己决定
SDK 读的不是你想读的配置目录
配置目录
MCP 扩展用全局 getAgentDir(),与加载器的 agentDir 无关;用 loadConfig 覆盖
stdio 服务器进程没被回收
释放路径
session.dispose() 不发 session_shutdown,改用 runtime.dispose()
改完配置没反应
是否需要重载
会话内 /reload;pi mcp shell 命令不加载扩展,看不到扩展侧改动

参考资料