# MCP 客户端 · 传输与 OAuth 登录

> 第 24 篇讲了 MCP 的连接管理与工具目录，这一篇往下一层看 codex-rmcp-client：配置怎样落成 stdio 或可流式 HTTP 两种传输、本地服务器进程怎么起、HTTP 会话过期怎么恢复，以及 codex mcp login 背后的 OAuth 流程、令牌存储和跨进程刷新。对外的 codex mcp-server 在 v0.158.0 里已不存在，代码中只剩 TUI 内部的一个任务工具服务。

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

# MCP 客户端 · 传输与 OAuth 登录

[MCP 工具调用](https://daiw.net/manual/codex-source/mcp-tool-calls)一篇讲了 `codex-mcp` 怎样管理连接、汇总工具目录。那一层之下，每个服务器对应一个 `RmcpClient`，实现在 `codex-rmcp-client`（`codex-rs/rmcp-client/`）。它构建在官方 Rust SDK `rmcp`（workspace 锁定 3.2.0）之上，自己负责三件 SDK 不管的事：进程放在哪里起、HTTP 请求走哪个客户端、OAuth 凭据怎么存和刷新。

## 怎么用

`config.toml` 里每个 `[mcp_servers.名字]` 写 `command` 就是本地 stdio 服务器，写 `url` 就是可流式 HTTP 服务器；`codex mcp add`、`list`、`login`、`logout` 管理它们，`codex mcp login 名字 --no-browser` 可以在没有本地浏览器的机器上完成 OAuth。字段与命令见手册 [MCP 集成](https://daiw.net/manual/codex/mcp)。

## 从配置到传输

两种传输由出现的字段决定，混写会在加载配置时直接报错：

```rust
        let transport = if let Some(command) = command {
            throw_if_set("stdio", "url", url.as_ref())?;
            // ...
            throw_if_set("stdio", "oauth", oauth.as_ref())?;
            // ...
        } else if let Some(url) = url {
            throw_if_set("streamable_http", "args", args.as_ref())?;
            // ...
            throw_if_set("streamable_http", "bearer_token", bearer_token.as_ref())?;
            // ...
        } else {
            return Err("invalid transport".to_string());
        };
```

（`codex-rs/config/src/mcp_types.rs:499`）

两种传输都不接受明文的 `bearer_token`，令牌只能经 `bearer_token_env_var` 从环境变量读取（`codex-rs/core/src/config/mod.rs:2312`）。`RmcpClient` 内部分两部分：一份 `TransportRecipe` 记住“怎样重新建一条传输”，一个 `Connecting`、`Ready`、`Closed` 三态的状态机持有正在运行的 rmcp 服务（`codex-rs/rmcp-client/src/rmcp_client.rs:153`）。配方单独保存，是为了重试、会话恢复时能原样重建连接。

```mermaid
flowchart LR
  C[mcp_servers 配置] --> R[RmcpClient<br/>TransportRecipe + 状态机]
  R -->|command| L{StdioServerLauncher}
  L --> L1[本机子进程]
  L --> L2[执行环境里的进程<br/>经 exec-server]
  R -->|url| H[StreamableHttpClientAdapter]
  H --> H1[本机 HTTP 客户端]
  H --> H2[远程运行时转发]
  R --> S[rmcp 客户端服务<br/>initialize · tools · resources]
```

## stdio：进程在哪里起

`StdioServerLauncher` 把“服务器进程跑在哪”从 MCP 生命周期里拆出去：`LocalStdioServerLauncher` 在本机起子进程，`ExecutorStdioServerLauncher` 通过执行环境的进程接口在远端起，两者都返回同一种字节流传输（`codex-rs/rmcp-client/src/stdio_server_launcher.rs:79`）。本机进程的环境变量不是整份继承，而是一张小白名单：Unix 上是 `HOME`、`LOGNAME`、`PATH`、`SHELL`、`USER`、`LANG`、`LC_ALL`、`TERM`、`TMPDIR`、`TZ` 等，再加上 `env_vars` 点名转发的变量、自定义 CA 证书变量和 `env` 里写死的值（`codex-rs/rmcp-client/src/utils.rs:163`）。进程本身这样创建：

```rust
        let build_command = || {
            let mut command = Command::new(&resolved_program);
            command.current_dir(&cwd).envs(&envs).args(&args);
            command.process_mode(ProcessMode::NewGroup);
            // MCP uses only stdio; select Explicit to exclude unrelated
            // orchestrator descriptors from the server and commands it launches.
            // Descriptor allowlisting is Unix-only. Windows can still inherit unrelated
            // handles and needs a handle allowlist in the shared spawn backend.
            #[cfg(unix)]
            command.descriptor_policy(DescriptorPolicy::Explicit);
            #[cfg(windows)]
            command.creation_flags(CREATE_NO_WINDOW);
            command
        };
```

（`codex-rs/rmcp-client/src/stdio_server_launcher.rs:286`）

独立进程组便于关闭时先温和终止、再强杀整组；Windows 上还尽量把进程放进 Job Object，并先用 `which` 解析出带扩展名的完整路径，否则 `npx` 这类 `.cmd` 脚本起不来。服务器的 stderr 逐行转进 Codex 日志。

## 可流式 HTTP：认证、会话与超时

HTTP 请求经 `StreamableHttpClientAdapter` 交给 Codex 统一的 `HttpClient`，它可能是本机客户端，也可能把请求转发给远程运行时。认证按顺序取用：`bearer_token_env_var` 或配置头里的 `Authorization`；宿主注入的认证方，即内置的 Codex Apps 服务器或配置了 `auth = "chatgpt"` 的官方源服务器，用当前 ChatGPT 会话；本地保存的 OAuth 令牌；都没有就不带认证连接。服务器不再提供 OAuth 元数据时，已存的 access token 还会被当作普通 bearer token 继续用。

按 MCP 规范，可流式 HTTP 服务器用 404 表示会话已失效。`RmcpClient` 遇到这种错误会用保存的配方和初始化参数重新握手，然后把失败的操作再执行一次：

```rust
            Err(error) if Self::is_session_expired_404(&error) => {
                self.reinitialize_after_session_expiry(&service).await?;
                let recovered_service = self.service().await?;
                Self::run_service_operation_with_transient_retries(
```

（`codex-rs/rmcp-client/src/rmcp_client.rs:1397`）

恢复过程由一个信号量串行化，多个并发请求同时撞上 404 只重连一次。操作超时只计“活跃时间”：服务器发来 `elicitation/create` 向用户要输入时，计时暂停，等用户答完再继续（`codex-rs/rmcp-client/src/rmcp_client.rs:237`）。协议版本默认按 2025-06-18 握手，开发中的功能开关 `mcp_2026_07_28` 打开后，优先协商 2026-07-28、不支持时回退（`codex-rs/rmcp-client/src/protocol_mode.rs:9`）。

## OAuth 登录

`codex mcp login` 由 `codex-rs/cli/src/mcp_cmd.rs` 解析参数，再交给 `codex-rmcp-client` 的 `OauthLoginFlow`：

1. **scope**：命令行 `--scopes` 优先，其次是配置的 `scopes`，最后用服务器声明的 `scopes_supported`；只有自动发现的 scope 被服务商拒绝时，才去掉 scope 重试一次（`codex-rs/codex-mcp/src/mcp/auth.rs:158`）。
2. **客户端注册**：配置了 `oauth.client_id` 就直接用；否则默认 `auto`——服务器声明支持 CIMD 时用 ChatGPT 托管的 Codex 客户端元数据文档，否则走动态客户端注册（DCR）。
3. **回调**：在 `127.0.0.1` 上起一个临时 HTTP 服务，端口默认由系统分配，回调地址形如 `http://127.0.0.1:端口/callback`，必要时再附上区分服务器的回调 ID；配置的回调地址不是本机时改为监听 `0.0.0.0`。配置了 `oauth_resource` 时授权链接附带 `resource` 参数，整个流程默认等 300 秒（`codex-rs/rmcp-client/src/perform_oauth_login.rs:553`）。
4. **浏览器**：用 `webbrowser` 打开授权页，打不开就把链接打印出来让用户自己复制。

`--no-browser` 不开浏览器，也不关掉本地回调，而是让两条路赛跑：

```rust
    let callback = timeout(flow.timeout, async {
        tokio::select! {
            callback = &mut flow.rx => callback.context("OAuth callback was cancelled"),
            input = read_callback(authorization_url.clone()) => {
                parse_callback_url(&input?, &flow.redirect_uri, &authorization_url)
            }
        }
    })
```

（`codex-rs/rmcp-client/src/oauth_callback_input.rs:75`）

粘贴进来的地址先做检查：不超过 64 KiB，并且必须对得上这次的回调地址；issuer 与本次登录不符就拒绝，报错信息也不回显粘贴内容。`codex mcp add` 添加 HTTP 服务器后会先探测一次，发现支持 OAuth 就直接开始登录。

## 令牌存储与刷新

`mcp_oauth_credentials_store` 默认 `auto`：先读系统钥匙串（服务名 `Codex MCP Credentials`），没有再读 `$CODEX_HOME/.credentials.json`；一旦从某处读到，这个客户端整个生命周期都钉在那里，刷新时不会切换到可能过期的另一份。条目的键是服务器名加上 URL 的 SHA-256 前缀（`codex-rs/rmcp-client/src/oauth.rs:1016`）。钥匙串后端在 Windows 上默认是加密的本地 secrets 文件（密钥放在钥匙串里），其他平台直接写系统钥匙串。

每次 MCP 操作开始前、计时之外，客户端检查 access token：离过期不到 30 秒就先刷新（`codex-rs/rmcp-client/src/oauth.rs:95`）。刷新是一个跨进程的事务：

```rust
//! Cross-process serialization for one MCP OAuth credential's refresh transaction.
//!
//! The guard is intentionally acquired before the authoritative credential reread and retained
//! through provider refresh and persistence. This prevents two processes from replaying the same
//! rotating refresh token or observing a partially persisted transaction.
```

（`codex-rs/rmcp-client/src/oauth/refresh_lock.rs:1`）

锁文件在 `$CODEX_HOME/mcp-oauth-locks/`，拿到锁后重新读一遍存储，发现别的进程已经刷新过就直接采用；向服务商请求限时 45 秒，整个事务放在独立任务里执行，调用方取消也打断不了写回（`codex-rs/rmcp-client/src/oauth/refresh_transaction.rs:36`）。`/mcp` 与 `codex mcp list` 显示的认证状态按同样的先后判断：环境变量令牌、`Authorization` 头、已存令牌，都没有才花最多 5 秒去探测服务器是否支持 OAuth（`codex-rs/rmcp-client/src/auth_status.rs:148`）。

## Codex 自己还是 MCP 服务器吗

对外不是。v0.158.0 的 `Subcommand` 枚举里只有管理外部服务器的 `mcp`（`codex-rs/cli/src/main.rs:164`），没有 `mcp-server`；手册记载它在 v0.154.0 被移除，想用程序驱动 Codex，应改走 [app-server](https://daiw.net/manual/codex-source/app-server-architecture) 的 JSON-RPC 协议。`rmcp-client/src/bin/` 下的几个服务器只供测试。

代码里剩下的唯一一个 rmcp 服务端实现在 TUI 内部：TUI 连接外部 app-server 时，可以把自己托管的任务管理工具（命名空间 `codex_tui`，含 `list_threads`、`read_thread`、`create_thread`、`fork_thread` 等）放进一个只监听 `127.0.0.1`、端口随机、带一次性 bearer token 的可流式 HTTP MCP 服务，再以 `mcp_servers.codex_tui` 的配置注入新线程（`codex-rs/tui/src/dynamic_tools_mcp.rs:107`）。这是前端与服务端之间的内部通道，不是给第三方的入口。

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

那本书的 [MCP](https://daiw.net/manual/llm-to-agent/mcp) 一章把 MCP 当作“外部工具的标准协议”，重点在工具如何接入统一的工具抽象。真正落到生产里，大部分代码花在协议之外：进程放在哪、继承哪些环境变量和文件描述符、会话过期怎么恢复、OAuth 客户端怎么注册、轮换的 refresh token 怎样在多个进程之间不被重放。Codex 把这些都收进 `codex-rmcp-client`，上层的连接管理只需要面对一个 `RmcpClient`。

---

上一篇：[插件与 marketplace · 打包分发扩展](https://daiw.net/manual/codex-source/plugins) · 下一篇：[多 agent · 子代理的派生与协作](https://daiw.net/manual/codex-source/multi-agent)
