青雲的博客
拆开 Codex 第四部:外部能力怎样进入同一套治理 第 21 章

外部 MCP Server 的工具怎样进入 Codex

沿着 Codex 作为 MCP client 的真实调用链,从运行时投影、stdio 或 Streamable HTTP 连接、tools/list,到 ToolInfo、direct/deferred exposure、审批与 tools/call,划清发现、暴露、批准和执行各自的边界。

源码版本
rust-v0.144.6
验证日期
Commit
5d1fbf26c43abc65a203928b2e31561cb039e06d

第 20 章把 Skill 的正文停在了 prompt 边界:文件可以被读取并注入上下文,但不会因此变成一个可调用的 executor。MCP 接着回答另一个问题:一个外部 server 的 tools/list 返回值,怎样经过 Codex 自己的运行时,变成模型能够提出、系统愿意批准、最后真的发出去的一次 tools/call

先把角色摆正。这里的主角是 Codex 作为 MCP client。它启动或连接外部 MCP server,保存连接,读取工具目录,再把调用路由回同一个 server。codex mcp-server 是反向的 server 进程,放在文末的边界侧栏里单独看。

先给四个会反复出现的词定一个本章范围内的意思:

本章中的意思
RMCPCodex 使用的 Rust MCP SDK/client 实现层;协议仍然是 MCP,RMCP 不是另一套协议。
Codex Appshost-owned 的 codex_apps MCP server 注册及其工具集合,是否出现还要经过 app/auth 与 connector policy。
connector某个 app/account integration 的身份与可访问性来源;它提供关联信息,不等于已经建立的 MCP client。
Guardian工具审批路径里的模型审查者之一;它可以给出审查结果,但不拥有 MCP 连接,也不执行 tools/call

后文如果没有特别说明,RMCP 指实现层,MCP 指协议和 server 侧语义;Codex Apps、connector 与 Guardian 也都只出现在各自的 owner 边界内。

整条链可以先压成一张图:

flowchart LR
  accTitle: MCP 工具从配置到一次调用的所有权链
  accDescr: 配置、plugins 与 auth 经 McpManager 与 connection manager 固定成 runtime snapshot 和 step context,投影出工具 exposure 与 ToolRouter,再经审批面进入 connection manager 的 call_tool,最后把 CallToolResult 投影给模型与事件。
  CFG["Config + plugins + auth"] --> PROJ["McpManager\nMcpRuntimeProjection"]
  PROJ --> CM["McpConnectionManager"]
  CM --> SNAP["McpRuntimeSnapshot\nconfig + manager"]
  SNAP --> STEP["StepContext\nOnceCell tool snapshot"]
  CM --> CLIENT["RmcpClient"]
  CLIENT --> TRANS["stdio or Streamable HTTP"]
  TRANS --> INIT["initialize\ntools/list"]
  INIT --> INFO["ToolInfo\nraw route + model name"]
  INFO --> STEP
  STEP --> EXP["McpToolExposure\ndirect / deferred"]
  EXP --> ROUTER["ToolRouter\nmodel specs + ToolRegistry"]
  ROUTER --> CALL["McpHandler\nfunction call"]
  CALL --> APPROVE["policy / hook / Guardian\nor user elicitation"]
  APPROVE --> EXEC["McpConnectionManager.call_tool"]
  EXEC --> WIRE["rmcp tools/call"]
  WIRE --> RESULT["CallToolResult\nmodel + event projections"]

这张图里有四个不能合并的动词:discovery、exposure、approval、execution。它们可能在一次顺利调用里连续发生,但每一步都有自己的 owner、输入和失败结果。

阶段它回答的问题主要 owner成功的最低证据
discoveryserver 说自己有哪些工具?RmcpClientMcpConnectionManagerinitialize -> tools/list -> ToolInfo
exposure哪些工具进入这次模型请求?mcp_tool_exposureToolRouterdirect exposure -> ToolSpec; deferred exposure -> search metadata
approval这次具体调用能不能继续?app policy、permission hook、Guardian、用户 elicitationMcpToolApprovalDecision -> non-rejection
execution请求是否真的发到 server,结果是什么?McpHandlerMcpConnectionManagerRmcpClienttools/call -> CallToolResult

