MCP-Tool处理器
MCP 工具调用不是“把 function call 转发给一个 server”这么简单。模型看到的是经过 namespace/name 归一化的 Responses schema;Core dispatch 需要用这个 canonical identity 找到当前 MCP catalog 中的 server/tool;调用前还要等待 server ready、读取审批策略、处理 Apps 或 Plugin 元数据;调用时要锁住 prepared catalog revision;调用后则分别生成模型输出、生命周期 item、Hook 输入和 telemetry。McpHandler 只是入口 adapter,真正的控制流在 mcp_tool_call.rs。
本文面向已经读过ToolSpec与函数规格、工具审批架构、ToolOutput与错误模型和ToolSearch工具的读者。本文只研究一个已经进入 registry 的 MCP tool 如何完成定位、审批、RMCP 调用和结果回灌,不展开 MCP resource、elicitation 的独立协议,也不把 Codex Apps、普通 MCP 和 Agent Plugin MCP 的策略混成一条路径。读完后,读者应能从 mcp__namespace__tool 反查到 server,定位审批为何发生或跳过,解释 catalog revision 为什么会拒绝调用,并区分 server error、用户拒绝和参数解析错误。
1. 名称与规格
1.1 ToolInfo身份
ToolInfo 同时保存三种名字:server_name 用于实际路由,callable_namespace/callable_name 用于模型可见名称,嵌套 tool.name 是传给 MCP server 的原始工具名。它们经常相同,但不能假设永远相同。
源码位置:codex-rs/codex-mcp/src/tools.rs :: ToolInfo
pub struct ToolInfo {
/// Raw MCP server name used for routing the tool call.
pub server_name: String,
pub supports_parallel_tool_calls: bool,
pub server_origin: Option<String>,
/// Model-visible tool name used in Responses API tool declarations.
pub callable_name: String,
/// Model-visible namespace used for deferred tool loading.
pub callable_namespace: String,
pub namespace_description: Option<String>,
/// Raw MCP tool definition; `tool.name` is sent back to the MCP server.
pub tool: rmcp::model::Tool,
pub openai_file_input_optional_fields: HashMap<String, Vec<String>>,
pub connector_id: Option<String>,
pub connector_name: Option<String>,
pub plugin_display_names: Vec<String>,
}
impl ToolInfo {
pub fn canonical_tool_name(&self) -> ToolName {
ToolName::namespaced(self.callable_namespace.clone(), self.callable_name.clone())
}
}canonical_tool_name() 是 registry key;server_name 和 tool.name 只在真正 RMCP 调用时使用。server_origin 进入 transport telemetry,supports_parallel_tool_calls 与 read-only annotation 共同决定并行能力,openai_file_input_optional_fields 则保留 schema 被模型路径掩码前的文件参数信息。排查“模型看见但调用不到”时,至少要同时核对 canonical identity、raw route 和 server origin。
1.2 名称归一
MCP 工具在进入模型前会 sanitize namespace/name,处理重复 namespace、重复 tool identity,并在必要时加 hash suffix;可选的 mcp__ 前缀由 server 配置决定。这个过程位于 MCP catalog,而非 McpHandler,所以 handler 接收到的 ToolInfo 已经是 model-callable 版本。
源码位置:codex-rs/codex-mcp/src/tools.rs :: normalize_tools_for_model_with_prefix
let callable_namespace = callable_namespace_with_prefix(
&sanitize_responses_api_tool_name(&tool.callable_namespace),
prefix_mcp_tool_names
&& !non_prefixed_mcp_tool_servers.contains(&tool.server_name),
);
candidates.push(CallableToolCandidate {
callable_namespace,
callable_name: sanitize_responses_api_tool_name(&tool.callable_name),
raw_namespace_identity,
raw_tool_identity,
tool,
});如果两个不同 raw server 归一后得到同一个 namespace,源码追加 namespace hash;如果同 namespace 下 tool 仍冲突,再追加 tool hash。这样 Responses 名称唯一,但 server routing identity 仍保留在 ToolInfo。
1.3 Handler规格
McpHandler::create_tool_spec 把 MCP function tool 包进一个 namespace。普通 MCP 使用 mcp_tool_to_responses_api_tool,Agent Plugin MCP 使用另一个转换器;namespace description 优先来自 connector/namespace metadata,最后截断到 512 KiB。MCP handler 的 namespace 不是 server transport 名称,而是模型 schema 的分组。
源码位置:codex-rs/core/src/tools/handlers/mcp.rs :: create_tool_spec
fn create_tool_spec(
tool_info: &ToolInfo,
agent_plugin: bool,
) -> Result<ToolSpec, serde_json::Error> {
let tool_name = tool_info.canonical_tool_name();
let tool = if agent_plugin {
agent_plugin_mcp_tool_to_responses_api_tool(&tool_name, &tool_info.tool)?
} else {
mcp_tool_to_responses_api_tool(&tool_name, &tool_info.tool)?
};
let description = tool_info
.namespace_description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
.or_else(|| {
tool_info
.connector_name
.as_deref()
.map(str::trim)
.filter(|connector_name| !connector_name.is_empty())
.map(|connector_name| format!("Tools for working with {connector_name}."))
})
.unwrap_or_default();
Ok(ToolSpec::Namespace(ResponsesApiNamespace {
name: tool_info.callable_namespace.clone(),
description: take_bytes_at_char_boundary(&description, MAX_MCP_NAMESPACE_DESCRIPTION_BYTES)
.to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(tool)],
}))
}2. 暴露与就绪
2.1 Catalog过滤
McpHandlerCache::append_mcp_tools 先确认当前 cache 是否仍属于同一个 McpBinding,再过滤 model-visible MCP tools,并按 Apps 开关和 connector policy 过滤 Codex Apps。如果 ToolSearch 可用,普通 MCP tools 默认以 Deferred 注册,否则 Direct;Agent Plugin MCP 还有单工具和总 schema 字节预算,超出后注册为 Hidden。
源码位置:codex-rs/core/src/mcp_tool_exposure.rs :: McpHandlerCache::append_mcp_tools
if !cached
.as_ref()
.and_then(|cached| cached.binding.upgrade())
.is_some_and(|cached_binding| Arc::ptr_eq(&cached_binding, binding))
{
*cached = None;
}
let cached = cached.get_or_insert_with(|| CachedMcpHandlers {
binding: Arc::downgrade(binding),
handlers: HashMap::new(),
});
append_mcp_tools(
binding.tools(),
config,
apps_enabled,
mcp_server_catalog,
search_tool_enabled,
&mut cached.handlers,
registry,
)同一 binding 的后续 sampling step 会按 canonical ToolName 复用 Arc<McpHandler>,但 Apps enablement、connector policy、search exposure 和 Plugin budget 每次重新计算。因此 cache 复用的是 handler/spec identity,不是上一 step 的可见性决策。“server 已连接”仍不等于“工具可见”。
2.2 Immutable规格
McpHandler 把 ToolSpec 保存为 Arc,通过 immutable_spec 暴露稳定 identity。ToolSearch cache 因此可以使用 Weak runtime identity,而不必每个 step 重新比较完整 schema。Code Mode definitions 也由 OnceLock 延迟构建并复用,同时移除 input/output schema,避免在 nested tool definition 中重复携带大 schema。
源码位置:codex-rs/core/src/tools/handlers/mcp.rs :: CoreToolRuntime for McpHandler
fn immutable_spec(&self) -> Option<&Arc<ToolSpec>> {
Some(&self.spec)
}
fn cached_code_mode_definitions(&self) -> Option<&[codex_code_mode::ToolDefinition]> {
Some(
self.code_mode_tool_definitions
.get_or_init(|| {
let mut definitions = codex_tools::collect_code_mode_tool_definitions(
std::iter::once(self.spec.as_ref()),
);
for definition in &mut definitions {
definition.input_schema = None;
definition.output_schema = None;
}
definitions
})
.as_slice(),
)
}handler 还通过 mcp_server_name() 明确声明所属 raw server,供 registry、telemetry 和 MCP-specific owner 查询使用。canonical namespace 不能替代这个字段,因为同一个模型 namespace 与 transport server identity 不是一回事。
2.3 Readiness
MCP runtime 实现 wait_until_ready。Tool orchestrator 在执行 gate 前调用 Session::wait_for_mcp_server,它先刷新 dirty runtime,再等待指定 server startup。handler 不会在 MCP client 尚未 ready 时直接发 RMCP call。
源码位置:codex-rs/core/src/tools/handlers/mcp.rs :: CoreToolRuntime::wait_until_ready
fn wait_until_ready<'a>(&'a self, session: &'a Arc<Session>) -> Option<BoxFuture<'a, ()>> {
Some(Box::pin(async move {
session
.wait_for_mcp_server(&self.tool_info.server_name)
.await;
}))
}源码位置:codex-rs/core/src/session/mcp_runtime.rs :: Session::wait_for_mcp_server
pub(crate) async fn wait_for_mcp_server(self: &Arc<Self>, server: &str) {
self.refresh_mcp_if_dirty().await;
self.services
.mcp_runtime
.wait_for_server_startup(server)
.await;
}2.4 PreparedCall
真正执行前,prepare_mcp_call 从当前 MCP binding 获取 server/tool 的 PreparedMcpCall。它捕获 client、config、catalog revision、tool metadata、server environment 和 plugin 标记;之后调用必须使用这份对象,不能重新从全局 catalog 查 client。
源码位置:codex-rs/core/src/session/mcp_runtime.rs :: Session::prepare_mcp_call
pub(crate) async fn prepare_mcp_call(
self: &Arc<Self>,
server: &str,
tool: &str,
) -> Option<PreparedMcpCall> {
self.refresh_mcp_if_dirty().await;
self.services
.mcp_runtime
.current_binding_for_call(server)
.await?
.prepare_call(server, tool)
}四层对象的所有权关系如下。ToolInfo 是 catalog 中的静态工具身份;McpHandler 持有它并进入 registry;每次调用重新取得 PreparedMcpCall,锁定当次 client 与 catalog authority;完成后 McpToolOutput 才负责面向不同消费者投影结果。
3. Handler入口
3.1 Payload与输入
McpHandler::handle_call 只接受 Function payload,记录当前 turn 的 model capability,然后把完整 ToolInfo、routed tool name、hook name、取消 token 和原始 JSON 参数交给 handle_mcp_tool_call。参数解析和审批不在 handler 中重复实现。
源码位置:codex-rs/core/src/tools/handlers/mcp.rs :: McpHandler::handle_call
let ToolInvocation {
session,
step_context,
cancellation_token,
call_id,
tool_name,
payload,
..
} = invocation;
let turn = Arc::clone(&step_context.turn);
let payload = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"mcp handler received unsupported payload".to_string(),
));
}
};
let result = handle_mcp_tool_call(
Arc::clone(&session),
&step_context,
&cancellation_token,
call_id.clone(),
&self.tool_info,
self.hook_tool_name(),
tool_name,
payload,
)
.await;完整 ToolInfo 同时提供 raw route 与 trusted connector/plugin metadata;routed tool_name 则让集中审批使用实际 registry identity。cancellation_token 进入审批上下文,transport 调用仍通过外层 tool future 的取消传播。
3.2 Hook名称
MCP hook 名称使用 mcp__ 前缀和 canonical namespace/tool,而不是裸的 tool name。这样 MCP 的 exec_command 不会与内置 exec_command 的 hook matcher 混淆。
源码位置:codex-rs/core/src/tools/handlers/mcp.rs :: join_tool_name
fn join_tool_name(tool_name: &ToolName) -> String {
match tool_name.namespace.as_deref() {
Some(namespace) => {
let namespace = namespace.trim_end_matches('_');
let name = tool_name.name.trim_start_matches('_');
format!("{namespace}__{name}")
}
None => tool_name.name.clone(),
}
}
fn ensure_mcp_prefix(name: &str) -> String {
if name.starts_with("mcp__") {
name.to_string()
} else {
format!("mcp__{name}")
}
}3.3 不可用路径
如果 prepare_mcp_call 找不到当前 server/tool,Core 不会 panic,也不会尝试旧 client;它生成一个 skip lifecycle item,并把 MCP tool ... is not available to the model 作为 CallToolResult 返回给模型。模型可以据此重新选择工具。
源码位置:codex-rs/core/src/mcp_tool_call.rs :: handle_mcp_tool_call
let Some(prepared_call) = sess.prepare_mcp_call(&server, &tool_name).await else {
let result = notify_mcp_tool_call_skip(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation,
item_metadata,
format!("MCP tool `{server}/{tool_name}` is not available to the model"),
/*already_started*/ false,
)
.await;
return HandledMcpToolCall {
result: CallToolResult::from_result(result),
tool_input: arguments_value
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())),
};
};4. 审批决策
4.1 Metadata与策略
prepared call 提供 metadata 后,Core 计算 MCP tool approval mode。Codex Apps 使用 AppToolPolicyEvaluator,普通 MCP 使用 server config 的 tool approval mode;selected Plugin server 还会选择 plugin-specific policy。
源码位置:codex-rs/core/src/mcp_tool_call.rs :: handle_mcp_tool_call
let metadata = mcp_tool_metadata(&prepared_call);
let runtime_config = prepared_call.config();
let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME {
let annotations = metadata.annotations.as_ref();
AppToolPolicyEvaluator::new(&runtime_config.config_layer_stack).policy(
AppToolPolicyInput {
connector_id: metadata.connector_id.as_deref(),
tool_name: &tool_name,
tool_title: metadata.tool_title.as_deref(),
destructive_hint: annotations.and_then(|a| a.destructive_hint),
open_world_hint: annotations.and_then(|a| a.open_world_hint),
},
)
} else {
AppToolPolicy::default()
};
let approval_mode = if server == CODEX_APPS_MCP_SERVER_NAME {
app_tool_policy.approval
} else {
prepared_call.tool_approval_mode()
};4.2 自动跳过
审批函数先检查全权限/自动批准上下文,再检查 MCP annotations 是否真的要求 approval。read-only 且没有 destructive/open-world hint 的工具可以直接继续;这不是“所有 GET 工具自动批准”,而是当前 annotation 和 policy 的组合结果。
源码位置:codex-rs/core/src/mcp_tool_call.rs :: maybe_request_mcp_tool_approval
if !strict_auto_review
&& mcp_permission_prompt_is_auto_approved(
config.approval_policy.value(),
&config.permission_profile,
McpPermissionPromptAutoApproveContext {
tool_approval_mode: Some(policy.mode),
},
)
{
return None;
}
let annotations = metadata.annotations.as_ref();
if !strict_auto_review && !requires_mcp_tool_approval_for_mode(annotations, policy.mode) {
return None;
}4.3 集中审批
需要审批时,MCP 不再自行串联 Hook、Guardian 和用户 UI,而是构造统一的 ApprovalAction::McpToolCall 与 ApprovalContext,交给 Session::request_approval。action 保存 raw server/tool、connector metadata、annotations、hook name 和持久化能力;context 保存 routed tool name、review context、strict-auto-review 标志与取消 token。
源码位置:codex-rs/core/src/mcp_tool_call.rs :: maybe_request_mcp_tool_approval
let action = ApprovalAction::McpToolCall {
id: call_id.to_string(),
server: invocation.server.clone(),
tool_name: invocation.tool.clone(),
arguments: invocation.arguments.clone(),
connector_id: metadata.connector_id.clone(),
connector_name: metadata.connector_name.clone(),
annotations: metadata.annotations.as_ref().map(/* ... */),
hook_tool_name: hook_tool_name.clone(),
approval_policy: config.approval_policy.value(),
reviewer: approvals_reviewer,
approval_mode: policy.mode,
allow_session_remember: session_approval_key.is_some(),
allow_persistent_approval: persistent_approval_key.is_some(),
};
let approval_context = ApprovalContext {
review_context: GuardianReviewContext::from(step_context),
cancellation_token: Some(cancellation_token.clone()),
call_id: call_id.to_string(),
tool_name: invocation_tool_name.clone(),
strict_auto_review,
/* ... */
};strict_auto_review 会绕过 full-access、annotation skip 和 remembered approval,强制进入统一审查。普通路径仍可先命中 session remember;但 reviewer、PermissionRequest Hook 和 Guardian 的具体顺序由 centralized approval owner 决定,不应再从 MCP 文件单独推导。
4.4 用户呈现
当集中审批最终选择用户 reviewer 时,request_mcp_tool_user_approval 再决定使用 MCP elicitation/create 还是 blocking request_user_input。ToolCallMcpElicitation feature 开启时,request id 与 approval metadata 随 elicitation 传递;关闭时回退到 overlay。两条路径最后都归一为 ReviewDecision。
源码位置:codex-rs/core/src/mcp_tool_call.rs :: request_mcp_tool_user_approval
if tool_call_mcp_elicitation_enabled {
let request = build_mcp_tool_approval_elicitation_request(/* ... */);
let decision = parse_mcp_tool_approval_elicitation_response(
sess.request_mcp_server_elicitation(
turn_context,
server.clone(),
request_id,
request,
)
.await
.response,
&question_id,
);
return normalize_approval_decision_for_mode(decision, *approval_mode);
}
let response = sess
.request_user_input(turn_context, call_id.to_string(), args)
.await;用户拒绝、超时和取消都走 skip completed item,不进入 RMCP transport;因此“审批失败”不是 server error。取消 token 使 pending centralized approval 能随 tool call 终止,而不是留下独立 waiter。
5. 调用执行
5.1 Pending元数据
在 started item 发出前,Session 保存 call id 对应的 McpToolApprovalMetadata,供后续 Guardian、审批响应和 item metadata 使用。这个 map 不负责等待 server response;RMCP client future 由 PreparedMcpCall 管理。
源码位置:codex-rs/core/src/mcp_tool_call.rs :: handle_mcp_tool_call
sess.register_mcp_tool_approval_metadata(
turn_context,
&call_id,
&invocation,
metadata.clone(),
)
.await;
notify_mcp_tool_call_started(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation.clone(),
item_metadata.clone(),
)
.await;5.2 Approval后调用
handle_approved_mcp_tool_call 在 prepared call 的 catalog lease 内应用审批决策、标记可能污染 thread memory 的 MCP server、按 connector/action 构造 hosted upload context、重写 OpenAI file 参数,再补 turn/thread/sandbox metadata 后调用 RMCP。tool_input 会更新为实际重写后的参数,供 PostToolUse 观察真实输入。
源码位置:codex-rs/core/src/mcp_tool_call.rs :: handle_approved_mcp_tool_call
let result = prepared_call
.call_with_preparation(/*requested_timeout*/ None, || async {
if let McpToolApprovalApplication::Apply { decision, policy } =
&approval_application
{
apply_mcp_tool_approval_decision(/* ... */).await;
}
maybe_mark_thread_memory_mode_polluted(sess, turn_context, &prepared_call).await;
let hosted_upload = item_metadata
.connector_id
.as_ref()
.zip(item_metadata.action_name.as_ref())
.map(|(connector_id, action_name)| HostedFileUploadContext {
connector_id: connector_id.clone(),
action_name: action_name.clone(),
model: turn_context.model_info.slug.clone(),
});
let rewritten_arguments = rewrite_mcp_tool_arguments_for_openai_files(
sess,
step_context,
arguments_value,
metadata.openai_file_input_optional_fields.as_ref(),
hosted_upload.as_ref(),
)
.await
.map_err(anyhow::Error::msg)?;
let request_meta = build_mcp_tool_call_request_meta(
turn_context,
&server,
call_id,
Some(&metadata),
);
let request_meta = with_mcp_tool_call_thread_id_meta(
request_meta,
&sess.thread_id.to_string(),
);
let request_meta = augment_mcp_tool_request_meta_with_sandbox_state(
step_context,
&prepared_call,
request_meta,
)
.await?;
Ok((rewritten_arguments, request_meta))
})
.await
.map_err(|error| format!("tool call error: {error:?}"))?;5.3 Revision屏障
PreparedMcpCall::call_with_preparation 读取 catalog revision;如果 MCP runtime 在 prepare 和 call 之间刷新,调用直接拒绝。这样旧 client、旧 tool metadata 和新 catalog 不会交叉使用。
源码位置:codex-rs/codex-mcp/src/binding.rs :: PreparedMcpCall::call_with_preparation
let current_revision = self.catalog_revision_source.read().await;
if *current_revision != self.catalog_revision {
return Err(anyhow::anyhow!(
"tool call rejected because the catalog changed after `{}/{tool_name}` was prepared",
self.server_name
));
}
let (arguments, meta) = prepare().await?;
let result = self
.client
.client
.call_tool(
tool_name.clone(),
arguments,
meta,
requested_timeout.or(self.client.tool_timeout),
)
.await
.with_context(|| format!("tool call failed for `{}/{tool_name}`", self.server_name))?;6. 生命周期与结果
6.1 调用事件
started item 保存 server、raw tool、arguments、Apps/plugin identity、read-only hint;completed item 根据 CallToolResult.is_error 或 transport error 选择 Completed/Failed,并记录 duration。大结果会在 event projection 截断,但模型输出使用另一套 truncation policy。
源码位置:codex-rs/core/src/mcp_tool_call.rs :: notify_mcp_tool_call_started
let item = TurnItem::McpToolCall(McpToolCallItem {
id: call_id.to_string(),
server,
tool,
arguments: arguments.unwrap_or(JsonValue::Null),
connector_id: item_metadata.connector_id,
mcp_app_resource_uri: item_metadata.mcp_app_resource_uri,
link_id: item_metadata.link_id,
app_name: item_metadata.app_name,
action_name: item_metadata.action_name,
plugin_id: item_metadata.plugin_id,
read_only_hint: item_metadata.read_only_hint,
status: McpToolCallStatus::InProgress,
result: None,
error: None,
duration: None,
});
sess.emit_turn_item_started(turn_context, &item).await;6.2 模型投影
McpToolOutput 保存原始 CallToolResult、实际 tool_input、wall time、原图能力和 truncation policy。模型输出加入 wall-time header,清理模型不支持的原图 detail,并按 policy 截断;Code Mode 则读取原始 result 的专用转换。
源码位置:codex-rs/core/src/tools/context.rs :: McpToolOutput::response_payload
fn response_payload(&self) -> FunctionCallOutputPayload {
let mut payload = self.result.as_function_call_output_payload();
if let Some(items) = payload.content_items_mut() {
sanitize_original_image_detail(self.original_image_detail_supported, items);
}
let wall_time_seconds = self.wall_time.as_secs_f64();
let header = format!("Wall time: {wall_time_seconds:.4} seconds\nOutput:");
match &mut payload.body {
FunctionCallOutputBody::Text(text) => {
*text = if text.is_empty() {
header
} else {
format!("{header}\n{text}")
};
}
FunctionCallOutputBody::ContentItems(items) => {
items.insert(0, FunctionCallOutputContentItem::InputText { text: header });
}
}
truncate_function_output_payload(&payload, self.truncation_policy * 1.2)
}6.3 Hook与Code Mode
MCP Hook 输入使用解析后的 JSON 参数,Hook 名称使用 mcp__ 前缀;PostToolUse response 是完整 CallToolResult JSON。Code Mode 的 result 会删除 MCP _meta 私有字段,不能把模型投影和脚本投影混为一个 payload。
源码位置:codex-rs/core/src/tools/handlers/mcp.rs :: CoreToolRuntime for McpHandler
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
let ToolPayload::Function { arguments } = &invocation.payload else {
return None;
};
Some(PreToolUsePayload {
tool_name: self.hook_tool_name(),
tool_input: mcp_hook_tool_input(arguments),
})
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
result: &dyn ToolOutput,
) -> Option<PostToolUsePayload> {
let ToolPayload::Function { .. } = &invocation.payload else {
return None;
};
Some(PostToolUsePayload {
tool_name: self.hook_tool_name(),
tool_use_id: invocation.call_id.clone(),
tool_input: result.post_tool_use_input(&invocation.payload)?,
tool_response: result.post_tool_use_response(
&invocation.call_id,
&invocation.payload,
)?,
})
}6.4 Accepted结果
MCP runtime 覆盖 on_tool_result_accepted。这个 callback 只在 handler 正常返回、PostToolUse 没有 block 之后运行;它不是普通日志钩子,而是 Node/CUA REPL 结果进入 Guardian 审查上下文的收集边界。只有 Code Mode 调用、node-repl-backed server、成功输出,以及 transcript 或 image capture 已启用时才继续。
源码位置:codex-rs/core/src/tools/handlers/mcp.rs :: McpHandler::on_tool_result_accepted
let ToolCallSource::CodeMode { cell_id, .. } = &invocation.source else {
return;
};
let evidence_mode = node_repl_review_evidence_mode(&invocation.turn);
let image_capture_enabled = invocation
.session
.services
.thread_extension_data
.get::<NodeReplReviewEvidence>()
.is_some_and(|evidence| evidence.image_capture_enabled());
if !is_node_repl_backed_server(&self.tool_info.server_name)
|| !result.success_for_logging()
|| evidence_mode == NodeReplReviewEvidenceMode::Disabled && !image_capture_enabled
{
return;
}源码中的 collector 从 Code Mode result 的 content 中提取非空文本和有效 inline image,跳过带 codex/encryptedContent=true 的条目,并以 MAX_RETAINED_BYTES 限制图片总量。如果没有文本且没有 encrypted content,才把非空 structuredContent 序列化为文本 fallback。最终记录以 raw server.tool、cell id 和 call id 建立关联。
这条顺序很关键:PostToolUse block 的结果不会进入审查记录;失败调用、Direct 调用、普通 MCP server 和无效图片也不会写入 thread-scoped record。模型输出、Code Mode result 和 Guardian 审查输入是三个不同消费者。
当前集成运行没有验证这条收集路径。截图测试连续两次都在最终断言发现记录为空;文本测试的 32 个实例则在预期 2 次模型请求、实际 0 次时提前失败。因此本节只把上述条件作为当前源码设计解释,不能把 Node/CUA REPL 的端到端收集描述为已通过。
7. 失败分支
7.1 参数错误
空 arguments 会被视为 None,传给 MCP client 的是无参数调用;非空但非法 JSON 会立即生成 CallToolResult::from_error_text,不会创建 prepared call,也不会发送 RMCP request。它是模型调用格式错误,不是 server failure。
7.2 审批拒绝
用户 decline/cancel、PermissionRequest hook deny 或 Guardian deny 都走 notify_mcp_tool_call_skip。如果调用已经发出 started item,skip 会补 completed failed item;RMCP server 不会收到调用。
源码位置:codex-rs/core/src/mcp_tool_call.rs :: notify_mcp_tool_call_skip
async fn notify_mcp_tool_call_skip(
sess: &Session,
turn_context: &TurnContext,
call_id: &str,
invocation: McpInvocation,
item_metadata: McpToolCallItemMetadata,
message: String,
already_started: bool,
) -> Result<CallToolResult, String> {
if !already_started {
notify_mcp_tool_call_started(
sess,
turn_context,
call_id,
invocation.clone(),
item_metadata.clone(),
)
.await;
}
notify_mcp_tool_call_completed(
sess,
turn_context,
call_id,
invocation,
item_metadata,
Duration::ZERO,
truncate_mcp_tool_result_for_event(&Err(message.clone())),
)
.await;
Err(message)
}7.3 Catalog与传输
catalog revision 变化、server startup failure、timeout、RMCP protocol error 和 server is_error=true 都进入 failed completed item。它们的区别在于:有些没有 CallToolResult,有些有带 is_error 的原始 result;McpToolOutput 和 event item 会保留不同程度的错误细节。
7.4 媒体降级
MCP result 中的图片、音频会先按模型能力和 URL 规则清洗。比如 text-only model 不能收到 image content;远程资源不会被当成可信的 inline input。清洗后的 result 仍可能是成功调用,但模型看到的是替换后的文本或安全内容,不应把“模型媒体被替换”写成 server call 失败。
8. 源码练习
先复述主线:ToolInfo canonical identity → binding-scoped handler cache → exposure/catalog filter → McpHandler readiness/payload adapter → prepare_mcp_call → centralized approval → started item → catalog revision + metadata preparation → RMCP call → completed item → McpToolOutput 的 Model/Hook/Code Mode 投影 → accepted Node REPL 审查记录。
再做三个只读验证:
- 找到
stdio_mcp_tool_call_includes_sandbox_state_meta,指出 sandbox state 是在 prepared call 已建立后、RMCP request 发出前哪一层加入的; - 找到
mcp_tool_call_output_exceeds_limit_truncated_for_model,说明为什么模型输出被截断不等于原始CallToolResult丢失,并指出 Code Mode 和 PostToolUse 各自读取哪种结果; - 比较
cached_app_handlers_still_obey_current_apps_enablement_and_tool_policy与mcp_code_mode_definitions_are_cached_lazily,解释 handler identity、当前可见性策略和 Code Mode definition cache 为什么是三层不同状态。
在 Codex 源码仓库的 codex-rs/ 目录运行:
rg -n "McpHandler|handle_mcp_tool_call|PreparedMcpCall|maybe_request_mcp_tool_approval|McpToolOutput" core codex-mcp
RUST_MIN_STACK=8388608 cargo test -p codex-core --lib 'mcp_tool_call_tests::' -- --test-threads=1
RUST_MIN_STACK=8388608 cargo test -p codex-core --lib 'tools::handlers::mcp::tests' -- --test-threads=1
RUST_MIN_STACK=8388608 cargo test -p codex-core stdio_mcp_tool_call_includes_sandbox_state_meta -- --test-threads=1
RUST_MIN_STACK=8388608 cargo test -p codex-core mcp_tool_call_output_exceeds_limit_truncated_for_model -- --test-threads=1前两组测试覆盖审批、metadata、媒体清洗、handler cache、Hook 和 telemetry 边界;后两个集成测试分别验证 request metadata 与模型截断。真实 OAuth、远程 server 和具体 connector 的业务行为仍需在对应测试环境中验证。
