Skip to content

MCP-Tool处理器

从 MCP 工具命名、暴露与 readiness,到审批、RMCP 调用、结果清洗和生命周期事件,追踪 MCP-Tool 的真实源码链路。

基于rust-v0.150.0
CodexRustToolsMCP

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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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/ 目录运行:

bash
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 的业务行为仍需在对应测试环境中验证。