下面先补齐配置投影、连接和 transport 三层运行前提,再按这四个阶段展开。不能把 MCP 写成一个“连接成功后自动出现的工具列表”。

1. 配置先变成有效 server,才有 client 可以启动

McpManager 做的是运行时投影

根配置里的 mcp_servers 还不是连接对象。McpManager::runtime_config_for_step 收集 extension contributor 的 SetRemove、selected plugin registration,再把已加载插件和兼容性内置 server 合并进 McpConfig。这个函数返回的是 McpRuntimeProjection:一份配置和一个 plugins_available 标记,不是已经握手完成的 client。

接下来 effective_mcp_servers 才把 materialized config 变成启动视图。它会处理 ChatGpt / OAuth auth mode,并在当前 auth 不满足 host-owned Codex Apps 条件时移除 codex_apps。所以“配置文件里有一项”也不等于“这次 runtime 会启动它”。

McpRuntimeContext 解决 server 运行在哪个环境

McpConfig 描述“有哪些 server”;McpRuntimeContext 才携带共享的 environment registry 和 local stdio fallback cwd。local stdio 没有可用 local environment 会直接报错;local Streamable HTTP 是当前例外,可以退回 ambient HTTP client;未知的显式 environment id 也会在 transport 构造前失败。

这一步的 owner 是环境解析,不是 MCP 协议。它决定了 stdio 子进程由本机 launcher 还是 executor 放置,也决定 Streamable HTTP 用哪个 HttpClient 发请求;它还没有读取 server 的工具目录。

本节源码依据(5 处)

2. McpConnectionManager 持有连接,但不替模型做决定

一个 server 一个异步 managed client

McpConnectionManager 的字段很克制:按 server name 保存 AsyncManagedClient,另外保留 server metadata、required server 列表、tool plugin provenance、elicitation manager 和 startup cancellation token。模块注释明确把职责写成四件事:启动状态、跨 server 聚合 tools/resources/templates、把 tool call 路由到正确 client、向 codex-core 暴露 manager API。

创建 manager 时,server startup 是异步的。每个 server 先发 Starting 事件,再把 AsyncManagedClient::client() 放进 JoinSet;完成后发 ReadyFailedCancelled,最终汇总为 McpStartupComplete。required server 的调用方可以随后单独等待并把多个失败合并成一条错误。

manager 的 shutdown 也有明确 owner:取消 startup token,逐个调用 client shutdown;对 stdio 来说,这还会终止它拥有的 server process。这里仍然没有模型审批的语义。

本节源码依据(5 处)

3. RMCP client 怎样接上 stdio 和 Streamable HTTP

两种 transport,先进入同一个 PendingTransport

codex-mcpmake_rmcp_client 先解析 environment,再按 McpServerTransportConfig 分支:stdio 构造 StdioServerCommand 和 local 或 executor launcher;Streamable HTTP 选择 environment-owned 或默认 reqwest client,解析 bearer token,然后创建 HTTP client。两条分支最终都返回 RmcpClient

rmcp-client crate 里,transport recipe 被展开成四种内部状态:in-process、stdio、普通 Streamable HTTP、带 OAuth runtime 的 Streamable HTTP。ClientState 只有 ConnectingReadyClosed 三态;连接尚未初始化时,tools/listtools/call 都没有可用的 service。

stdio 的 process ownership 不在 RMCP service

StdioServerLauncher 是 process placement 的边界。trait 只负责启动配置命令并返回一个 rmcp-facing StdioServerTransportRmcpClient 负责 initializetools/list 和后续 MCP method。local launcher 清理环境、解析 program、创建 piped stdin/stdout,并持有 process handle;executor launcher 则通过 ExecBackend 启动远端进程,保持 tty=falsepipe_stdin=true,让 stdout 仍是干净的 JSON-RPC 流。

这解释了一个常见误读:MCP server 进程还活着,不代表 RmcpClient 仍在 Connecting;反过来,RMCP service 关闭也不应假设子进程已经被外部清理。两者通过 StdioServerProcessHandle 和 transport close 显式关联。

