# MCP 客户端 · 连上外部工具服务器

> MCP（Model Context Protocol）让 grok 接入外部工具服务器。这一章看 xai-grok-mcp 的三种传输、单飞握手状态机、韧性传输、远程 MCP 的 OAuth 流程，以及新版协议的协商。

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

# MCP 客户端 · 连上外部工具服务器

[MCP](https://daiw.org/manual/llm-to-agent/mcp)(Model Context Protocol)是让 agent 接入**外部工具服务器**的标准协议——数据库、Jira、Figma 等等。grok 的 MCP 客户端在 `xai-grok-mcp`，基于 `rmcp 3.2`。

<Callout type="info">
  一个工程细节：rmcp 3.x 要求 reqwest ≥ 0.13.2，而其余 workspace 还在用 reqwest 0.12。`xai-grok-mcp` 于是把 rmcp + reqwest 0.13 **私有隔离**在自己 crate 里，再 `pub use rmcp` 让全仓库只从这一个命名空间取 MCP 模型类型。**用一个 crate 隔离有版本冲突的依赖**是 Rust 大仓库常见的解耦手法。
</Callout>

## 三种传输

`PendingTransport` 有四个变体(`Stdio` / `Http` / `HttpAuth` / `Acp`)，归结为三种连法：

- **Stdio**：spawn 一个子进程，按行分隔的 JSON-RPC 通信(最常见，`npx some-mcp-server`)。
- **HTTP**：`StreamableHttpClientTransport`，连远程 HTTP MCP 服务器，需要 OAuth 的走 `HttpAuth`。配置里写 `sse` 只是个标签，同样走 streamable HTTP。
- **ACP 反向**：连**进程内**的 SDK MCP 服务器，走 ACP 反向通道(`x.ai/mcp/sdk_call`)。

Stdio 有个韧性设计：`ResilientRwTransport` **能扛住一行解不了的 stdout**(跳过 + 报个事件)，而不是像 rmcp 原版那样整条传输崩掉——因为真实的 MCP 服务器常往 stdout 打非协议的杂日志。子进程 Drop 时还会杀掉**整个进程组**(`killpg`)，不留孤儿。

## 状态机 + 单飞握手

`McpClient` 的状态有四个：`Empty`、`Pending`、`Initializing`、`Ready`。构造时就是 `Pending`(传输已配好、等握手)，握手成功进 `Ready`；失败时能重建的传输(Http / HttpAuth / Acp)退回 `Pending` 等下次重试，Stdio 的子进程已被消耗，只能进终态 `Empty`。握手用一个**单飞(single-flight)**模式，值得学：

```mermaid
flowchart LR
  E[ensure_initialized] --> R{当前状态?}
  R -->|Ready| DONE[直接返回 service]
  R -->|Initializing| PARK["park 在 Notify 上等别人握完"]
  R -->|Pending| HOLD["mem::replace 成 Initializing<br/>我来握手"]
  R -->|Empty| ERR["报错：没有可用传输"]
  HOLD --> HS[try_handshake 在锁外跑]
  HS --> COMMIT["Ready / 失败回退 Pending<br/>(Stdio 进 Empty)"]
  COMMIT --> WAKE[notify_waiters]
```

关键：用 `std::mem::replace(state, Initializing)` **拿走**旧状态，谁拿到 `Pending` 谁就成为握手的**唯一执行者**，其余并发调用都 park 在 `Notify` 上等(先订阅通知再看状态，防止漏掉唤醒)。配一个 RAII `InitGuard`——握手被取消/panic 时，在 `Drop` 里 `try_lock` 把能重建的传输**回退**成 `Pending` 并唤醒等待者。它是尽力而为：Stdio 没有可恢复的传输，`try_lock` 抢不到锁时状态也会留在 `Initializing`，这时靠等待者的超时兜底(报「init still in progress」)，而不是永远卡住。这是「并发初始化只做一次、还要 cancel-safe」的教科书写法。

## 远程 MCP 的 OAuth

连需要登录的远程 MCP，走 OAuth(rmcp 的 `AuthorizationManager`：元数据发现、动态注册、PKCE 都靠 rmcp 实现，grok 给发现套了 5 秒超时，也支持在配置里自带 `oauth_client_id`)。交互流程：起一个 loopback 回调服务器(axum)、开浏览器，然后 `select!` 三路等待——回调、每 2 秒轮询一次凭据文件(别的进程/tab 可能已登)、600 秒超时报错。去重做了**两层**：跨进程用文件锁(`flock`，仅 Unix；拿到锁先复查凭据，别人已登就直接复用)，进程内用 `IN_FLIGHT_AUTH` map——避免同时弹好几个浏览器窗口。token 存 `~/.grok/mcp_credentials.json`(原子写、`0o600`)，每次读改写都在跨进程锁下进行，并带新鲜度保护，防止慢的写者把刚轮换的 refresh token 写回旧值。

会话启动时的鉴权判定**只看磁盘、不探网络**(`McpOauthDiscovery::Disk`)，交互与否行为一致：存有 token 就走 OAuth 传输；有凭据条目却没 token(登录没做完)就直接报 `AuthRequired`——源码注释说得明白，免得起一个会在 OAuth 质询上崩掉的 HTTP worker；什么都没有就按普通 HTTP 连，握手被 401 拒了再记为「待认证」。只有刷新、诊断、用户主动认证这类显式操作才做网络探测；非交互下探测结论不确定时，会先发一个匿名请求：被拒判为需要登录，连不上则报可重试的 `Unreachable`。**fail-closed 只落在「确定需要登录」上**，网络抖动不会让会话永久丢掉这个服务器——又一个「按威胁模型选 fail-open 还是 fail-closed」的决策。

## 新版协议与 elicitation

HTTP 和 ACP 传输握手前，先用 `server/discover` 探测 2026-07-28 版协议(SEP-2575)，失败或超时再回退到旧的 `initialize`(固定报 2025-11-25)；Stdio 只走旧握手——探测一个慢启动的子进程并不安全。新版里一次 `tools/call` 可以多轮往返(SEP-2322)：服务器中途要用户补信息(填表单或打开一个 URL)，grok 经 `x.ai/mcp/elicit` 交给界面处理。MCP 的 resources / prompts 目前仍未接入。

## 和《从 LLM 到 Coding Agent》对照

对照 [mcp](https://daiw.org/manual/llm-to-agent/mcp)：最小实现里接 MCP 大概是「连个 stdio 服务器、列工具、调用」。grok 的增量在真实世界的粗粝：三种传输、脏 stdout 韧性、cancel-safe 单飞握手、多层去重的 OAuth、进程组清理，以及新旧协议的协商。下一章看连上之后，那些远程工具怎么进[工具注册表](https://daiw.org/manual/grok-build/tool-registry)。

---

上一篇：[Skills](https://daiw.org/manual/grok-build/skills) · 下一篇：[MCP 工具进注册表](https://daiw.org/manual/grok-build/mcp-registry)
