# MCP：采样与工具检索

> MCP 服务器请求模型补全时怎样选模型、审批与检测停滞，自管的 stdio 传输与结果规范化，轮次生命周期通知，以及工具过多时的按需检索与排序。

- 作者：David（道雾轩）
- 专栏：MiMo Code 源码解读（https://daiw.org/manual/mimo-code.md）
- 最后更新：2026-09-17
- 原文：https://daiw.org/manual/mimo-code/mcp
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

上游的 MCP 集成（连接与状态、工具命名、OAuth、`mcp` 子命令）见 [MCP 集成](https://daiw.org/manual/opencode/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 迁移](https://daiw.org/manual/mimo-code/imports)。

只允许一个服务器采样、且不弹询问的写法：

```jsonc
{
  "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`）：

```ts
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`）：

```ts
  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/createMessage` request 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` 设了超时，采样审批同样到点自动拒绝，见[上一篇](https://daiw.org/manual/mimo-code/git-guards)。

### 调用、心跳与停滞检测

模型调用用 `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 `StdioClientTransport` clears its private `_process` in `close()` before callers can read exitCode, and `onclose` does 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 沙箱](https://daiw.org/manual/mimo-code/exec-sandbox)）。设计文档 `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`）。

下一篇：[模型、提供方与提示词路由](https://daiw.org/manual/mimo-code/providers)——进入第 7 部分，看模型与提供方怎么配置、系统提示词怎么按模型路由。