Streamable HTTP 还多了一层 auth/session 状态

RmcpClient::new_streamable_http_client 只先记录 recipe。真正创建 pending transport 时,代码按优先级处理:显式 bearer token 或 Authorization header 优先;没有它们才尝试读取存储的 OAuth token。能建立 OAuth runtime 时,代码创建带 OAuthPersistor 的 transport;metadata discovery 不可用时,则退回普通 bearer transport。initialize 成功进入 Ready 后会尝试持久化;带 OAuthPersistor 的握手失败也会在返回原错误前尝试写回 token。

HTTP adapter 把 MCP JSON-RPC message 作为 POST 发出,带 Accept: text/event-stream, application/jsonContent-Type: application/json 和可选 mcp-session-id401 只有同时带 WWW-Authenticate challenge 时才映射为 AuthRequired;没有 WWW-Authenticate401 会落入普通 non-success 分类。带 scope challenge 的 403 映射为 InsufficientScope,已有 session 的 404 映射为 session expired;成功响应再按 SSE 或 JSON 分支交给 RMCP。

OAuth discovery 本身也不是一次工具调用。没有显式 token 或 Authorization header 时,auth status 先看存储 token,再调用 OAuth metadata discovery;发现到 authorization server 只说明需要 login,不说明任意 tool 已经获批。

本节源码依据(10 处)

4. initialize 之后才有 discovery

handshake 把 transport 变成可操作的 RMCP service

RmcpClient::initialize 先从 Connecting 状态取出 pending transport,构造带 elicitation callback 的 ElicitationClientService,然后调用带超时和重试的连接流程。成功后从 peer info 取得 server info,保存 initialize context,把状态换成 Ready;失败时 client 不会假装有一个半初始化的工具目录。

Streamable HTTP 的 initialize 可以在特定 transport error 上重试,但 stdio 和 in-process 不重试;重试会重建 pending transport,并受同一个 initialize deadline 约束。这个差异是 transport-specific 的恢复策略,不是 MCP 协议把所有连接都视为可重放。

tools/list 的结果先成为原始 ToolInfo

startup task 完成 initialize 后,调用 list_tools_for_client_uncached。它通过 RMCP 的 tools/list 取得带 connector id 的工具,再交给 tool_info_from_listed_tool。普通 MCP 工具保留原始 tool.name,把 server name 作为 model-visible namespace;Codex Apps 则额外规范化 connector namespace 和 callable name。

ToolInfo 同时保存两组身份:

  • server_nametool.name 是发回 MCP server 的 raw route;
  • callable_namespacecallable_name 是 Responses API / tool search 使用的 model-visible identity;
  • tool 仍保留原始 schema、annotations 和 _meta,供后面的 policy、审批与执行读取。

模型名字还要过过滤、清理、去重和 collision hash。普通 MCP 的 connector metadata 会先被视为不可信并从 raw tool meta 移除;随后 normalization 保证模型名字符合 API 字符集、唯一且不超过 64 bytes。这个过程改变的是模型看到的名称,不是 tools/call 要发回的 raw tool.name

manager 聚合,仍不等于 exposure

McpConnectionManager::list_all_tools 逐个等待 managed client 的 listed_tools,补上 server origin 和 parallel-call metadata,最后统一执行 model-name normalization。它返回的是“当前 manager 能发现的全部工具”。它没有根据当前模型是否启用 tool_search 来决定 direct/deferred,也没有替具体调用走审批。

本节源码依据(9 处)

5. Session 用 snapshot 把同一份 MCP 交给一次采样

McpRuntimeSnapshot 是配置、manager 和环境 key 的配对

McpRuntimeSnapshot 包含五样东西:McpConfig、插件可用性、Arc<McpConnectionManager>McpRuntimeContext 和可用 environment id 列表。它不是一个轻量的“server name 列表”,而是一次请求真正要用的配置、连接 manager 和环境绑定的组合。

