第 9 章 MCP 与外部工具生态:客户端、服务端与延迟加载¶
第 6 章我们看了 Codex 自带的工具,第 8 章看了打包的技能。但一个 harness 不可能把世界上所有工具都内置进去。这一章讲 agent 接入外部世界的关口——MCP(Model Context Protocol,模型上下文协议):怎么把任意外部工具插进来,又怎么把 Codex 自己变成别人的工具,以及——当工具多到几百个时——怎么让上下文别被工具说明书撑爆。
引子:一段被焊死的对接代码¶
先从一个失败场景开始。
假设你给 Codex 加一个"查 Jira 工单"的能力。最直接的做法是:在 core 里写一个 JiraHandler,硬编码 Jira 的 REST 地址、鉴权头、JSON 字段映射,再把它注册进工具系统。能跑。
然后第二个需求来了:查 Figma 设计稿。再写一个 FigmaHandler。第三个:读公司内部的数据看板。再写一个。第四个、第五个……每加一个外部系统,core 里就多一坨"对接代码",每一坨都带着自己的 SDK 依赖、鉴权细节、错误处理。半年后,core 变成一个谁也不敢动的大泥球——而且这些对接全部和 Codex 这个特定 harness 绑死了。换一个 agent 框架(比如你自己写的、或者 Cursor、或者 Claude Code),这些对接得从头再写一遍。
这就是经典的 M×N 集成爆炸:M 个 agent 框架,N 个外部工具,朴素做法要写 M×N 套对接。每个组合都是一次性的、不可复用的胶水。
〔配图 1:M×N 爆炸 → M+N 标准化〕——左半幅画 M 个 agent(Codex / Cursor / 自研)和 N 个工具(Jira / Figma / 数据库),两两之间拉满乱糟糟的连线,标"M×N 套胶水";右半幅中间插入一根竖线标"MCP 协议",左边每个 agent 只连到这根线、右边每个工具也只连到这根线,标"M+N 套:各连协议一次"。底部:"工具不焊死在 harness 里——按协议'插上就能用'。"
解法不是写更好的胶水,而是取消胶水:定一个协议,约定"一个 agent 该怎么跟一个外部工具说话"。任何工具只要按这个协议把自己暴露出来,任何 agent 就都能用——M×N 就塌缩成 M+N。这个协议,就是 MCP。
MCP 官方自己的比喻很贴切:"Think of MCP like a USB-C port for AI applications. Just as USB-C provides a standardized way to connect electronic devices, MCP provides a standardized way to connect AI applications to external systems."(把 MCP 想成 AI 应用的 USB-C 口:就像 USB-C 给电子设备提供了标准化的连接方式,MCP 给 AI 应用提供了标准化的、连接外部系统的方式)。你买个鼠标不用关心主板怎么设计,插上就能用——因为大家都遵守 USB 协议。MCP 让"给 agent 加一个工具"也变成"插上就能用"。
它和你可能听过的 Tool Use / Function Calling 是什么关系?简单说:Function Calling 是"模型怎么表达'我要调一个工具'"——是第 6 章那套点单语言;而 MCP 是"这个工具从哪来、怎么连、怎么鉴权、怎么传输"的标准化层。前者是点单的语言,后者是把后厨接进来的管道。两者正交:Codex 把一个 MCP 工具接进来后,模型看到的仍然是一条普通的 function 规格,调用方式和内置的 shell 一模一样——MCP 的存在对模型是透明的。这正是本书主线"对 agent 可见的才存在"的一个精妙拐点:MCP 的复杂性恰恰不该对模型可见,可见的只是又一条干净的工具规格。
接下来我们用 Codex 源码(codex-rs,HEAD ad2012d6)逐层拆开三件事:Codex 怎么当客户端把别人的工具接进来、怎么当服务端把自己变成别人的工具、以及几百个工具怎么不撑爆上下文。
一、Codex 作为 MCP 客户端:把别人的工具接进来¶
Codex 最常见的角色,是 MCP 客户端:启动时按配置去连接一批 MCP server,把它们提供的工具"接"进自己的工具系统。codex-rs/README.md 写得很直白:"Codex CLI functions as an MCP client that allows the Codex CLI and IDE extension to connect to MCP servers on startup."(Codex CLI 作为 MCP 客户端,让 CLI 和 IDE 扩展在启动时连接 MCP server)。
这一节我们要纠正一个常见误解,并补上整条调用链。
1.1 McpManager 不是"转发调用"的那个家伙¶
很容易想当然地以为:既然叫 McpManager,那它一定负责"连接、列举工具、转发调用"这一整套。其实不然。 翻开 core/src/mcp.rs,整个文件只有 42 行——它根本不碰具体的工具调用:
pub struct McpManager {
plugins_manager: Arc<PluginsManager>,
}
impl McpManager {
pub async fn configured_servers(&self, config: &Config)
-> HashMap<String, McpServerConfig> { /* … */ }
pub async fn effective_servers(&self, config, auth)
-> HashMap<String, EffectiveMcpServer> { /* … */ }
pub async fn tool_plugin_provenance(&self, config)
-> ToolPluginProvenance { /* … */ }
}
读这三个方法名就够了:configured_servers(解析配置里声明了哪些 server)、effective_servers(结合鉴权算出实际生效的 server)、tool_plugin_provenance(来源归属——某个工具是哪个 plugin 带进来的)。McpManager 的真实职责是 "服务器配置解析 + plugin 来源归属(provenance)",是个目录管理员,不是接线员。它回答的是"我该连哪些 server、它们各自从哪来",而不是"现在去调那个 server 的那个工具"。
为什么 provenance 重要?因为同一个 MCP server 可能由不同来源(用户手写的 config.toml、某个技能的依赖声明、某个插件)带进来。当后面要"按需安装/卸载"时,得知道每个 server 归谁管——这就是 tool_plugin_provenance 存在的理由,也是第五节技能依赖闭环的伏笔。
1.2 真正的"接线员":McpHandler 与工具名加前缀¶
那"转发调用"在哪?在 core/src/tools/handlers/mcp.rs 的 McpHandler。回到第 6 章那张图——外部 MCP 工具,会和内置工具一起汇进同一个 ToolRouter。 而把一个 MCP 工具"翻译"成 ToolRouter 能识别的一条工具,靠的就是 McpHandler:它持有一个 ToolInfo(描述某 server 的某工具),实现了和内置工具同样的 ToolExecutor trait——tool_name() / spec() / handle()。对 ToolRouter 来说,它和 ShellHandler 没有区别。
这里有个不起眼但关键的细节:工具名加前缀。外部 server 的工具叫什么是它自己说了算,可能撞上内置工具名(比如某个 server 也叫自己的工具 read)。Codex 用两个常量给 MCP 工具名加上命名空间:
const LEGACY_MCP_TOOL_NAME_PREFIX: &str = "mcp__";
const MCP_TOOL_NAME_DELIMITER: &str = "__";
fn join_tool_name(tool_name: &ToolName) -> String {
match tool_name.namespace.as_deref() {
Some(namespace) => format!("{namespace}{MCP_TOOL_NAME_DELIMITER}{name}"),
None => tool_name.name.clone(),
}
}
fn ensure_mcp_prefix(name: &str) -> String {
if name.starts_with(LEGACY_MCP_TOOL_NAME_PREFIX) { name.to_string() }
else { format!("{LEGACY_MCP_TOOL_NAME_PREFIX}{name}") }
}
解读:join_tool_name 先把 <server 命名空间>__<工具名> 拼起来,ensure_mcp_prefix 再确保它带上 mcp__ 前缀。于是一个来自 server notion 的 search 工具,对模型呈现为类似 mcp__notion__search 的名字。前缀做了两件事:一是避免撞名(所有外部工具都在 mcp__ 命名空间下),二是保留来源(中间那段 server 名让调用能路由回正确的 server)。你在本章开头那张系统提示里看到的一长串 mcp__plugin_atlassian_atlassian__createJiraIssue、mcp__claude_ai_Notion__authenticate,正是这套规则的产物——这本书的 harness 自己就接了一堆 MCP server。
至于真正的网络往返,McpHandler::handle() 把活儿转交给 handle_mcp_tool_call(...),由它带着 server_name、原始工具名、参数去和具体的 server(通过下面要讲的 rmcp 客户端)通信,再把结果裹成 McpToolOutput 返回。注意 handle() 里有一行还原前缀的细节——它把模型菜单上那个 mcp__notion__search 重新拆回 server_name + 原始 tool.name:模型看到的是带前缀的名字,发给 server 的却是 server 自己认得的原始名。加前缀是给模型看的(避免撞名、保留来源),去前缀是给 server 看的(它只认自己的工具名)——前缀只是 harness 内部的一层"翻译皮",两头都不暴露给对方。这又是"对 agent 可见的才存在"的微观体现:对模型可见的命名空间,对 server 根本不存在。
还有一个容易忽略的并发细节。McpHandler::supports_parallel_tool_calls() 并不无脑允许并行,它看工具有没有 read_only_hint 注解:
self.tool_info.supports_parallel_tool_calls
|| self.tool_info.tool.annotations.as_ref()
.and_then(|a| a.read_only_hint).unwrap_or(false)
解读:一个"只读"工具(按 MCP 注解自报)天然可以和别的调用并行——读不会互相踩;而会改外部状态的工具默认不并行,除非 server 显式声明扛得住。harness 没法控制外部 server 的行为,于是退一步:用工具自己声明的语义(只读 vs 有副作用)来决定能不能并发。这是和外部世界打交道时一种典型的"信任但设默认下限"的姿态——第 10 章你会看到这种姿态贯穿整个安全模型。
〔配图 2:一条 MCP 工具调用的旅程〕——从左到右四格:①模型菜单里一条
mcp__notion__search(标"加了前缀的工具名");②ToolRouter按名字路由到McpHandler(标"和内置工具同一个 trait");③handle_mcp_tool_call拆出 server 名+原始工具名,经 rmcp 客户端发 JSON-RPC;④Notion server 返回结果,裹成McpToolOutput回流。底部:"McpManager 管目录,McpHandler 管接线——别记反。"
1.3 传输层:stdio 与 StreamableHttp(不是 uds)¶
工具调用最终要走某种"管道"到达 server。Codex 支持的传输方式定义在 config/src/mcp_types.rs 的 McpServerTransportConfig 枚举里——两种:Stdio 与 StreamableHttp(注意不是 stdio/uds):
pub enum McpServerTransportConfig {
/// …/transports#stdio
Stdio { command: String, args: Vec<String>,
env: Option<…>, env_vars: Vec<…>, cwd: Option<PathBuf> },
/// …/transports#streamable-http
StreamableHttp { url: String,
bearer_token_env_var: Option<String>,
http_headers: Option<…>, env_http_headers: Option<…> },
}
解读:
- Stdio:Codex 把 server 当成一个本地子进程拉起来(
command+args),通过它的标准输入/输出收发 JSON-RPC。适合本地工具——一个 npm 包、一个本地脚本,启动即用,无需网络。 - StreamableHttp:Codex 通过 HTTP 连一个远端 server(
url),用bearer_token_env_var从环境变量读 Bearer Token 做鉴权。适合 SaaS——Notion、Jira 这类需要 OAuth/Token 的云服务。注意鉴权设计的克制:配置里只存环境变量名,不存密钥本身(注释明说 "The actual secret value must be provided via the environment."),这样config.toml可以安全提交。
这两种传输直接对应 MCP 规范定义的两种标准传输(stdio 和 Streamable HTTP),枚举注释里甚至直接贴了规范链接。具体的 JSON-RPC 客户端实现封装在 rmcp-client crate(crate 名 codex-rmcp-client,基于官方 rmcp SDK)里——这又是一次"对 agent 可见的才存在"的体现:传输是 stdio 还是 HTTP、Token 怎么注入,模型一概不知道,它只管点单。
1.4 资源(Resources):不只调工具,还能读外部数据¶
MCP 不止有工具,还有资源(resources)——server 可以暴露一些可读的外部数据(文档、数据库 schema、应用信息)。MCP 规范的定义是:"Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information."
Codex 在 core/src/tools/handlers/mcp_resource/ 下用三个内置工具承接资源:
| 工具 | 对应 MCP 方法 | 作用 |
|---|---|---|
list_mcp_resources |
resources/list |
列出有哪些固定资源(带 URI) |
list_mcp_resource_templates |
resources/templates/list |
列出有哪些参数化资源模板 |
read_mcp_resource |
resources/read |
按 URI 读某个具体资源 |
前两个是"列目录",第三个是"取内容"。read_mcp_resource 的核心就一句(read_mcp_resource.rs):
注意它的两个参数:server(哪个 server)+ uri(读哪个资源)。工具规格里写得很死板也很贴心——uri 字段描述是 "Must be one of the URIs returned by list_mcp_resources."(必须是 list_mcp_resources 返回过的 URI 之一)。这是一条精心设计的工具说明:它把"先 list 再 read"的协议约束直接写进了 schema 描述里,逼模型走正确流程,而不是凭空编 URI。Anthropic 的《Writing effective tools for agents》正是这么主张的——工具描述就是 prompt engineering,要"像给新同事讲一样"把资源之间的关系讲明白。
第三个工具为什么单独存在? 因为有些资源不是一个固定 URI,而是一族。这就是资源模板(resource template):server 用 RFC 6570 的 URI 模板暴露参数化资源,比如 file:///{path}——{path} 是个占位参数。list_mcp_resources 只会列出已经存在的、确定的资源;而 list_mcp_resource_templates 列出的是"你可以按这个模板构造 URI 去读"的那一类。Codex 工具描述里区分得很清楚:模板用于"data that takes parameters"。少了它,模型就只能读到 server 愿意逐条列举的资源,碰不到"按 ID 取记录"这种需要填参的场景。
于是模型在外部世界的能力是两条腿:调外部工具去做事(McpHandler)+ 读外部资源拿信息(mcp_resource/ 三件套)。 资源这条腿还自带一个 harness 设计上的暗示——规格里反复出现 "Prefer resources over web search when possible."(能用资源就别用网搜)。这是在用工具描述做行为引导:内部资源比公网搜索更准、更省、更可控,harness 通过措辞把模型往省钱又靠谱的路上推。
1.5 一次调用的完整往返:审批闸 → rmcp 发包 → 回填交互¶
前面 1.2 说 McpHandler::handle() 把活儿"转交给 handle_mcp_tool_call",一句话带过了。但这条往返链上其实卡了好几道闸——它是理解"agent 调一个外部工具到底经历了什么"的关键,也是下一章安全模型的预演。我们顺着 core/src/mcp_tool_call.rs 的 handle_mcp_tool_call 走一遍。
先想象一个失败场景:模型决定调 mcp__notion__delete_page,harness 二话不说就把请求发给 Notion server,页面没了。这显然不行——外部工具可能有破坏性副作用,而 harness 对这些副作用一无所知。所以真正的执行路径在"发包"之前,先插了一道审批闸:
let approval_mode = /* 按 server / 工具查出来的审批模式 */;
notify_mcp_tool_call_started(/* 通知 UI:要调这个工具了 */).await;
if let Some(decision) = maybe_request_mcp_tool_approval(
&sess, turn_context, &call_id, &invocation,
&hook_tool_name, metadata.as_ref(), approval_mode,
).await {
match decision {
Accept | AcceptForSession | AcceptAndRemember =>
return handle_approved_mcp_tool_call(/* … */).await,
Decline { message } => /* 通知 skip,回一条"用户拒绝" */,
Cancel => /* 通知 skip,回一条"用户取消" */,
}
}
handle_approved_mcp_tool_call(/* 无需审批,直接放行 */).await
解读:maybe_request_mcp_tool_approval 返回 Some(decision) 表示"这次需要问用户"——Accept 系列(含 AcceptForSession、AcceptAndRemember 两档"记住")才会走 handle_approved_mcp_tool_call 继续;Decline/Cancel 直接把一条"用户拒绝/取消"的结果回给模型,根本不发包。返回 None 则表示这个工具的审批模式判定为无需打扰(比如已被信任),直接放行。审批模式不是 McpHandler 自己拍的,而是按 server + 工具名查出来的(custom_mcp_tool_approval_mode,对 Codex Apps 还有专门的 AppToolPolicy)——这正是 1.1 里 McpServerToolConfig 的 approval_mode 字段落地的地方。外部工具一律先过审批闸,再谈执行:harness 不信任外部副作用,把"要不要真做"的决定权留给用户。
过了闸,才是真正的网络往返。execute_mcp_tool_call 里那一句 sess.call_tool 就是 rmcp 客户端把 JSON-RPC 发出去、等回包的地方:
let result = sess
.call_tool(&invocation.server, &invocation.tool, rewritten_arguments, request_meta)
.await
.map_err(|e| format!("tool call error: {e:?}"))?;
let result = sanitize_mcp_tool_result_for_model(/* 按模型是否支持图片 */, Ok(result))?;
注意这里两个细节都是"对 agent 可见的才存在"的延伸。其一,发出去的不是裸参数:request_meta 在发包前被 with_mcp_tool_call_thread_id_meta、augment_mcp_tool_request_meta_with_sandbox_state 一路加料——把当前 thread id、沙箱状态塞进 MCP 请求的 _meta 里,让 server 端能感知"是谁、在什么沙箱下调我"。其二,回包不是直接喂给模型:sanitize_mcp_tool_result_for_model 会按模型本身的能力裁剪结果——模型不支持图片输入,就把结果里的图片内容清掉。server 返回了什么是一回事,模型最终看见什么是另一回事,中间永远隔着 harness 这层裁剪。
最后是一个反向的交互——elicitation(征询)。普通工具调用是"模型问 server,server 答",单向。但有些场景 server 反过来需要问用户:最典型的是"你还没授权这个连接器,去点一下这个链接登录"。Codex 在 maybe_request_codex_apps_auth_elicitation 里处理这种回流:
let params = McpServerElicitationRequestParams {
thread_id: …, turn_id: …, server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
request: McpServerElicitationRequest::Url { message, url, elicitation_id, .. },
};
let response = sess.request_mcp_server_elicitation(turn_context, request_id, params).await.response;
if response.map(|r| r.action) == Some(ElicitationAction::Accept) {
refresh_codex_apps_after_connector_auth(sess, turn_context).await; // 重连刷新工具缓存
}
解读:当一次调用因"未授权"失败、且审批策略允许征询(AskForApproval::Never 和不允许 elicitation 的 granular 配置会直接跳过)时,harness 不是干巴巴回一句"失败",而是给用户弹一个带授权链接的征询请求;用户点了 Accept、完成登录后,refresh_codex_apps_after_connector_auth 会硬刷新连接器的工具缓存(hard_refresh_codex_apps_tools_cache),让刚授权出来的工具立刻可用。这就把"调用失败 → 引导授权 → 自动恢复"串成了一个闭环,而不是把锅甩回给模型自己琢磨。
所以一条 MCP 工具调用的真实骨架是四段:查审批模式 → (需要则)问用户、拒了就不发包 → rmcp 带着 meta 发包、回包按模型能力裁剪 → 失败若因未授权则反向征询、授权后刷新缓存。 McpHandler::handle() 那句轻描淡写的"转交给 handle_mcp_tool_call",背后是这一整套对外部副作用的审慎。审批与征询的更完整图景留到第 10 章,这里先记住:外部工具的每一次往返,都被 harness 夹在"发包前问一遍、回包后裁一遍"的两道关之间。
〔配图 3:一次 MCP 调用的四道关〕——横向流水线四格:①「查审批模式」(按 server+工具查出 approval_mode,橙色判断块);②「问用户」(maybe_request_mcp_tool_approval,分叉:Accept→继续 / Decline·Cancel→不发包直接回模型);③「rmcp 发包」(call_tool,标注"请求里加料 thread_id+sandbox state;回包按模型能力 sanitize",蓝色管道);④「反向征询」(未授权失败→弹授权链接 elicitation→Accept 后 hard_refresh 工具缓存,紫色)。底部:"外部工具的每次往返,都被夹在'发包前问一遍、回包后裁一遍'之间。"
二、Codex 作为 MCP 服务端:把自己变成别人的工具¶
反过来,Codex 也能当 MCP 服务端。
运行 codex mcp-server(对应 codex-mcp-server crate),整个 Codex 就把自己暴露成一个 MCP server——这样别的 agent 就能把"调用 Codex"当成自己的一个工具来用。README 的措辞很精确:"Codex can be launched as an MCP server by running codex mcp-server. This allows other MCP clients to use Codex as a tool for another agent."(运行 codex mcp-server 可把 Codex 作为 MCP server 启动,让别的 MCP 客户端把 Codex 当成另一个 agent 的工具)。
注意这里有两个容易混的子命令,文档 docs/codex_mcp_interface.md 专门澄清过:
codex mcp-server:把 Codex 当作 server 跑起来(传输是"standard MCP over stdio,JSON-RPC 2.0, line-delimited")。codex mcp:是管理子命令——add/list/get/removeconfig.toml里配置的 MCP server 启动项。一个是"我自己当 server",一个是"管理我要连的那些 server",别搞反。
你可以拿官方的 MCP Inspector 直接戳它:npx @modelcontextprotocol/inspector codex mcp-server。
这就出现了一个很有意思的递归:一个 agent,既能用工具,也能成为别人的工具。 harness 的边界因此变得可组合——大 agent 套小 agent,每一层都通过同一个协议对话。你的某个上层编排 agent 遇到"这步需要改代码",可以直接把这活儿当一个 MCP 工具调用丢给 Codex;Codex 在内部该用 shell 用 shell、该跑 sandbox 跑 sandbox,干完把结果顺着 MCP 协议吐回去。对上层 agent 来说,Codex 只是它菜单上的一个工具——一个恰好"内部还藏着一整个 agent 循环"的工具。
〔配图 4:Codex 既是客户端又是服务端〕——中间一个 Codex 图标。左边:几个外部 MCP server(数据库/Jira/Notion)通过 MCP 连进来,箭头指向 Codex,标"作客户端(codex mcp):用别人的工具"。右边:一个"上层 agent"通过 MCP 把 Codex 当工具调用,箭头从上层 agent 指向 Codex,标"作服务端(codex mcp-server):把自己变成别人的工具"。底部:"既能用工具,也能成为工具——同一个协议两个方向。"
举个具体场景体会这个递归的威力。设想一个"发布流水线"编排 agent:它负责"读需求 → 改代码 → 跑测试 → 写发布说明"。它自己不擅长改代码,于是把"改代码"这一步配成一个 MCP 工具——指向一个 codex mcp-server。当流水线走到这一步,它就像调任何普通工具一样调"Codex 工具",传进任务描述;Codex 在自己的进程里跑完一整轮 agent 循环(该读文件读文件、该 sandbox 跑命令 sandbox 跑命令、该 apply_patch 就改),把 diff 和结论顺着 MCP 协议吐回去。对编排 agent 来说,它从头到尾只看见"一个工具调用返回了结果",完全不知道这个工具内部藏着一整套循环、上下文管理、沙箱——这正是协议封装的意义:复杂性被一条干净的工具边界挡在外面。
这条主线和后面几章呼应:第 12 章的多代理、第 15 章的运行时——Codex 的"核能"被各种宿主驱动,"上层 agent 通过 MCP 调用"只是其中一种宿主。协议化的边界,是 harness 可组合的前提。换个角度看:第一节"Codex 当客户端"和这一节"Codex 当服务端"其实是同一枚硬币——客户端那头"调进来的工具",可能恰恰是另一个 Codex 当服务端"暴露出去的自己"。 整个生态因此能层层嵌套,而每一层的契约都只是 MCP 那一份协议。
三、延迟加载:这一次,省的是"工具菜单"¶
接了一堆 MCP server,新问题立刻来了:它们可能一共提供几百个工具。如果把这几百个工具的规格(名字 + 描述 + JSON schema)全塞进模型的菜单,第 4 章那个上下文预算又要爆了——只不过这次撑爆它的,是"工具说明书"本身。
这不是危言耸听。Anthropic 工程博客《Code execution with MCP》直接点了这个痛:"Tool descriptions occupy more context window space, increasing response time and costs."(工具描述吃掉更多上下文窗口,推高延迟和成本),而问题在"hundreds or thousands of tools across dozens of MCP servers"(横跨几十个 server 的成百上千个工具)的规模下尤为严重。工具规格不是免费的:它在你还没调用任何工具之前就已经占满了上下文。
你应该已经猜到 Codex 怎么解了——和第 8 章 skill 一模一样的招:索引常驻、细节按需。 这是"渐进式加载"在本书的第四次现身(前三次:第 4 章 AGENTS.md 目录、第 6 章 tool_search、第 8 章 skill 名录)。同一个母题——上下文是预算,知道得多、占得少——又来一次。
3.1 两拨工具:mcp_tools 与 deferred_mcp_tools¶
在构造 ToolRouter 的参数 ToolRouterParams(core/src/tools/router.rs)里,MCP 工具被分成两拨:
pub(crate) struct ToolRouterParams<'a> {
pub(crate) mcp_tools: Option<Vec<ToolInfo>>, // 直接进菜单
pub(crate) deferred_mcp_tools: Option<Vec<ToolInfo>>, // 延迟,不进菜单
// …
}
它们的待遇差别,在 core/src/tools/spec_plan.rs 注册时一目了然——同样是 McpHandler,注册的"曝光度(exposure)"不同:
// 直接的:默认曝光,进模型菜单
if let Some(mcp_tools) = context.mcp_tools {
for tool in mcp_tools { planned_tools.add(McpHandler::new(tool.clone())?); }
}
// 延迟的:标成 Deferred,不进菜单
if let Some(deferred_mcp_tools) = context.deferred_mcp_tools {
for tool in deferred_mcp_tools {
planned_tools.add_with_exposure(handler, ToolExposure::Deferred);
}
}
关键就在 ToolExposure::Deferred 这个标记。后面构建"模型可见规格"时有一道闸(build_model_visible_specs_and_registry):
for runtime in &runtimes {
let exposure = runtime.exposure();
if exposure.is_direct() && !is_hidden_by_code_mode_only(...) {
specs.push(spec_for_model_request(turn_context, exposure, runtime.spec()));
}
}
let registry = ToolRegistry::from_tools(runtimes); // ← 全部进注册表
解读这道闸是理解整个延迟加载的钥匙:进菜单(specs)和进注册表(registry)是两件分开的事。 循环里只有 exposure.is_direct() 的工具才 push 进 specs——specs 最终变成发给模型的 model_visible_specs(菜单);Deferred 的被这道 if 挡在菜单外。但下一行 ToolRegistry::from_tools(runtimes) 拿的是全部 runtimes——延迟工具照样进了注册表。
这就是"延迟加载"和"根本没加载"的本质区别:工具在手边、随时可调(在注册表里),只是暂时没摆上菜单(不在 specs 里)。一旦 tool_search 命中并把某个延迟工具的 schema 喂给模型,模型发起调用时,ToolRouter 在注册表里一查就有——不需要任何重新连接、重新拉取。藏起来的是"说明书",工具本体一直在线。菜单是给模型省上下文的,注册表是给 harness 留后路的,两者刻意分离。
3.2 tool_search:藏起来的工具靠什么被找到¶
菜单上藏了几百个工具,模型怎么知道有它们、又怎么把需要的那个调出来?答案是放一个搜索入口——tool_search。它的名字是个常量(tools/src/tool_discovery.rs):
pub const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
pub const TOOL_SEARCH_DEFAULT_LIMIT: usize = 8;
tool_search 工具只在有延迟工具时才挂上(append_tool_search_executor)——它从所有 Deferred 工具里收集"可搜索信息",只有非空才注册这个搜索器:
let search_infos = planned_tools.runtimes().iter()
.filter(|e| e.exposure() == ToolExposure::Deferred) // 只收延迟的
.filter_map(|e| e.search_info())
.collect::<Vec<_>>();
if search_infos.is_empty() { return; }
planned_tools.add(ToolSearchHandler::new(search_infos));
那"可搜索信息"是什么?看 McpHandler::search_info():它把工具的 server 名/connector 名、命名空间描述、再加上工具规格本身揉成一段可被检索的文本(build_mcp_search_text)。换句话说——搜索索引里有名字和描述,但模型菜单上只放一个 tool_search 按钮。
于是完整的延迟套路是:
- 几百个工具大多注册成
Deferred,不进菜单; - 菜单上只放一个
tool_search; - 模型需要某能力时,用
tool_search搜(按描述匹配,默认返 8 条); - 搜到目标工具,它的 schema 才被临时加载进上下文,模型这才能正式调用它。
工具名/描述先入索引,完整 schema 后取。 省下的,正是几百份 JSON schema 平时白白占着的上下文——恰好对应 Anthropic 那篇文章的诊断。这和第 6 章本地内置工具的 tool_search、第 8 章 skill 名录是同一台机器的不同投影:harness 永远在用"索引常驻 + 细节按需"对抗有限的上下文预算。
〔配图 5:MCP 工具的延迟加载〕——左侧一个巨大的"工具仓库"(密密麻麻几百个工具图标,标
ToolExposure::Deferred,灰色"已注册但未上桌");右侧模型手里的"菜单"只摆几个常用工具 + 一个醒目的tool_search按钮;中间一条流程:模型用tool_search按描述搜(默认返 8 条)→ 命中的那一个工具的 schema 被取到菜单上 → 模型调用。标注:"名/描述先入索引,schema 后取(同第 4、6、8 章一个母题)。"
四、插件与连接器:发现、安装、按需接入¶
回到一个失败场景:假设接入 MCP 的唯一方式,是用户手动往 config.toml 里抄每个 server 的启动命令、URL、Token 变量名。这就把"扩展能力"的门槛压在了用户身上——模型自己明明知道"我现在需要一个 Jira 工具",却没法说出"那就给我装一个"。能力发现成了纯人工活,agent 的自主性在这道墙前断掉了。
MCP 协议解决了"怎么连",但没解决"从哪发现、怎么装"。Codex 在 MCP 之上补了这一层,让模型自己也能参与"发现与请求安装"。相关常量都在 tools/src/tool_discovery.rs 里成对出现:
pub const LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME: &str
= "list_available_plugins_to_install";
pub const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install";
- 连接器(connectors)(
core/src/connectors.rs):把常见 SaaS 的接入封装成"可发现项"。list_accessible_connectors_from_mcp_tools这类函数从已接入的 MCP 工具里反推出"你现在能用哪些连接器",还有带缓存的版本(list_cached_...)避免每次都重新探测。 - 可发现工具(DiscoverableTool):统一抽象,分两类——
Connector(云服务)和Plugin(插件)。模型用list_available_plugins_to_install看见有哪些可装,需要时用request_plugin_install请求安装。
注意一个安全细节:filter_request_plugin_install_discoverable_tools_for_client 会按客户端过滤——比如 TUI 客户端(codex-tui)下会滤掉 Plugin 类。不同宿主能"发现"到的东西不一样,这是 harness 在按运行环境裁剪能力面(第 15 章会展开)。当然,真要装东西是要审批的——那是第 10 章的主题。
这就形成一个完整的外部能力生态:发现(有哪些可用)→ 按需安装/连接 → 通过 MCP 统一调用 → 用完即走。 整条链都贯彻"按需"二字,不给上下文和系统添不必要的负担——和第三节的延迟加载是同一种节制,只不过一个省的是"菜单上的 schema",一个省的是"根本没必要常驻的连接"。
五、技能与 MCP 的合流:自带依赖的能力包¶
最后把第 8 章和这一章接上。
有些技能(skill)本身就依赖某个外部工具——比如一个"查工单并汇总"的技能,没有对应的 Jira MCP server 根本干不了活。如果技能装好了、MCP 却没接,技能就是个空壳。Codex 用 core/src/mcp_skill_dependencies.rs 处理这件事:一个技能可以声明"我依赖哪些 MCP server",缺了就提示装上。
主流程在 maybe_prompt_and_install_mcp_dependencies 里,逻辑一步步收窄,把"门槛"写得很清楚:
// 1. 只对一方客户端开放
if !is_first_party_originator(originator_value.as_str()) { return; }
// 2. 没提到技能、或特性未开 → 不管
if mentioned_skills.is_empty()
|| !config.features.enabled(Feature::SkillMcpDependencyInstall) { return; }
// 3. 算出"声明了但还没装"的那批
let installed = sess.services.mcp_manager.configured_servers(config).await;
let missing = collect_missing_mcp_dependencies(mentioned_skills, &installed);
if missing.is_empty() { return; }
// 4. 去重(没提示过的) → 询问用户 → 同意才装
解读几个要点:
- 谁来判断"已装了哪些"? 正是第一节的
McpManager::configured_servers——这下闭环了:目录管理员(McpManager)报出"现有 server 清单",技能依赖逻辑拿它和"技能声明的需求"做差集,算出missing。这就是为什么McpManager要管 provenance:知道每个 server 归谁,才能正确判断"这个依赖是不是已经被某来源满足了"。 collect_missing_mcp_dependencies是核心差集函数;它用canonical_mcp_dependency_key/canonical_mcp_server_key做"规范化键"比对,避免同一个 server 用不同写法(命令 vs URL)被误判成缺失。- 层层设防:一方客户端限制、特性开关、去重(
filter_prompted_mcp_dependencies,别反复烦同一个依赖)、最后should_install_mcp_dependencies走用户确认。装东西从来不是悄悄进行的。
于是"能力"这一层(第 8、9 章)就闭环了:技能定义"怎么做一套流程",MCP 提供"这套流程需要的外部工具",依赖声明把二者绑在一起,缺失时按需补齐。 一个自带依赖、即插即用、缺啥提示装啥的能力包——这才是完整的"可插拔能力"。
〔配图 6:技能 ↔ MCP 依赖〕——左边一张技能卡
查工单并汇总,下方一条"依赖"连线连到右边一个 MCP server 卡工单系统。中间一个判断框:"技能被提及 → McpManager 报出已装清单 → 差集算 missing → 非空则提示安装(需审批·第 10 章)"。底部:"技能定义流程,MCP 提供外部工具,依赖声明把它们绑在一起。"
本章小结¶
- MCP 是 agent 工具的"USB-C 口":朴素做法是 M×N 套胶水,MCP 把它塌缩成 M+N——定一个协议,让工具"插上就能用"。它是 Tool Use / Function Calling 之下"工具从哪来、怎么连、怎么鉴权、怎么传输"的标准化层,且这层复杂性对模型透明。
- 客户端两个角色别记反:
McpManager(core/src/mcp.rs)是目录管理员——配置解析 + plugin 来源归属(provenance),不碰调用;真正的接线员是McpHandler(tools/handlers/mcp.rs),它给工具名加mcp__<server>__<tool>前缀汇入同一个ToolRouter,把调用转给handle_mcp_tool_call。 - 传输是 stdio / StreamableHttp(不是 uds):本地子进程走 stdio,远端 SaaS 走 HTTP + Bearer Token,且配置只存"环境变量名"不存密钥。
- 资源是三件套:
list_mcp_resources/list_mcp_resource_templates(RFC 6570 URI 模板,参数化资源)/read_mcp_resource——模型不只能"调工具做事",还能"读资源拿信息",规格里还用 "Prefer resources over web search" 做行为引导。 - 一次调用的四道关(
mcp_tool_call.rs):查审批模式 →(需要则)maybe_request_mcp_tool_approval问用户、拒了就不发包 →sess.call_tool经 rmcp 发包(请求加料 thread/sandbox、回包按模型能力sanitize)→ 失败若因未授权则反向 elicitation 弹授权链接、Accept后硬刷新工具缓存。外部副作用一律"发包前问、回包后裁"。 - 服务端:
codex mcp-server把 Codex 变成别的 agent 的一个工具(注意和管理子命令codex mcp区分)——agent 既能用工具,也能成为工具。 - 延迟加载(第四次现身):几百个 MCP 工具不全进菜单,分
mcp_tools/deferred_mcp_tools,后者标ToolExposure::Deferred——已注册但不上桌,靠tool_search按描述检索、命中后才取 schema。省的是工具说明书占的上下文,与 AGENTS.md / tool_search / skill 名录同一个母题。 - 完整生态闭环:connectors /
list_available_plugins_to_install/request_plugin_install负责发现与按需安装,mcp_skill_dependencies让技能声明依赖、缺失则提示装——而判断"缺没缺"正是回头去问McpManager的现有清单,整章首尾相扣。
到这里,模型内圈的几层——循环、上下文、工具、能力——都拆完了。从下一章起,我们进入"约束与安全":当 agent 能调这么多工具、能动你的文件和命令、还能自己请求装新东西时,怎么让它放手干又不闯祸。
参考来源¶
解剖标本(codex-rs 源码,HEAD ad2012d6)
core/src/mcp.rs—McpManager目录管理员core/src/tools/handlers/mcp.rs—McpHandler接线员、加前缀core/src/mcp_tool_call.rs—handle_mcp_tool_call调用往返、审批、征询core/src/tools/handlers/mcp_resource/— 资源三件套core/src/tools/handlers/mcp_resource/mcp_resource_spec.rs— 资源工具规格config/src/mcp_types.rs—McpServerTransportConfig(stdio / StreamableHttp)rmcp-clientcrate —codex-rmcp-clientrmcp 客户端core/src/tools/router.rs—mcp_tools/deferred_mcp_toolscore/src/tools/spec_plan.rs—ToolExposure::Deferred注册tools/src/tool_discovery.rs—TOOL_SEARCH_TOOL_NAME、DiscoverableToolcore/src/connectors.rs— 连接器发现tools/handlers/list_available_plugins_to_install.rs— 列可装插件tools/handlers/request_plugin_install.rs— 请求安装插件core/src/mcp_skill_dependencies.rs— 技能 MCP 依赖、collect_missing_mcp_dependenciesmcp-servercrate —codex-mcp-server服务端docs/codex_mcp_interface.md—codex mcpvscodex mcp-server
方法论 / 外部参考
- What is the Model Context Protocol (MCP)?(MCP 官方文档)
- Server Features · Resources(MCP 规范 2025-06-18)
- Code execution with MCP: building more efficient agents(Anthropic 工程博客)
- Writing effective tools for agents — with agents(Anthropic 工程博客)
注:MCP 子系统还涉及 OAuth 流程细节、
_meta协议字段、连接器更细的缓存策略等更多内容,本章聚焦"客户端/服务端/调用往返/延迟加载/技能依赖"主干;审批与征询的完整图景见第 10 章;类型名与工具名以 HEADad2012d6为准。