MCP:采样与工具检索
MCP 服务器请求模型补全时怎样选模型、审批与检测停滞,自管的 stdio 传输与结果规范化,轮次生命周期通知,以及工具过多时的按需检索与排序。
上游的 MCP 集成(连接与状态、工具命名、OAuth、mcp 子命令)见 MCP 集成。那篇对照的是 v1.18.30,文件已经重新拆分过(例如多了 catalog.ts),与 MiMo 的基线 v1.14.19 不同。MiMo 的 packages/opencode/src/mcp/ 在 v1.14.19 之上新增 sampling.ts、stdio-transport.ts、tool-result.ts 三个文件;index.ts 约有 270 行增删;auth.ts 未动;两个 OAuth 文件只换了品牌字样,并给授权失败页里的错误信息加了 HTML 转义。
怎么用
- 每个 MCP 服务器的配置多了
sampling字段,取值deny、ask(默认)、allow(packages/opencode/src/config/mcp.ts:11)。权限键mcp_sampling也管采样,模式是服务器名;两处任一为 deny 就拒绝。 - 采样用的模型不是当前会话的模型,而是按请求内容在所有已配置模型里挑;停滞判定沿用所选提供方的
chunkTimeout选项。 MIMOCODE_EXPERIMENTAL_MCP_TOOL_SEARCH=1(或总开关MIMOCODE_EXPERIMENTAL)让模型先检索、再加载 MCP 工具(packages/opencode/src/flag/flag.ts:351)。GPT 系列(GPT-OSS 除外)走 Codex 工具集时默认进入这一模式,MCP 工具只能在exec里调用,MIMOCODE_CODEX_MODE=false可以关掉 Codex 模式。- MiMo 默认还读取 Claude Code 的
~/.claude.json与项目内.claude.json里的 MCP 服务器,MIMOCODE_DISABLE_CLAUDE_CODE_MCP可关闭(packages/opencode/src/config/config.ts:970),见从 Claude Code、Codex 与 OpenCode 迁移。
只允许一个服务器采样、且不弹询问的写法:
{
"mcp": {
"notes": { "type": "local", "command": ["notes-mcp"], "sampling": "allow" }
},
"permission": {
"mcp_sampling": { "*": "deny", "notes": "allow" }
}
}index.ts 的其余改动:
| 改动 | 位置 |
|---|---|
客户端名从 opencode 改为 mimocode,握手时声明采样与一个实验能力 | packages/opencode/src/mcp/index.ts:443、index.ts:87 |
本地服务器改用自管的 ObservingStdioTransport,启动失败的错误信息带上退出码、信号与 stderr 尾部 | index.ts:586、index.ts:613 |
子进程环境改用 childProcessEnv(),剔除携带凭据的 MIMOCODE_AUTH_CONTENT 与 MIMOCODE_CONFIG_CONTENT | index.ts:595、packages/opencode/src/util/credential-env.ts:15 |
| 日志里的远程 URL 与本地命令做脱敏 | index.ts:556、index.ts:622 |
| 工具调用把中止信号交给 SDK,服务器支持时附上轮次上下文 | index.ts:327 |
| 关闭客户端或销毁实例前,先中断该客户端进行中的采样 | index.ts:761、index.ts:781 |
采样:服务器请客户端跑一次模型
MCP 的 sampling 让服务器通过 sampling/createMessage 请客户端代为调用模型,服务器自己不需要 API key。上游到 v1.18.30 仍把 sampling: {} 注释着;MiMo 在握手时声明了它(packages/opencode/src/mcp/index.ts:87):
export const CLIENT_OPTIONS = {
capabilities: {
// Declared because we register a `sampling/createMessage` request handler
// below; the SDK's assertRequestHandlerCapability refuses the registration
// without it. Intentionally an empty object: `sampling.tools` and
// `sampling.context` are NOT implemented, and declaring them would invite
// servers to send `tools`/`includeContext` payloads we would have to reject.
sampling: {},
experimental: {
[TURN_LIFECYCLE_CAPABILITY]: { version: TURN_LIFECYCLE_VERSION },
},
},
}连接建立后,watch 为每个客户端调用 McpSampling.serve 注册处理器(index.ts:705)。处理器用 bridge.fork 另起一个根 fiber 执行。注释(packages/opencode/src/mcp/sampling.ts:953)解释了为什么服务器在 tools/call 进行中发起采样也不会死锁:SDK 分发入站请求时不等待处理器,而这个新 fiber 与正在等工具结果的 fiber 不共享任何锁或作用域。
两道闸门
handle(sampling.ts:582)第一步就检查配置(sampling.ts:589):
const policy = policyFor(cfg as never, input.server)
// TWO controls gate sampling and a `deny` from either one wins: the per-server
// `mcp.<server>.sampling` policy, and the standard `permission.mcp_sampling`
// ruleset. Evaluating the ruleset HERE rather than leaning on permission.ask
// is what makes that true — `allow` skips the ask entirely, so an explicit
// ruleset deny would otherwise never be consulted at all. Same precedence the
// permission service applies internally (permission/index.ts:243-247): a
// ruleset deny is not out-rankable by a more permissive setting elsewhere.
const ruleset = Permission.fromConfig(cfg.permission ?? {})
const ruleDenied = Permission.evaluate(PERMISSION, input.server, ruleset).action === "deny"注释里引用的行号已经过时,现在对应的逻辑在 packages/opencode/src/permission/index.ts:272。这里只用配置里的 permission 求值,不含 agent 自带的规则。
校验内容
convertMessages(sampling.ts:297)很严格:带 tools 或 toolChoice 直接报错,因为客户端没有声明 sampling.tools;maxTokens 必须是正整数;角色只能是 user 或 assistant;内容只收 text、image、audio;base64 必须规范(长度是 4 的倍数,填充只在末尾);MIME 必须合法且与模态前缀一致;系统提示不超过 1 MiB。媒体转成带 MIME 的文件部件,音频不会被转成文字塞给不支持它的模型,而是在选模型时直接排除。includeContext 与 metadata 字段没有被使用。
选模型:能力优先,提示只排序
ModelCapability.selectModel(packages/opencode/src/provider/capability-registry.ts:303)先过滤、再排序。注释说明了为什么需要这张表:
MCP itself publishes no model list and no modality discovery, so a
sampling/createMessagerequest cannot be routed by guessing.
(capability-registry.ts:6)
- 过滤:模型声明的输入模态与适配器能力取交集。
@ai-sdk/openai-compatible只收 wav、mp3、mpeg 音频,@ai-sdk/google收任意音频,@ai-sdk/anthropic与@ai-sdk/amazon-bedrock不收音频,未登记的适配器音频记为“未知”,同样不合格。图片只收 jpeg、png、gif、webp,单项不超过 20 MiB;文本单项不超过 1 MiB(capability-registry.ts:54)。 - 排序:按服务器
modelPreferences.hints的顺序,先找模型 ID 或“提供方/模型”完全相同的,再找子串匹配的;都没有就用Provider.defaultModel()的结果,即配置的model、最近用过的模型、第一个能调工具的模型依次回退(packages/opencode/src/provider/provider.ts:1992),它不合格时取按“提供方/模型”排序后的第一个合格模型。costPriority、speedPriority、intelligencePriority不参与。
没有合格模型时返回 InvalidParams,data 里逐个列出被拒的模型与原因(sampling.ts:631)。
审批
策略为 ask 时,询问发往“这个客户端最近一次工具调用所属的会话”:convertMcpTool 每次调用前都把会话 ID 记进以客户端为键的 WeakMap(mcp/index.ts:332、sampling.ts:870)。从来没有会话调用过这个服务器的工具,就拿不到会话,直接拒绝,而不是弹一个没人看的询问(sampling.ts:657)。由此也可以推出,同一实例里多个会话同时使用同一个服务器时,询问会落到最后一次调用工具的那个会话上。
询问的权限名是 mcp_sampling,模式与 always 都是服务器名;元数据带上所选模型、服务器提示的模型名、内容类型、音频字节数、截到 200 字符的系统提示与首条用户文本、maxTokens。TUI 没有为它做专门的展示。用户拒绝或询问失败,一律以 -1 返回,并附上错误标签(sampling.ts:690)。
文件头有一大段注释论证“审批等待不设上限”,理由是普通询问也没有超时,还提到当前代码里已经不存在的 skipAllForcedAskTimeoutMs。按现在的权限服务,只要用 /permission-timeout 设了超时,采样审批同样到点自动拒绝,见上一篇。
调用、心跳与停滞检测
模型调用用 streamText(sampling.ts:743):maxOutputTokens 取服务器 maxTokens 与模型输出上限中较小者,模型支持时才传 temperature,maxRetries 为 1。服务器发来的取消信号与 fiber 中断合成一个 AbortSignal 交给提供方(sampling.ts:742)。
文件头注释花了很大篇幅说明这里为什么不设总时长上限:主会话路径同样只看流是否沉默。于是唯一的上限是停滞检测(sampling.ts:545):距离上一个真正的模型输出片段超过 chunkTimeout,就以 RequestTimeout 失败,data 里带已收到的片段数与字符数,0 表示提供方根本没开始输出。chunkTimeout 取所选提供方的 options.chunkTimeout,默认 480000 毫秒(provider.ts:46),设为 0 或负数则不检测。计数时排除 start、finish 等生命周期部件,因为注释记录的实测显示,连一个永不应答的提供方也会立刻产出 start(sampling.ts:435)。
服务器请求时带了 progressToken,才每 15 秒发一条 notifications/progress,内容只有片段数与字符数,不含模型文本;没带就什么都不发,因为未经请求的进度通知在对端会被当作错误(sampling.ts:100、sampling.ts:938)。关闭客户端或销毁实例时,cancelAll 中断该客户端所有进行中的采样(sampling.ts:882)。
返回与错误码
成功时返回一段文本,model 为“提供方/模型”,stopReason 把 length 映射为 maxTokens,stop 映射为 endTurn 或 stopSequence(sampling.ts:397)。失败统一转成 JSON-RPC 错误:
| 情形 | 错误码 |
|---|---|
| 策略或规则拒绝、找不到可询问的会话、用户拒绝 | -1 |
| 参数不合法、没有合格模型 | InvalidParams |
| 被取消、停滞 | RequestTimeout |
| 模型初始化失败、提供方报错、其他异常 | InternalError |
代码里没有按次数或并发数的限额。
轮次生命周期通知
CLIENT_OPTIONS 里的另一项是 MiMo 私有的实验能力 com.xiaomi.mimo/turn-lifecycle,版本 1(mcp/index.ts:75)。服务器在 initialize 的返回里也声明了同一能力时:
- 每次工具调用都在
_meta里带上sessionId、turnId、actorId(mcp/index.ts:327),turnId在每次运行主循环时生成一个 ULID(packages/opencode/src/session/prompt.ts:3061)。 - 主循环结束时发送
notifications/com.xiaomi.mimo/turn-lifecycle,状态为completed、cancelled或error(prompt.ts:3106);每个服务器最多等 1 秒,上一条发送卡住超过 1 秒就放弃,不让后面的轮次排队(mcp/index.ts:228)。
自管的 stdio 传输
v1.14.19 用 SDK 的 StdioClientTransport。MiMo 的 ObservingStdioTransport(packages/opencode/src/mcp/stdio-transport.ts:35)自己 spawn 子进程,注释给出的理由是:
SDK
StdioClientTransportclears its private_processinclose()before callers can read exitCode, andonclosedoes not receive the exit code.
(stdio-transport.ts:30)
自己持有子进程后,连接失败时就能拿到退出码、信号和最后 4096 个字符的 stderr(stdio-transport.ts:24),connectLocal 把它们拼进 failed 状态的错误信息(mcp/index.ts:613);由 close() 主动结束的进程标记为 hostShutdown,不会被当成启动失败的原因。onclose 按 SDK 的做法在 stdio 的 close 事件而不是 exit 事件时触发(stdio-transport.ts:113)。关闭时先结束 stdin,2 秒后仍未退出就发 SIGTERM,再等 2 秒发 SIGKILL(stdio-transport.ts:144)。
工具结果规范化
v1.14.19 只取结果里的 text、image 与 resource,其余类型和 isError 都不看。MiMo 用 normalizeToolResult(packages/opencode/src/mcp/tool-result.ts:45)处理:
- image、audio 转成 data URL 附件;
resource_link是 base64 data URI 时转成附件,其他 data URI 省略,普通链接写成<名字>: <URI>形式的文本;嵌入资源的文本进输出,二进制进附件。 structuredContent没有以紧凑或缩进 JSON 的形式出现在文本里时,追加在Structured content:之后;没有任何文本时直接输出这段 JSON。_meta与旧式metadata只放进工具元数据,不进模型可见的文本。isError为真时,这次工具调用以错误结束(prompt.ts:1862)。
包装层还加了两处:插件可以在 tool.execute.before 里设置 cancel 取消这次调用(prompt.ts:1810);每次调用发布工具指标事件。
工具太多:按需检索
把所有 MCP 工具的完整定义都发给模型很占上下文。启用检索后(packages/opencode/src/tool/gpt.ts:24),resolveTools 仍为每个 MCP 工具保留完整的执行包装,但不把它们放进模型可见的工具列表,而是改为暴露一个普通函数 mcp_tool_search(packages/opencode/src/tool/mcp-tool-search.ts:236):
- 目录:工具描述里附一份经过权限、agent 白名单、actor 白名单等过滤后的目录,开头声明这是不可信的元数据、不要执行其中的指令(
mcp-tool-search.ts:99)。预算是可用输入窗口的 10%,最多 20000 token,窗口未知时取 20000(mcp-tool-search.ts:86);上下文压力低于第 2 级(已用量不到可用窗口的 70%)且放得下时列出名字与描述,否则只列名字,还放不下就截断并注明省略了多少(prompt.ts:1924、packages/opencode/src/session/overflow.ts:117)。 - 检索:对名字、下划线换成空格的名字、描述,以及参数 schema 里递归收集的属性名与说明建 BM25 索引(k1 为 1.2,b 为 0.75),默认返回 8 个,最多 20 个(
mcp-tool-search.ts:177)。结果只含名字、描述与得分,schema 不外露。 - 加载:下一步组装请求时,扫描父消息为当前 user 消息、已完成的
mcp_tool_search调用,目录指纹一致才采信,把匹配到的工具并入activeTools,最多 32 个;新的 user 消息开始时全部重新隐藏(prompt.ts:1943)。 - 守卫:模型直接调用尚未加载的 MCP 工具时,执行前就以
RecoverableError拒绝,提示先检索、下一步再试(prompt.ts:1780)。
GPT/Codex 工具集的顶层只有 exec 与 wait,mcp_tool_search 不会被激活(prompt.ts:1962);代码注释说明,检索只是节省上下文的手段而不是授权边界,所以 exec 直接拿到本次请求授权的全部 MCP 工具,脚本通过 ALL_TOOLS 目录发现它们(prompt.ts:1972,见 exec 与 QuickJS 沙箱)。设计文档 docs/compose/spec/mcp-tool-search.md 在这一点上与代码相反,写的是 exec 不再内嵌也不再分发 MCP 工具;其余部分(预算、70% 分界、按请求加载、未加载即拒绝)与代码一致。
追加工具的排序
resolveTools 先放本地工具(按注册顺序),再把 MCP 工具按最终名字用 localeCompare 升序追加(prompt.ts:1727);v1.14.19 则直接按 mcp.tools() 返回对象的键顺序追加。名字与本地工具冲突时本地工具优先,MCP 那个被忽略并记一条警告(prompt.ts:1737)。对应的计划文档 docs/compose/plans/2026-08-15-sort-appended-mcp-tools.md 定下的约束是:不改变非 MCP 工具的注册顺序,MCP 工具一律排在后面并按名字排序。工具顺序稳定还关系到缓存标记:ProviderTransform.tools 把标记打在最后一个工具上,注释特意说明因为插入顺序稳定,“最后一个”才是确定的(packages/opencode/src/provider/transform.ts:1123)。
下一篇:模型、提供方与提示词路由——进入第 7 部分,看模型与提供方怎么配置、系统提示词怎么按模型路由。