Session::mcp_runtime_for_step 先用 environment id 做快速比较;如果已有 snapshot 的可用环境相同就复用。环境变化时,它还会检查 server catalog 和 connector snapshot 是否真正变化:如果只是某个未被 MCP 使用的 environment availability 改变,就推进 snapshot 的 input key,但复用旧 manager,不重启子进程。只有投影内容确实变化,才进入 refresh_mcp_servers_inner 创建新 manager。

刷新时,session 根据当前 turn 的 auth、environment、approval policy 和 permission profile 创建 McpConnectionManager,把它发布成新的 runtime。publish_mcp_runtime 先把 manager 写入 legacy manager slot,再构造并存储配对的 snapshot;这样旧的 resource client 和新的 model-scoped consumer 不会拿到两套不相干的 manager。

StepContext 再固定一次工具列表

一份 StepContext 覆盖 run_turn 里的一次正常 sampling step,不跨下一次正常 follow-up。run_sampling_request 在 retry loop 之前用它构造 ToolRouterToolCallRuntime;sampling retry 会从最新 history 重建 Prompt.input,但继续复用同一个 router、runtime 和 StepContext,更内层的 request-open / transport retry 也不会更换这份 context。只有 run_sampling_request 返回、控制回到 run_turn,下一次正常 follow-up 才重新 capture。

这份 StepContext 持有上面那份 Arc<McpRuntimeSnapshot>,并用 OnceCell<Vec<ToolInfo>> 缓存第一次 mcp_tools() 的结果。因此同一步里的模型声明、tool search 索引和后续 McpHandler 都从同一个 manager snapshot 读,而不是每个 call 或每次 retry 都重新 list 一遍 server。

step capture 的顺序也值得记住:先取 environment 和 selected capability roots,再取 mcp_runtime_for_step,最后把 snapshot 放进新的 StepContext。这保证了 deferred environment 的变化不会在同一次 sampling 中偷偷换掉 MCP manager。

本节源码依据(9 处)

6. 从 discovery 到 exposure:工具怎样进入 ToolRouter

direct 和 deferred 的差别是模型初始 surface

build_mcp_tool_exposure 先筛掉不可见工具,再把普通 MCP 和通过 connector policy 允许的 Codex Apps 工具放进候选集合。search_tool_enabled 为 false 时,候选集合成为 direct_tools;为 true 时,direct_tools 为空,候选集合放进 deferred_tools。这一步只决定模型请求初始看到什么,不会从 manager 发出 tools/call

普通 MCP 还要通过 tool_is_model_visible;带 ui.visibility 的 tool 只有显式包含 model 才会进入候选集合。Codex Apps 工具除此之外还要有可访问 connector,并通过每个 tool 的 app policy;所以 server 已返回某个 tool,也可能在 exposure 阶段被排除。

ToolExposure 的定义把一个容易混淆的细节写得很直白:Deferred 仍然注册,之后可以通过 search metadata 被发现,但不进入初始 model-visible tool list;Hidden 也保留 dispatch runtime,只是不向模型暴露。DirectDirectModelOnly 才是 is_direct()

ToolRouter 同时保存 model specs 和 executable registry

built_tools 从 step context 取得同一份 all_mcp_tools,计算 connectors,再调用 build_mcp_tool_exposure,把 direct/deferred 列表交给 ToolRouter::from_context。接下来 add_mcp_runtime_tools 为两组 ToolInfo 都构造 McpHandler;deferred 组只是以 ToolExposure::Deferred 加入。

真正的分界在 build_model_visible_specs_and_registry:只有 exposure.is_direct() 的 runtime 才把 spec 放进 model-visible specs,但所有 runtimes(包括 deferred)都交给 ToolRegistry::from_tools。因此“模型当前看不到”不等于“系统没有 handler”。

有 search tool 时,规划器从所有 deferred runtime 收集 search_info,构造一个独立的 ToolSearchHandler。它是发现 deferred spec 的入口,不是 MCP server 的另一个连接;搜索结果只是下一次模型请求可加载的 spec。

McpHandler 把模型名字还原到 raw route

