# MCP

> ZCode 的 MCP 客户端：配置从用户、项目、插件与内置宿主四处合并，支持 stdio、http、sse 三种传输；会话一创建就开始连接，首次模型请求前注册工具；工具命名与图片归一、OAuth 两段式授权、官方 MCP 的身份头注入，以及工作区级 MCP 自动连接的安全含义。

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

ZCode 只实现了 MCP 的客户端一侧，而且只用它的一项能力：把外部服务器暴露的工具接进 Agent 的工具表。端口 `McpPort` 只有连接、断开、探活、查状态、列工具、调工具、关闭这几个方法（`apps/zcode-cli/packages/contracts/src/interfaces/mcp.port.ts:290`），资源（resources）与提示词（prompts）都不在其中；客户端构造时只传了版本协商选项，没有声明 roots、sampling 之类的客户端能力（`apps/zcode-cli/packages/adapters/src/mcp/index.ts:1042`），代码里也没有处理 `tools/list_changed` 通知的地方，工具表在连接时一次定型。

代码分四层。`apps/zcode-cli/packages/adapters/src/mcp` 的 19 个文件约 6000 行是适配器，基于官方 TypeScript SDK `@modelcontextprotocol/client` 2.0.0（`apps/zcode-cli/packages/adapters/package.json:105`），管传输、连接、进程回收、OAuth 与官方鉴权；`apps/zcode-cli/packages/core/src/mcp` 把工具描述投影成运行时的工具条目，并归一结果里的图片；`apps/zcode-cli/packages/core/src/runtime/methods/mcp.ts` 决定何时连接、何时注册；bootstrap 把各路配置合并成一张服务器表。端口类型经 `@zcode/contracts/mcp` 子路径导出，指向的就是上面那个 `mcp.port.ts`。浏览器控制所用的 `node_repl` 也是一台 MCP 服务器，留给[下一篇](https://daiw.net/manual/zcode/node-repl-browser)。

## 怎么用

配置写在主配置文件里，用户级默认是 `~/.zcode/cli/config.json`（`apps/zcode-cli/packages/adapters/src/config/file-config.adapter.ts:61`），服务器放在 `mcp.servers` 下；`features.mcp` 缺省为真（`apps/zcode-cli/packages/adapters/src/config/index.ts:289`）。README 的示例（`apps/zcode-cli/README.md:144`）：

```json
{
  "features": {
    "mcp": true
  },
  "mcp": {
    "servers": {
      "filesystem": {
        "type": "stdio",
        "command": "npx",
        "args": ["-y", "@modelcontextprotocol/server-filesystem", "."],
        "cwd": ".",
        "timeoutMs": 30000
      },
      "docs": {
        "type": "http",
        "url": "https://mcp.example.com/mcp",
        "headers": {
          "Authorization": "Bearer <token>"
        }
      },
      "legacy-sse": {
        "type": "sse",
        "url": "https://mcp.example.com/sse",
        "enabled": false
      }
    }
  }
}
```

字段以 schema 为准（`apps/zcode-cli/packages/adapters/src/config/schema.ts:46`）：

| 传输 | 必填 | 可选 |
| --- | --- | --- |
| `stdio` | `command` | `args`、`cwd`、`env` |
| `http` | `url` | `headers`、`oauth` |
| `sse` | `url` | `headers`、`oauth` |
| 三者共有 | —— | `enabled`、`timeoutMs`、`protocolVersion`（`auto`、`legacy`、`2026-07-28`） |

README 的字段说明（`apps/zcode-cli/README.md:176`）漏了 `oauth` 与 `protocolVersion`。解析前还有一层宽容的归一（`schema.ts:331`）：不写 `type` 时有 `command` 就当 `stdio`、有 `url` 就当 `http`，`type: "remote"` 改成 `http`；旧字段 `environment`、`enable`、`http_headers` 分别映射到 `env`、`enabled`、`headers`，别家配置里的 `timeout`、`startup_timeout_sec` 直接丢弃。单台服务器校验不过只跳过它并记一条诊断，不连累整份配置（`schema.ts:488`）。

在 CLI 里查看和管理用 `/mcp`（`apps/zcode-cli/packages/cli/src/command-center/handlers/mcp.ts:5`）：

| 命令 | 作用 |
| --- | --- |
| `/mcp`、`/mcp list`、`/mcp status` | 三者相同，逐台列出状态、传输、工具数与错误 |
| `/mcp connect <server>` | 按已配置的条目重新连接，未配置的名字直接报错 |
| `/mcp disconnect <server>` | 断开当前连接 |

TUI 侧栏的 MCP 分区每 5 秒刷新一次状态，出错时改为 10 秒（`apps/zcode-cli/packages/tui/src/app-mcp-status.ts:5`），最多列出 5 台（`apps/zcode-cli/packages/tui/src/app-sidebar-mcp.tsx:16`）。

## 配置从哪来

```mermaid
flowchart TD
  P["项目：git 根到 cwd 各级的 zcode.json 与 .zcode/config.json"] --> R["resolveEffectiveMcpServers：同名时用户压过项目"]
  U["用户：~/.zcode/cli/config.json"] --> R
  R --> C["configuredMcpServers：插件、配置、内置依次覆盖"]
  D["桌面端 session/create 带来的 mcp.servers"] -.->|有则替换文件配置| C
  PL["已启用插件的服务器，键名 plugin:插件名:键"] --> C
  B["内置 node_repl"] --> C
  C --> O["omitMcpServers：剔除退役的 CUA 条目，给 node_repl 注入凭据"]
  O --> RT["runtimeConfig.mcp.servers：会话启动时全部自动连接"]
```

项目配置的发现规则与 Hook 共用：从工作目录向上找到含 `.git` 的目录为止，沿途每一级的 `zcode.json` 和 `.zcode/config.json` 都算（`packages/shared/src/workspace-hook-config.ts:174`、`workspace-hook-config.ts:335`），找不到 `.git` 就只看工作目录本身。全局配置按系统默认、用户、项目、环境变量、命令行的顺序覆盖（`apps/zcode-cli/packages/adapters/src/config/config-factory.ts:122`），MCP 服务器却单列一条规则：同名时用户配置压过项目配置（`config-factory.ts:391`）。环境变量层没有任何 MCP 相关的变量（`apps/zcode-cli/packages/adapters/src/config/env-config.adapter.ts:14`），CLI 也没有对应参数，所以文件层实际只有用户与项目两级。README 说当前 CLI 不会自动发现插件以外的 `mcp.json`、`.mcp.json`（`apps/zcode-cli/README.md:141`），这没错，但它没提项目目录里的 `zcode.json` 与 `.zcode/config.json` 同样会被读进来。

文件层之外还有三路，在 bootstrap 里合并（`apps/zcode-cli/packages/bootstrap/src/app/runtime-config.ts:90`）：

```ts
  // Protocol session/create 传入的 mcp.servers 只包含 UI MCP 设置里的用户配置，
  // 不包含插件注册的 MCP。宿主内建 server 最后合并并保留其 identity，避免用户或第三方
  // 用同名配置劫持 mcp__node_repl__*；普通 plugin MCP 仍允许显式用户配置覆盖。
  const configuredMcpServers = {
    ...pluginMcpServers,
    ...(options.runtimeConfig?.mcp?.servers ?? configResult.config.mcp.servers),
    ...builtInMcpServers,
  };
  // ...
  // 产品决定 workspace MCP 开箱即用：project 作用域 MCP 默认 trusted，并自动连接。
  const untrustedProjectMcpServers = new Set<string>();
  const autoConnectMcpServers = omitMcpServers(
    configuredMcpServers,
    untrustedProjectMcpServers,
    cuaBridgeServerNames,
  );
```

- **插件**：服务器键被加上 `plugin:<插件名>:` 前缀（`apps/zcode-cli/packages/adapters/src/plugins/mcp.ts:67`），处在最底层，用户可以用同名条目覆盖。清单写法与变量展开见[插件与官方市场](https://daiw.net/manual/zcode/plugins)。
- **桌面端**：经 ZCode Protocol 创建会话时带上设置页的服务器表，有它就整体替换文件配置（`runtime-config.ts:95`）。这张表由桌面主进程读 `~/.zcode/cli/config.json`、工作区 `.zcode/config.json`，并把 `.agents/mcp.json` 的 `mcpServers` 当兜底来源（`packages/desktop/src/main/mcpUserDirectory/index.ts:37`、`mcpUserDirectory/index.ts:52`）；桌面端还会不落盘地把当前工作区路径追加到 `@modelcontextprotocol/server-filesystem` 的参数里（`packages/services/src/session/mcpWorkspaceScope.ts:46`），设置页也能把本机条目同步到远程工作区（`packages/services/src/mcp-sync/mcpSync.ts:18`）；界面一侧的 MCP 类型另在 `packages/shared/src/mcp.ts:24`。
- **内置**：`node_repl` 最后合并，同名的用户或插件条目劫持不了 `mcp__node_repl__*`。

`omitMcpServers` 的第二个参数本是“未受信任的项目服务器”名单，这里恒为空集，后文再谈它的含义。

## 三种传输与超时

`createTransport` 按 `type` 直接选传输，不存在失败后换一种再试的回退（`apps/zcode-cli/packages/adapters/src/mcp/index.ts:1407`）：

| 传输 | 实现 | 要点 |
| --- | --- | --- |
| `stdio` | `ProcessTreeStdioClientTransport`，继承 SDK 的 `StdioClientTransport` | `cwd` 相对工作目录解析，缺省即工作目录；stderr 走管道，末尾 4000 字符脱敏后才进失败日志（`adapters/src/mcp/index.ts:97`、`adapters/src/mcp/index.ts:1901`） |
| `http` | SDK 的 `StreamableHTTPClientTransport` | `headers` 作静态请求头，fetch 遵循 ZCode 的代理与 CA 设置 |
| `sse` | SDK 的 `SSEClientTransport` | 协议时代强制 legacy |

README 说 stdio 进程继承 zcode 的环境再叠加 `env`，实际并非原样继承：先剔除一批 ZCode 私有的运行时变量（代理、证书、CUA 凭据等），再按网络设置重新写入代理与 CA 变量，最后才叠加配置里的 `env`（`apps/zcode-cli/packages/adapters/src/mcp/network.ts:9`）；如果当前 Agent 由 `node` 可执行文件启动，还会把它所在目录补进 `PATH`，让插件里写的 `command: "node"` 在远程主机上也能起来（`network.ts:78`）。清洗规则见[执行边界](https://daiw.net/manual/zcode/exec-boundary)。

SDK 2.0 区分 modern 与 legacy 两个协议时代，由 `protocolVersion` 决定协商方式：`2026-07-28` 钉死 modern，`legacy` 或 sse 传输走 legacy，其余一律 `auto`（`apps/zcode-cli/packages/adapters/src/mcp/index.ts:1773`）。`auto` 先发一次探测，预算是连接超时的一半、封顶 5 秒；钉死版本时没有回退，探测就是唯一的握手，所以用满整个连接超时（`adapters/src/mcp/index.ts:1815`）。协商失败单独归为 `protocol_negotiation_failed`，不会再被误报成“网络不可达”（`adapters/src/mcp/index.ts:1232`）。各项超时：

| 项 | 数值 | 出处 |
| --- | --- | --- |
| 连接握手、列工具 | 各 30000 毫秒，可被 `timeoutMs` 覆盖 | `adapters/src/mcp/index.ts:93`、`adapters/src/mcp/index.ts:1057`、`adapters/src/mcp/index.ts:1067` |
| 工具调用 | 30000 毫秒，收到进度通知时重新计时 | `apps/zcode-cli/packages/core/src/mcp/index.ts:37`、`apps/zcode-cli/packages/adapters/src/mcp/index.ts:703` |
| 存活探测 ping | 5000 毫秒与 `timeoutMs` 取小 | `adapters/src/mcp/index.ts:95`、`adapters/src/mcp/index.ts:401` |
| 会话启动时等 OAuth | 15000 毫秒 | `apps/zcode-cli/packages/core/src/runtime/methods/mcp.ts:11` |
| OAuth 授权事务 | 300000 毫秒 | `apps/zcode-cli/packages/adapters/src/mcp/oauth-interactive.ts:38` |
| 连接池空闲回收 | 30000 毫秒 | `apps/zcode-cli/packages/adapters/src/mcp/pool.ts:16` |

## 连接生命周期

```mermaid
stateDiagram-v2
  [*] --> disabled: enabled 为 false
  [*] --> connecting: connectServer
  connecting --> connected: 握手与列工具都成功
  connecting --> failed: 超时、进程起不来、协商失败
  connecting --> connecting: 等待 OAuth 授权
  connected --> disconnected: 进程退出、ping 不通、手动断开
  disconnected --> connecting: 下一次 callTool 自动重连
  failed --> connecting: /mcp connect
```

**何时连接**。`AgentRuntime` 构造的最后一步就调用 `startMcpStartup`（`apps/zcode-cli/packages/core/src/runtime/agent-runtime.ts:307`），在后台对整张服务器表发起连接，并把等待 OAuth 的时间压到 15 秒；注释说以前没人授权时会等满 5 分钟，模型请求迟迟发不出去（`apps/zcode-cli/packages/core/src/runtime/methods/mcp.ts:73`）。回合循环在组装工具表之前等它完成并注册工具（`apps/zcode-cli/packages/core/src/runtime/methods/turn-loop.ts:106`），这就是 README 所说的“在第一次模型请求前注册”（`apps/zcode-cli/README.md:180`）；手动压缩与插件引用提醒也会先等它（`apps/zcode-cli/packages/core/src/runtime/methods/compact-active.ts:253`）。注册只做一次，`initializeMcp` 靠 `mcpToolsRegistered` 标记保证幂等（`methods/mcp.ts:126`）。连接开始得比多数人以为的更早：TUI 主界面挂载后（不需要登录时）立即开始轮询 MCP 状态（`apps/zcode-cli/packages/tui/src/app-view.tsx:110`），轮询经 `getApp()` 把会话建出来（`apps/zcode-cli/packages/cli/src/tui-prompt-handler-queries.ts:61`），所以打开 TUI、还没输入第一句话，配置里的 stdio 命令就已经跑起来了。

**逐台连接**。`connectConfiguredServers` 是“替换”语义：先断开不在新表里的服务器，再并行连接其余各台（`adapters/src/mcp/index.ts:253`）。单台的 `openServerConnection` 依次建传输、构造 `Client`、带超时 `connect`、带超时列工具，把工具描述规范化后存进记录，最后挂上 `onclose`（`adapters/src/mcp/index.ts:1002`）。

**失败**。任何一步出错都进 `failConnection`：关掉客户端与传输，记录置为 `failed`、清空工具，并附一个 `failureKind`（`adapters/src/mcp/index.ts:1327`）。分类共 19 种（`packages/shared/src/zcode-protocol/index.ts:663`），常见的是 `process_start_failed`、`network_unreachable`、`connection_timeout`、`protocol_negotiation_failed`、`tool_list_failed`、`oauth_authorization_failed`。整次启动的异常也被吞掉、退化成空快照（`apps/zcode-cli/packages/core/src/runtime/methods/mcp.ts:100`），MCP 出问题不会挡住回合，只是少了工具。

**断连与重连**。`onclose` 把意外断连记成 `disconnected`，分类 `unexpected_disconnect`，并把 stdio 子进程的退出码与 stderr 尾部写进日志（`apps/zcode-cli/packages/adapters/src/mcp/index.ts:1119`）。恢复发生在下一次调用（`adapters/src/mcp/index.ts:455`）：

```ts
    // stdio MCP 子进程死亡后（如 node_repl 被异步错误击穿），此前没有任何恢复路径：
    // 连接只在 session 创建时建立一次，session resume 也不重建，该会话的工具从此永远失败。
    // 这里在调用前对已断连的 record 重连一次；server 进程内状态（如 REPL 变量）不可恢复，
    // 但工具本身恢复可用。
    const disconnected = this.records.get(request.serverName);
    if (disconnected && disconnected.status.status === "disconnected") {
      await waitWithinMcpDeadline(
        this.reconnectForCall(request.serverName, disconnected.config),
        deadline,
        timeoutMessage,
        options.signal,
      );
    }
```

SDK 可能在 `onclose` 派发之前先抛出裸的 `Not connected`，对这一种错误也重连后重试一次（`adapters/src/mcp/index.ts:497`）。HTTP/SSE 服务停掉时没有常驻流可断，状态会一直停在 `connected`，所以桌面端设置页刷新时带上 `revalidate`，先 ping 一下再决定是否重连（`apps/zcode-cli/packages/adapters/src/mcp/pool.ts:111`）。

**关闭**。主动关闭先摘掉 `onclose`，再按进程树回收 stdio 服务器，最后才调 SDK 的 `close`，因为 SDK 只保证直接子进程退出，`npx` 之类包装器拉起的后代会残留（`adapters/src/mcp/index.ts:1625`）。POSIX 上依次发 SIGINT、等 250 毫秒、SIGTERM、等 750 毫秒、SIGKILL（`apps/zcode-cli/packages/adapters/src/mcp/process-tree.ts:196`）；Windows 上启动时把子进程放进一个“句柄关闭即终止”的 Job Object（`apps/zcode-cli/packages/adapters/src/mcp/windows-job-object.ts:4`），回收时再用 `taskkill /T /F` 兜底，超时 2 秒（`process-tree.ts:173`）。独立 CLI 的会话自己拥有适配器，关会话时它与执行端口、浏览器会话并行关闭，各给 6 秒（`apps/zcode-cli/packages/bootstrap/src/app/session-facade.ts:301`、`session-facade.ts:82`）。

**桌面端的连接池**。app-server 进程里是一个连接池，每个会话领一份租约（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol-entrypoint.ts:271`）。连接默认按会话隔离，只有声明了 `isolation: "workspace"` 的服务器才在同一工作区的多个会话间共享（`pool.ts:447`），租约全部释放后再等 30 秒才真正关闭。`isolation` 只由宿主生成，用户配置的 schema 不接受它。桌面端还用 `McpTelemetryTracker` 跟踪每个 stdio 子进程的启动、崩溃与资源占用（`apps/zcode-cli/packages/adapters/src/mcp/telemetry.ts:41`），相关上报见[遥测、调试与提示词轨迹](https://daiw.net/manual/zcode/telemetry-debug)。

**两个命令的边界**。`/mcp connect` 只在端口层重连（`session-facade.ts:361`），工具注册却只在会话里做一次（`apps/zcode-cli/packages/core/src/runtime/methods/mcp.ts:138`）：从代码看，启动时就失败的服务器事后连上，它的工具不会补进当前会话，要换新会话才出现。`/mcp disconnect` 也不是封禁，记录变成 `disconnected` 之后，模型下一次调用它的工具就会触发上面的自动重连。

## 工具命名与模型契约

模型看到的名字在适配器里生成（`apps/zcode-cli/packages/adapters/src/mcp/descriptor.ts:14`）：

```ts
  const toolName = typeof record.name === "string" ? record.name : "unknown";
  return {
    serverName,
    toolName,
    name: `mcp__${sanitizeMcpName(serverName)}__${sanitizeMcpName(toolName)}`,
  // ...
function sanitizeMcpName(name: string): string {
  const sanitized = name.replace(/[^a-zA-Z0-9_-]/g, "_").replace(/_+/g, "_");
  return sanitized.length > 0 ? sanitized : "unknown";
}
```

规则就两条：`[a-zA-Z0-9_-]` 以外的字符换成下划线，连续下划线压成一个；清洗后为空用 `unknown`。没有长度上限，也没有撞名检测，core 里另有一份同样的实现（`apps/zcode-cli/packages/core/src/mcp/name.ts:14`）。插件服务器键里的冒号同样被替换，`plugin:foo:bar` 的工具于是叫 `mcp__plugin_foo_bar__<tool>`。两个工具清洗后同名时，后注册的覆盖先注册的，只留一条 `console.warn`（`apps/zcode-cli/packages/core/src/tool/registry.ts:46`）。对照 [MiniMax Code 的 MCP](https://daiw.net/manual/minimax-code/mcp)：那边限长 80、撞名加后缀，并用持久名册防止新来源继承旧名字。

`createMcpToolEntry` 再把描述投影成工具条目（`apps/zcode-cli/packages/core/src/mcp/index.ts:102`）：

| MCP 描述 | ZCode 工具条目 |
| --- | --- |
| `description` | 原样放进 `metadata.description`，随工具契约交给模型 |
| `inputSchema` | 强制 `type: "object"`；缺失时给空 `properties` 并允许任意附加字段（`descriptor.ts:30`） |
| `outputSchema` | 读入但不用，条目统一挂 `McpToolOutputJsonSchema`：`content`、`structuredContent`、`isError`、`_meta` |
| `readOnlyHint`、`destructiveHint` | 决定 `readOnly`、`destructive`；风险等级只读为 low、破坏性为 high、其余 medium（`apps/zcode-cli/packages/core/src/mcp/index.ts:116`） |
| `idempotentHint` | 与只读任一成立即 `concurrentSafe`，可与其他工具并发 |
| `openWorldHint` | 读入，未被使用 |
| 服务器 `node_repl` 的 `js` | 例外：副作用范围记为 `system`、风险 high，其余 MCP 一律记为 `network`（`apps/zcode-cli/packages/core/src/mcp/index.ts:115`） |

不论注解怎么写，`needsApproval` 恒为真（`apps/zcode-cli/packages/core/src/mcp/index.ts:123`）。超时不许调用方覆盖；结果预算是给模型 50000 字节、内联 100000 字节，超出从头截断（`apps/zcode-cli/packages/core/src/mcp/index.ts:144`）。权限键是 `mcp`，规则可以按工具名、输入与网络目标匹配（`apps/zcode-cli/packages/core/src/mcp/index.ts:189`）。于是 build 与 edit 模式下 MCP 调用总要询问（有允许规则时除外）；Plan 模式反倒例外，非破坏性的 MCP 工具直接放行（`apps/zcode-cli/packages/core/src/permission/service.ts:417`），细节见[权限模式与规则](https://daiw.net/manual/zcode/permission)。会话级禁用名单（如 CLI 的 `--disallowed-tools`）在注册时就把命中的 MCP 工具滤掉（`apps/zcode-cli/packages/core/src/mcp/index.ts:78`）。

## 结果与图片

`formatMcpToolResult` 把结果转成模型内容（`apps/zcode-cli/packages/core/src/mcp/index.ts:369`）：文本块原样保留；图片块变成带 data URL 的图片；音频不交给模型，只留一行带 MIME 类型的“已省略”说明；内嵌资源被整段序列化成文本；非空的 `structuredContent` 另起一块附在后面；`isError` 为真时加前缀 `MCP tool returned an error:`，除非结果在 `_meta` 里声明了 `message-only`。

图片还要过一道归一（`apps/zcode-cli/packages/core/src/mcp/image-normalization.ts:27`）。handler 必须在这一步处理，因为结果预算只看得到图片的占位文本，大图原样进请求会把 provider 的请求体撑爆（`apps/zcode-cli/packages/core/src/mcp/index.ts:245`）。内联上限是 base64 后 200 KiB（`image-normalization.ts:21`），超限时：

- 普通 MCP：原图存成会话级工件，模型看到的是一段说明加工件路径与 URI（`image-normalization.ts:170`）；没配工件存储就只留“已省略”说明。
- 宿主 `node_repl` 的 `js`：先用统一的图片端口压到 200 KiB 以内、长边不超过 2000 像素（`image-normalization.ts:133`、`image-normalization.ts:25`），压不下来再走上面的路；模型显式截的浏览器截图另存一份原图，并在图片之后补一行 `Browser screenshot saved to:` 加绝对路径，保持图片在前的顺序（`image-normalization.ts:80`）。

官方 Computer Use 的“帧”另有一条精确保真的路径，但开源版的判定函数恒为假（`packages/zcode-cua/frame-contract.js:7`），这条分支不会生效。

## OAuth

http 与 sse 服务器支持两种 OAuth。`client_credentials` 直接交给 SDK 的 `ClientCredentialsProvider`（`adapters/src/mcp/index.ts:1547`）。`authorization_code` 则是隐式默认：只要没写 `oauth`、请求头里也没有 `Authorization`，就按授权码流程准备，由服务端的 401 与发现机制触发（`adapters/src/mcp/index.ts:1851`）。它拆成两段：

| 阶段 | 做什么 | 出处 |
| --- | --- | --- |
| 被动 | 纯 `AuthProvider`：`token()` 读凭据，临近过期 30 秒内主动刷新；401 时 `onUnauthorized()` 刷新，不做发现、不注册客户端、不开回调端口 | `apps/zcode-cli/packages/adapters/src/mcp/oauth-provider.ts:34`、`apps/zcode-cli/packages/adapters/src/mcp/oauth-credentials.ts:177` |
| 刷新 | 跨进程文件锁单飞，最多等 45 秒，避免多个进程拿同一个 refresh token 撞上轮换检测 | `apps/zcode-cli/packages/adapters/src/mcp/oauth-refresh.ts:35`、`oauth-refresh.ts:69` |
| 交互 | 抢授权租约；领头者 `listen(0)` 起本机回调，没配静态 `clientId` 时每次重新动态注册客户端，用 SDK 的 `auth()` 走完发现、授权、换码；跟随者每 500 毫秒轮询结果 | `oauth-interactive.ts:77`、`oauth-interactive.ts:121` |
| 扩权 | 403 带 `insufficient_scope` 时，scope 取配置、现有 token 与 challenge 三者并集，强制重新授权 | `adapters/src/mcp/index.ts:1274` |
| 存储 | `~/.zcode/v2/credentials.json`，键前缀 `mcp:oauth:` 加服务器名、地址与授权参数的哈希，CLI 与桌面端共用 | `apps/zcode-cli/packages/adapters/src/auth/shared-credentials.ts:289`、`apps/zcode-cli/packages/adapters/src/mcp/oauth.ts:16` |

回调路径缺省为 `/oauth/callback/mcp/<server>`（`oauth-interactive.ts:459`）。领头者只发布授权地址，不自动打开浏览器，注释说那会打断当前操作（`oauth-interactive.ts:405`）。地址写进服务器状态的 `authorization.authorizationUrl`，由桌面端设置页展示；CLI 的 `/mcp` 输出与 TUI 侧栏都不显示它（`apps/zcode-cli/packages/cli/src/command-center/handlers/mcp.ts:97`、`app-sidebar-mcp.tsx:104`），所以从代码看，纯 CLI 下需要浏览器授权的服务器没有现成的授权入口。

## 官方 MCP

“官方 MCP”指由 ZCode 自家后端提供、按用户的 ZCode 登录与 Coding Plan 套餐鉴权的 MCP 服务。只有插件的 MCP 配置（`.mcp.json` 或清单里的 `mcpServers`）能声明它；用户配置的 schema 是严格模式，不认这个字段，写了整台服务器都会因校验失败被跳过（`apps/zcode-cli/packages/adapters/src/config/schema.ts:94`）。插件里的声明方式是：`auth` 写成 `{ "type": "zcode_official", "provider": "jwt_token" }`，传输只能是 http 或 stdio，不能与 `oauth` 同时出现，静态请求头里也不许带身份头（`apps/zcode-cli/packages/adapters/src/plugins/mcp.ts:190`、`plugins/mcp.ts:267`）。服务器归属（插件 id、原始键）由加载器生成，清单里写了也会被覆盖（`apps/zcode-cli/packages/adapters/src/plugins/mcp-official-auth.ts:39`）。身份头是这几个（`packages/shared/src/official-mcp-auth.ts:18`）：

```ts
export const OFFICIAL_MCP_AUTH_HEADER_NAMES = {
  authorization: "Authorization",
  codingPlanAuthorization: "X-Bigmodel-Authorization",
  targetType: "Bigmodel-Target-Type",
  organization: "Bigmodel-Organization",
  project: "Bigmodel-Project",
} as const;
```

`Authorization` 携带 ZCode 登录 JWT，`X-Bigmodel-Authorization` 携带 MaaS 登录 JWT，套餐类型是 `PERSONAL` 或 `TEAM`，团队套餐再成对附上组织与项目（`packages/services/src/official-mcp/officialMcpCredentials.ts:397`）。信任判定只有一条：目标 origin 必须逐字等于当前 ZCode API 的 https origin 且不带用户名密码，另有环境变量 `ZCODE_OFFICIAL_MCP_DEV_TRUSTED_ORIGINS` 只放开本机 http 回环地址供自测（`official-mcp-auth.ts:190`、`official-mcp-auth.ts:138`）。两种传输的投递通道不同：

- **http**：适配器包一层 fetch，逐请求校验 origin、覆盖写入身份头，禁止跟随重定向；带了凭据的请求遇到 401 只重试一次，401、403 与 3xx 各自归类报错（`apps/zcode-cli/packages/adapters/src/mcp/official-auth.ts:95`、`official-auth.ts:297`）。
- **stdio**：请求由插件进程自己发出，身份头随每条出站协议消息放进 `_meta` 的 `com.zcode/official-mcp-auth` 键（`apps/zcode-cli/packages/adapters/src/mcp/stdio-transport.ts:81`、`adapters/src/mcp/index.ts:1428`），拿不到时也下发 `ok: false` 与原因。

Agent 进程不是身份的权威：身份头经反向请求 `interaction/requestOfficialMcpAuthHeaders` 向宿主索取，凭据不落运行时配置（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol/official-mcp-auth-port.ts:1`）。独立 CLI 没有这个端口，也没有可信 origin 表，http 形态的官方 MCP 因此在第一个请求就以 `official_auth_unavailable` 失败（`adapters/src/mcp/index.ts:1487`），stdio 形态收到的则是同名原因（`adapters/src/mcp/index.ts:602`）。只有 http 形态的官方工具被标记为 `official`，界面才会信任结果里的 `quota_exceeded`、`coding_plan_required` 并弹出相应提示（`adapters/src/mcp/index.ts:1079`、`packages/shared/src/official-mcp-tool-error.ts:20`）；stdio 结果由插件自己产出，可以伪造，故不标记。开源仓库里没有带这种声明的插件实体，官方插件定义表中的 image-search 注明“认证仍由官方 MCP adapter 注入”（`apps/zcode-cli/packages/bootstrap/src/app/official-plugin-definitions.ts:186`）。账号与套餐见[账号、Coding Plan 与闲时计划](https://daiw.net/manual/zcode/accounts-plans)。

## 工作区级 MCP 的安全含义

根目录 NOTICE 的风险表里有这样一句（`NOTICE.md:19`）：

> 当前 Agent 运行配置将工作区级 MCP 纳入自动连接范围；启用 MCP 并启动运行时时，可能使用配置中的命令、环境变量、认证头或 OAuth 连接服务。

对应的代码就是上面那个恒为空的 `untrustedProjectMcpServers`（`apps/zcode-cli/packages/bootstrap/src/app/runtime-config.ts:110`）。把几处事实连起来看：

- 一个仓库只要在 `zcode.json` 或 `.zcode/config.json` 里写了 `mcp.servers`，打开会话（TUI 一挂载就算）时其中的 stdio `command` 就会在工作区里以当前用户身份启动，没有任何确认。
- 工具审批管的是 `tools/call`；连接、协商、列工具和 OAuth 刷新都不经过它（`NOTICE.md:42`）。工作区 Hook 那套按摘要信任的机制也不覆盖 MCP 配置（`NOTICE.md:18`），见[生命周期 Hooks 与工作区信任](https://daiw.net/manual/zcode/hooks)。
- 旧设计的痕迹还在：状态枚举里有 `untrusted`（`mcp.port.ts:110`），状态投影会给它配上“Project MCP server requires explicit connection before use.”的说明（`apps/zcode-cli/packages/bootstrap/src/mcp-config.ts:211`），连接池刷新时也会跳过它（`pool.ts:130`），只是名单为空，这些分支走不到。
- 能用的开关：`features.mcp: false` 全关；由于同名时用户压过项目，在用户配置里写一个同名且 `enabled: false` 的条目，从代码看就能压住项目里的那一台。

子 Agent 不自己连 MCP，只借用父会话启动快照里已连上的服务器，能调工具，改连接的操作一律被拒（`apps/zcode-cli/packages/core/src/subagent/borrowed-mcp-port.ts:34`）；profile 声明必需、父会话却没连上的服务器会直接报错（`apps/zcode-cli/packages/core/src/runtime/methods/subagent.ts:596`），“必需”按模型可见名做不分大小写的包含匹配（`apps/zcode-cli/packages/core/src/subagent/mcp-config.ts:4`、`apps/zcode-cli/packages/core/src/mcp/name.ts:19`）。细节见[子 Agent](https://daiw.net/manual/zcode/subagents)。

下一篇：[node_repl、Browser Use 与 Computer Use](https://daiw.net/manual/zcode/node-repl-browser)——浏览器控制为什么只给模型一个 `js` 工具，宿主进程、bridge 与 Playwright 怎样串起来，以及开源版里 Computer Use 的占位实现。