McpHandlerToolInfo 生成 ToolSpec,其 tool_name() 返回 canonical model name;但 handle_call 传给 handle_mcp_tool_call 的是 tool_info.server_nametool_info.tool.name。这就是 raw identity 与 model identity 分离的实际落点。

本节源码依据(9 处)

7. 具体 function call 仍要过审批

handle_mcp_tool_call 先做本地判断,再决定是否发 wire request

模型提交一个 function call 后,handle_mcp_tool_call 的顺序是:解析 JSON arguments;按 raw server/tool 查 metadata;计算 Codex Apps 或 custom MCP 的 approval mode;如果 app policy disabled,直接生成 skip;否则先发 McpToolCallBegin,再调用 maybe_request_mcp_tool_approval。它用 Some 返回 AcceptAcceptForSessionAcceptAndRemember 时进入 handle_approved_mcp_tool_call;返回 None 表示无需再弹审批或已经自动批准,也直接继续执行;DeclineCancel 都生成 skip,不联系 MCP server。

approval 的顺序不是一个简单的 yes/no

maybe_request_mcp_tool_approval 依次检查:

  1. 当前 AskForApproval、permission profile 和 tool approval mode 是否使 MCP prompt 自动通过;
  2. tool annotations(destructive/read-only/open-world)在当前 mode 下是否真的需要审批;
  3. 当前 session 是否已经记住同一 server、connector、tool 的批准;
  4. permission request hooks 是否直接 allow 或 deny;
  5. 是否路由给 Guardian;
  6. 是否启用 tool-call MCP elicitation,若启用则发 structured elicitation,否则走 request_user_input
  7. AcceptForSessionAcceptAndRemember 写入 session store 或 config。

自动通过的条件也不是“没有弹窗就算批准”。mcp_permission_prompt_is_auto_approved 只有在 tool mode 是 Approve,或 approval policy 是 Never 且 permission profile 允许对应写权限时才返回 true。其余情况必须继续走上述判断。

三种 elicitation 不要混写

这里至少有三条相似但不同的路径:

  • server elicitation:外部 MCP server 在 RMCP service 上发 elicitation/createElicitationClientService 把请求交给 SendElicitation,再由 ElicitationRequestManager 按 policy、reviewer 或用户事件回复。
  • tool-call approval elicitation:Codex 在真正调用某个 MCP tool 之前,主动把审批问题包装成 elicitation,供支持 MCP elicitation 的 client/UI 展示。
  • Codex Apps auth elicitation:Codex Apps tool 已经返回“需要登录”结果后,maybe_request_codex_apps_auth_elicitation 才根据 feature 和 approval policy 发 URL elicitation;接受后刷新 Apps tool cache。

通过后才进入 execute_mcp_tool_call

批准后的执行仍会做几件治理工作:把 thread id 写入 _meta;按 server capability 附加 sandbox state;把 request meta 记进 rollout trace;然后调用 snapshot 中同一个 manager。这个函数不是一个可以绕过审批的公共快捷方式,它只在 approved path 中被调用。

本节源码依据(11 处)

8. tools/call 的最后两跳:manager 到 RMCP wire

manager 负责 server/tool 路由和 server-side filter

McpConnectionManager::call_tool(server, tool, arguments, meta) 先按 server name 找到 managed client,再检查该 server 的 ToolFilter 是否允许 raw tool name。未知 server、disabled tool 或 client 尚未 ready 都在这一层失败;通过后才调用 RMCP client,并把 RMCP 的 content、structured content、error flag 和 _meta 转成 Codex 自己的 codex_protocol::mcp::CallToolResult

RMCP client 负责 JSON-RPC method,不负责 Codex approval

RmcpClient::call_tool 在发 request 前刷新 OAuth;它要求 arguments 和 _meta 都是 JSON object,然后构造 CallToolRequestParams,把 _meta 放进 PeerRequestOptions,通过 RunningServiceClientRequest::CallToolRequest,最后只接受 ServerResult::CallToolResult。这里没有 Guardian、permission hook 或用户交互;那些都已在 core/src/mcp_tool_call.rs 的上一层完成。

普通 operation(list_tools、resources、call_tool 等)都先 refresh_oauth_if_needed,再以 .await? 等待 service operation;失败会提前返回,不会走到后面的 persist_oauth_tokens,只有成功结果才触发持久化。initialize 不同:当 pending transport 带 OAuthPersistor 时,握手失败在 connect_pending_transport 内有单独的 persist 分支,先尝试写回 token 再返回原错误;成功 initialize 也只在持有 persistor 时于 client 进入 Ready 后尝试持久化。这说明 OAuth 是连接运行时的一种可更新凭据状态,也说明“每个 operation 无论成败都会 persist”并不成立。

CallToolResult 还要经过两个投影

wire result 回到 core 后,模型看到的结果和事件存储的结果不完全是同一份:

  • 如果模型不支持 image input,content 里的 image block 会被替换成说明文字;
  • 如果结果序列化后超过 event budget,事件副本会折叠成整段 JSON 的 text preview,清掉 structured content 和 meta;
  • is_error: true 会让 McpToolCallItem 的 status 变成 Failed,即使 transport 本身成功返回了 JSON-RPC result;真正的 Rust error 则放进 McpToolCallError
本节源码依据(7 处)

9. 四个边界放在一起看

下面这张表比“有没有 MCP 工具”更接近实际调试顺序:

现象发生在哪一层仍然可能成功的下一层不要下的结论
server startup 失败、environment 不存在、initialize 超时discovery旧的 Codex Apps cache 可能仍提供启动期工具;否则 exposure 没有新工具配置存在不等于 client ready
tools/list 返回工具,但 visibility、connector 或 app policy 过滤掉exposure同一个 raw tool 仍可能在 manager cache 或 status snapshot 中存在discovery 成功不等于模型可见
deferred tool 有 search metadata,但不在首轮 specsexposure / ToolRoutertool_search 可返回 loadable spec,registry 已有 handler不在首轮列表不等于没有 executor
模型提出 function call,但 hook deny、Guardian deny、用户 cancel 或 app disabledapproval可以产生 skip event 和模型可读错误看到 function call 不等于 server 收到 request
approval 通过,但 OAuth expired、server filter 禁止、session closed、wire errorexecution可重试 HTTP initialize 或 refresh OAuth,取决于错误类别approval 通过不等于调用成功
wire 返回 is_error: trueresult projection仍有结构化 CallToolResult 可以回给模型transport 成功不等于业务成功

其中最容易漏掉的是第二行和第四行:list_all_tools 是 manager 的发现 API,McpToolExposure 是模型 surface,maybe_request_mcp_tool_approval 是具体 invocation 的 governor;它们既不共享同一个返回类型,也不由同一个模块拥有。

10. 指定实验:从 fixture 缺失到真实 stdio round trip

当前可复现实验在第四部源码工作台创建并校准的 disposable archive 中运行;该工作台已经 build fixture,并为最终测试配置了足够的线程栈。下面前两段是共享工作台定型前、在独立副本里得到的历史校准日志,不是当前实验要依次执行的步骤;按第四部导读准备后,第一段的 fixture 缺失不会再出现。源码 checkout /tmp/codex-handbook-final-rust-v0.144.6 只用于核对源码和生成 archive,不承载测试执行。

展开两段历史校准日志

历史校准 A:named test 真跑了,但 fixture binary 尚未就绪

共享工作台尚未 build fixture 时,曾直接执行 named test:

: "${ARCHIVE_CODEX_RS:?先执行第四部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-core --test all stdio_server_round_trip

测试 harness 确实进入了一个 case,不是 filter 出来的空结果:

running 1 test
test suite::rmcp_client::stdio_server_round_trip ... FAILED

Error: could not locate binary "test_stdio_server"; tried env vars ["CARGO_BIN_EXE_test_stdio_server"]

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 954 filtered out

失败停在 stdio_server_bin() 的 binary lookup,尚未创建 MCP fixture。它证明测试名匹配成功,也证明不了 stdio 往返;这只是校准前的失败。

历史校准 B:先 build fixture,默认 worker stack 仍然中止

把测试依赖的 binary 单独编出来:

: "${ARCHIVE_CODEX_RS:?先执行第四部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
cargo build --locked -p codex-rmcp-client --bin test_stdio_server

这条命令成功结束,test_stdio_server 已经位于 target 目录。再次运行原来的 test command,fixture lookup 不再报错,但同一个 case 在 Tokio worker 上耗尽默认线程栈:

running 1 test

thread 'tokio-rt-worker' (...) has overflowed its stack
fatal runtime error: stack overflow, aborting

process didn't exit successfully (...) (signal: 6, SIGABRT: process abort signal)

这一段已经越过 binary lookup,但进程中止在测试运行环境。它仍然不是 round trip 的成功证据,也不能据此说 MCP client 的业务断言失败了。

当前实验:只校准线程栈,完整 case 通过

不改测试内容,只给 Rust 创建的 worker thread 更大的最小栈:

: "${ARCHIVE_CODEX_RS:?先执行第四部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just --set rust_min_stack 16777216 test --locked -p codex-core --test all stdio_server_round_trip

最终输出:

PASS ... codex-core::all suite::rmcp_client::stdio_server_round_trip
Summary ... 1 test run: 1 passed

现在才能引用这条实验的业务结论:Codex 启动真实 stdio test server,完成 discovery,允许 tool_search 找到 deferred tool,把模型调用路由到 raw MCP tool,再收到 structured result 和最终 function-call output。RUST_MIN_STACK 只是让这个固定版本的 integration harness 在本机跑完,不是 MCP 协议本身的一项能力。

三段输出都包含 running 1 test。如果日志只有 running 0 tests,说明 named case 未运行,不能拿来证明 fixture 失败、stack calibration 或 stdio round trip 中的任何一项。

本节源码依据(3 处)

11. 反向边界:codex mcp-server 不是这条 client 链

边界侧栏:方向反过来,owner 也反过来。

codex-rs/mcp-serverMessageProcessor 自己处理 initializetools/listtools/call。initialize 响应把 server identity 设为 codex-mcp-server,声明 tools capability;tools/list 返回的是 codexcodex-reply 两个 server-owned tool;收到 tools/call 后,它按名字分派到 Codex session runner,并把异步事件转换回 MCP result。

这条路径里的 Codex 是 MCP server,外部客户端才是调用者。不要把它和本章的 McpConnectionManager → RmcpClient 画成同一条连接,也不要因为它能暴露 codex tool,就推断普通 MCP server 的 tool exposure 或 approval 逻辑已经发生。

本节源码依据(2 处)

交给第 22 章的三个稳定对象

第 22 章:Dynamic Tools 为什么把执行权交回宿主不会接收一份 MCP runtime。下面三个对象更适合作为比较坐标:MCP 与 Dynamic Tools 是规划器的并列、独立输入,最后都进入 ToolRouter,但没有前者向后者传递 snapshot 的步骤。

  1. McpRuntimeSnapshot:一次逻辑 sampling request 使用的 MCP config、manager、runtime context 和 environment key,只服务 MCP 路径。DynamicToolHandler 不读取 McpRuntimeSnapshot;它从独立的 DynamicToolSpec 构造 runtime,调用时再等待宿主。
  2. ToolInfo 与 exposure:MCP 的 ToolInfo 保留 raw route 和 model-visible callable identity;Dynamic Tools 没有这份 raw MCP route,但同样通过 ToolExposure 区分 direct/deferred。两类 runtime 都可能进入 ToolRegistry,这是共同规划结果,不是状态移交。
  3. CallToolResult:它是 MCP wire result 转成 Codex protocol 后的稳定形状,再经过模型 modality 和 event size 两次投影。第 22 章对应的是 DynamicToolResponseFunctionToolOutput,不能把宿主 response 写成 MCP tools/call 的结果。

如果只记住一句话,记这句:tools/list 发现的是 server 能做什么;ToolRouter 暴露的是模型这次可以提出什么;approval 决定这次具体提出的调用能否继续;只有 manager 路由成功后,MCP server 才真正收到 tools/call