From ac985376787fcf69a9dcd4ef43c4eb1752f230d0 Mon Sep 17 00:00:00 2001 From: peilin-openai Date: Mon, 21 Sep 2026 18:07:52 +0000 Subject: [PATCH] Track cumulative MCP attribution across requests and thread history (#47081) ## What changed - Record each MCP source and its first turn, including connector and plugin identifiers when available. Cover direct and Code Mode calls, including returned errors and results that are not printed. - Persist cumulative attribution checkpoints and restore them across compaction, resume, and forks, including forks limited to recent turns. - Attach fresh attribution to Responses API `client_metadata` under `mcp_attribution`, outside model-visible content. Restrict transmission to allowed OpenAI HTTPS destinations for both HTTP and WebSocket requests. - Report `attribution_error` for legacy history without checkpoints, malformed or conflicting attribution, and serialized attribution exceeding 16 KiB. ## Testing Add coverage for source deduplication, checkpoint persistence and restoration, recent-turn forks, direct and Code Mode calls, destination filtering, and the serialized size limit. GitOrigin-RevId: fd285e152ca9e24bfa000debb20013709d67a86e --- .../app-server-exports-stable.json.zst | Bin 152177 -> 152456 bytes codex-rs/core/src/agent/control_tests.rs | 189 +++++++++++++++++- codex-rs/core/src/client.rs | 63 +++--- codex-rs/core/src/client_tests.rs | 106 ++++++++-- codex-rs/core/src/compact.rs | 12 +- codex-rs/core/src/guardian/input_budget.rs | 1 + codex-rs/core/src/guardian/request_budget.rs | 1 + codex-rs/core/src/mcp_tool_call.rs | 15 ++ codex-rs/core/src/responses_metadata.rs | 16 +- codex-rs/core/src/session/inject_tests.rs | 9 +- codex-rs/core/src/session/mod.rs | 57 +++++- codex-rs/core/src/session/session.rs | 1 + codex-rs/core/src/session/tests.rs | 159 ++++++++++++++- codex-rs/core/src/session/turn.rs | 12 +- codex-rs/core/src/test_support.rs | 12 ++ .../core/src/tools/executed_tool_calls.rs | 21 ++ .../executed_tool_calls/mcp_attribution.rs | 169 ++++++++++++++++ .../mcp_attribution_tests.rs | 129 ++++++++++++ codex-rs/core/src/turn_metadata_tests.rs | 4 +- codex-rs/core/tests/suite/code_mode.rs | 35 ++++ .../core/tests/suite/mcp_turn_metadata.rs | 22 ++ codex-rs/core/tests/suite/rmcp_client.rs | 19 +- codex-rs/history/src/lib.rs | 25 +++ codex-rs/history/src/tests.rs | 26 +++ codex-rs/protocol/src/mcp.rs | 36 ++++ codex-rs/rollout/src/lib.rs | 1 + codex-rs/utils/string/src/json.rs | 51 ++++- codex-rs/utils/string/src/lib.rs | 1 + 28 files changed, 1119 insertions(+), 73 deletions(-) create mode 100644 codex-rs/core/src/tools/executed_tool_calls/mcp_attribution.rs create mode 100644 codex-rs/core/src/tools/executed_tool_calls/mcp_attribution_tests.rs diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 66e60eba98c3aa56536b046e5ac6dcedcf5947a9..79241e9314ce9d9d52012d00e0ddc875ed78f4d0 100644 GIT binary patch delta 20420 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, message: &str .expect("test thread rollout should flush"); } +/// Builds a request entirely in process; it does not install a transport or send anything. +/// The destination filtering behavior is covered separately in `client_tests`. +async fn mcp_attribution_in_constructed_request(thread: &CodexThread) -> McpAttribution { + let turn_context = thread.session.new_default_turn().await; + let step_context = StepContext::for_test(Arc::clone(&turn_context)); + let prompt = crate::session::turn::build_prompt( + thread + .session + .clone_history() + .await + .for_prompt(&step_context.settings.model_info.input_modalities), + &step_context, + thread.session.get_prompt_base_instructions().await, + ); + let metadata = thread + .session + .responses_metadata(&step_context, CodexResponsesRequestKind::Turn) + .await; + let request = thread + .session + .services + .model_client + .build_responses_request( + &prompt, + &step_context.settings.model_info, + /*effort*/ None, + ReasoningSummary::None, + /*service_tier*/ None, + &metadata, + /*include_internal*/ true, + ) + .expect("construct request"); + serde_json::from_str( + request + .client_metadata + .as_ref() + .and_then(|metadata| metadata.get("mcp_attribution")) + .expect("request MCP attribution"), + ) + .expect("valid request MCP attribution") +} + async fn wait_for_live_thread_spawn_children( control: &LocalAgentControl, parent_thread_id: ThreadId, @@ -3009,6 +3058,103 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc } } +#[tokio::test] +async fn resume_and_cold_fork_restore_mcp_attribution_in_constructed_requests() { + let harness = AgentControlHarness::new().await; + let (source_thread_id, source_thread) = harness.start_thread().await; + let turn_context = source_thread.session.new_default_turn().await; + source_thread + .session + .record_conversation_items( + turn_context.as_ref(), + turn_context.model_info(), + &[user_message("source turn")], + ) + .await; + let source = McpAttributionSource { + connector_id: None, + plugin_id: Some("messages@openai-bundled".to_string()), + server_name: "messages".to_string(), + tool_name: "search".to_string(), + first_turn_id: turn_context.sub_id.clone(), + }; + source_thread + .session + .services + .executed_tool_calls + .record_mcp_source(source.clone()); + source_thread + .session + .record_conversation_items( + turn_context.as_ref(), + turn_context.model_info(), + &[assistant_message("source result", /*phase*/ None)], + ) + .await; + source_thread.ensure_rollout_materialized().await; + source_thread + .flush_rollout() + .await + .expect("flush source rollout"); + let rollout_path = source_thread.rollout_path().expect("source rollout path"); + source_thread + .shutdown_and_wait() + .await + .expect("shutdown source thread"); + assert!( + harness + .manager + .remove_thread(&source_thread_id) + .await + .is_some() + ); + drop(source_thread); + + let forked = harness + .manager + .fork_thread( + ForkSnapshot::Interrupted, + StartThreadOptions::new(harness.config.clone()), + rollout_path.clone(), + ) + .await + .expect("fork unloaded source thread"); + let resumed = harness + .manager + .resume_thread_from_rollout( + harness.config.clone(), + rollout_path, + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")), + /*parent_trace*/ None, + ClientMcpExtensions::default(), + ) + .await + .expect("resume source thread"); + let expected = McpAttribution { + status: McpAttributionStatus::Complete, + sources: vec![source], + }; + assert_eq!( + mcp_attribution_in_constructed_request(&forked.thread).await, + expected + ); + assert_eq!( + mcp_attribution_in_constructed_request(&resumed.thread).await, + expected + ); + + forked + .thread + .shutdown_and_wait() + .await + .expect("shutdown forked thread"); + resumed + .thread + .shutdown_and_wait() + .await + .expect("shutdown resumed thread"); +} + #[tokio::test] async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { let harness = AgentControlHarness::new().await; @@ -3075,11 +3221,35 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { let harness = AgentControlHarness::new().await; let (parent_thread_id, parent_thread) = harness.start_thread().await; - + let old_turn_context = parent_thread.session.new_default_turn().await; parent_thread - .inject_response_items(vec![user_message("old parent context")]) - .await - .expect("inject old parent context"); + .session + .record_conversation_items( + old_turn_context.as_ref(), + old_turn_context.model_info(), + &[user_message("old parent context")], + ) + .await; + let source = McpAttributionSource { + connector_id: None, + plugin_id: None, + server_name: "example".to_string(), + tool_name: "search".to_string(), + first_turn_id: old_turn_context.sub_id.clone(), + }; + parent_thread + .session + .services + .executed_tool_calls + .record_mcp_source(source.clone()); + parent_thread + .session + .record_conversation_items( + old_turn_context.as_ref(), + old_turn_context.model_info(), + &[assistant_message("old MCP result", /*phase*/ None)], + ) + .await; let queued_communication = InterAgentCommunication::new( AgentPath::root(), AgentPath::try_from("/root/worker").expect("agent path"), @@ -3176,6 +3346,10 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { !history_contains_text(history.raw_items(), "old parent context"), "forked child history should drop parent context outside the requested last-N turn window" ); + assert!( + !history_contains_text(history.raw_items(), "old MCP result"), + "forked child history should drop the old result while retaining its attribution" + ); assert!( !history_contains_text(history.raw_items(), "queued message"), "forked child history should drop queued inter-agent messages outside the requested last-N turn window" @@ -3196,6 +3370,13 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { .is_none(), "last-N forked child should rebuild context after truncating the cached prefix" ); + assert_eq!( + mcp_attribution_in_constructed_request(&child_thread).await, + McpAttribution { + status: McpAttributionStatus::Complete, + sources: vec![source], + } + ); let _ = harness .control diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 1c5b49353a..bc5f59e479 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -405,6 +405,16 @@ fn response_items_equal_ignoring_internal_metadata( previous == current } +/// Whether the resolved outbound Responses destination may receive internal tool metadata. +fn is_internal_metadata_destination(provider: &ApiProvider) -> bool { + url::Url::parse(&provider.base_url).ok().is_some_and(|url| { + url.scheme() == "https" + && url.host_str().is_some_and(|host| { + host == "api.openai.com" || codex_http_client::is_allowed_chatgpt_host(host) + }) + }) +} + impl WebsocketSession { fn reset(&mut self, reason: Option<&'static str>) { // Per-socket backend metrics call a resend after reconnect "initial". @@ -802,9 +812,10 @@ impl ModelClient { fn build_ws_client_metadata( &self, responses_metadata: &CodexResponsesMetadata, + include_internal: bool, use_responses_lite: bool, ) -> HashMap { - let mut client_metadata = responses_metadata.client_metadata(); + let mut client_metadata = responses_metadata.client_metadata(include_internal); if use_responses_lite { client_metadata.insert( WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY.to_string(), @@ -866,6 +877,7 @@ impl ModelClient { } } + #[allow(clippy::too_many_arguments)] pub(crate) fn build_responses_request( &self, prompt: &Prompt, @@ -874,6 +886,7 @@ impl ModelClient { summary: ReasoningSummaryConfig, service_tier: Option, responses_metadata: &CodexResponsesMetadata, + include_internal: bool, ) -> Result { let mut input = prompt.get_formatted_input_for_request(model_info); if !self.reasoning_effort_override_enabled(model_info) { @@ -963,6 +976,12 @@ impl ModelClient { } else { model_info.service_tier_for_request(service_tier) }; + if !include_internal { + for item in &mut input { + item.clear_tool_result_metadata(); + } + } + let client_metadata = responses_metadata.client_metadata(include_internal); let request = ResponsesApiRequest { model: model_info.slug.clone(), instructions, @@ -978,32 +997,12 @@ impl ModelClient { service_tier, prompt_cache_key, text, - client_metadata: Some(responses_metadata.client_metadata()), + client_metadata: Some(client_metadata), access_programs: None, }; Ok(request) } - fn filter_tool_result_metadata(input: &mut [ResponseItem], api_provider: &ApiProvider) { - // Check the resolved destination only when sending, not for local budget estimates. - // HTTP and WS (including v2 compaction) share this raw-metadata-only filter. - let result_metadata_allowed = - url::Url::parse(&api_provider.base_url) - .ok() - .is_some_and(|url| { - url.scheme() == "https" - && url.host_str().is_some_and(|host| { - host == "api.openai.com" - || codex_http_client::is_allowed_chatgpt_host(host) - }) - }); - if !result_metadata_allowed { - for item in input { - item.clear_tool_result_metadata(); - } - } - } - fn prepare_response_items_for_request(&self, input: &mut [ResponseItem]) { for item in input { if item.id().is_some_and(|id| !id.is_prefixed()) { @@ -1629,6 +1628,7 @@ impl ModelClientSession { .client .current_client_setup(ClientRouting::Workspace) .await?; + let include_internal = is_internal_metadata_destination(&client_setup.api_provider); let responses_headers = self .client .responses_headers(client_setup.auth.as_ref(), &model_info.slug); @@ -1666,11 +1666,8 @@ impl ModelClientSession { summary, service_tier.clone(), responses_metadata, + include_internal, )?; - ModelClient::filter_tool_result_metadata( - &mut request.input, - &client_setup.api_provider, - ); self.client.set_guardian_metadata( &mut request.client_metadata, responses_metadata.parent_response_id.as_deref(), @@ -1811,6 +1808,7 @@ impl ModelClientSession { .client .current_client_setup(ClientRouting::Workspace) .await?; + let include_internal = is_internal_metadata_destination(&client_setup.api_provider); let responses_headers = self .client .responses_headers(client_setup.auth.as_ref(), &model_info.slug); @@ -1828,11 +1826,8 @@ impl ModelClientSession { summary, service_tier.clone(), responses_metadata, + include_internal, )?; - ModelClient::filter_tool_result_metadata( - &mut request.input, - &client_setup.api_provider, - ); let guardian_reviewer = responses_headers .get("x-codex-guardian") .is_some_and(|value| value == "reviewer"); @@ -1905,9 +1900,11 @@ impl ModelClientSession { { crate::guardian::observe_guardian_request(session_telemetry, &request); } - let mut client_metadata = self - .client - .build_ws_client_metadata(responses_metadata, model_info.use_responses_lite); + let mut client_metadata = self.client.build_ws_client_metadata( + responses_metadata, + include_internal, + model_info.use_responses_lite, + ); if let Some(turn_state) = self.turn_state.get() { client_metadata.insert(X_CODEX_TURN_STATE_HEADER.to_string(), turn_state.clone()); } diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 24b785a6fa..0f62891632 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -12,6 +12,8 @@ use crate::AttestationContext; use crate::AttestationProvider; use crate::GenerateAttestationFuture; use crate::responses_metadata::CodexResponsesMetadata; +use crate::responses_metadata::MAX_MCP_ATTRIBUTION_BYTES; +use crate::responses_metadata::MCP_ATTRIBUTION_CLIENT_METADATA_KEY; use crate::test_support::TestCodexResponsesRequestKind; use crate::test_support::responses_metadata as test_responses_metadata; use base64::Engine; @@ -42,6 +44,9 @@ use codex_protocol::ThreadId; use codex_protocol::auth::AuthMode; use codex_protocol::error::CodexErr; use codex_protocol::error::CodexErrorDetails; +use codex_protocol::mcp::McpAttribution; +use codex_protocol::mcp::McpAttributionSource; +use codex_protocol::mcp::McpAttributionStatus; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::ExecutedToolCall; @@ -479,7 +484,7 @@ fn output_with_tool_result_metadata(metadata: ToolResultMetadata) -> ResponseIte } #[test] -fn responses_request_limits_raw_tool_metadata_to_resolved_first_party_https_endpoint() +fn responses_request_limits_internal_metadata_to_resolved_first_party_https_endpoint() -> anyhow::Result<()> { let provider = ModelProviderInfo::create_openai_provider(Some("https://api.openai.com/v1".to_string())); @@ -492,17 +497,28 @@ fn responses_request_limits_raw_tool_metadata_to_resolved_first_party_https_endp "private": { "resource": "raw-result-metadata" }, }))); let without_raw_metadata = output_with_tool_result_metadata(ToolResultMetadata::default()); + let attribution = McpAttribution { + status: McpAttributionStatus::Complete, + sources: vec![McpAttributionSource { + connector_id: Some("connector_example".to_string()), + plugin_id: Some("example@openai-bundled".to_string()), + server_name: "codex_apps".to_string(), + tool_name: "search".to_string(), + first_turn_id: "turn_123".to_string(), + }], + }; let prompt = Prompt { input: vec![output.clone()], ..Default::default() }; - let responses_metadata = test_responses_metadata_for_client( + let mut responses_metadata = test_responses_metadata_for_client( &client, /*turn_id*/ None, format!("{}:0", client.state.thread_id), /*parent_thread_id*/ None, TestCodexResponsesRequestKind::Turn, ); + responses_metadata.mcp_attribution = Some(attribution.clone()); for (base_url, allowed) in [ ("https://api.openai.com/v1", true), ("https://chatgpt.com/backend-api/codex", true), @@ -515,18 +531,19 @@ fn responses_request_limits_raw_tool_metadata_to_resolved_first_party_https_endp ("not a URL", false), ] { api_provider.base_url = base_url.to_string(); + let include_internal = super::is_internal_metadata_destination(&api_provider); for responses_lite in [false, true] { let mut model = test_model_info(); model.use_responses_lite = responses_lite; - let mut request = client.build_responses_request( + let request = client.build_responses_request( &prompt, &model, /*effort*/ None, codex_protocol::config_types::ReasoningSummary::None, /*service_tier*/ None, &responses_metadata, + include_internal, )?; - ModelClient::filter_tool_result_metadata(&mut request.input, &api_provider); assert_eq!( request.input.last(), Some(if allowed { @@ -536,9 +553,58 @@ fn responses_request_limits_raw_tool_metadata_to_resolved_first_party_https_endp }), "resolved endpoint: {base_url}, responses_lite: {responses_lite}", ); + let expected_json = allowed + .then(|| serde_json::to_string(&attribution).map(serde_json::Value::String)) + .transpose()?; + assert_eq!( + serde_json::to_value(&request)? + .get("client_metadata") + .and_then(|metadata| metadata.get(MCP_ATTRIBUTION_CLIENT_METADATA_KEY)), + expected_json.as_ref(), + ); + let ws_client_metadata = client.build_ws_client_metadata( + &responses_metadata, + include_internal, + responses_lite, + ); + let ws_request = codex_api::ResponseCreateWsRequest { + client_metadata: Some(ws_client_metadata), + ..codex_api::ResponseCreateWsRequest::from(&request) + }; + assert_eq!( + serde_json::to_value(ws_request)? + .get("client_metadata") + .and_then(|metadata| metadata.get(MCP_ATTRIBUTION_CLIENT_METADATA_KEY)), + expected_json.as_ref(), + ); + assert!( + serde_json::to_value(&request)? + .get("mcp_attribution") + .is_none() + ); assert_eq!(prompt.input, vec![output.clone()]); } } + let mut oversized_attribution = attribution; + oversized_attribution.sources[0].tool_name = "a".repeat(MAX_MCP_ATTRIBUTION_BYTES); + responses_metadata.mcp_attribution = Some(oversized_attribution); + let request = client.build_responses_request( + &prompt, + &test_model_info(), + /*effort*/ None, + codex_protocol::config_types::ReasoningSummary::None, + /*service_tier*/ None, + &responses_metadata, + /*include_internal*/ true, + )?; + assert_eq!( + request.client_metadata.as_ref().and_then(|metadata| { + metadata + .get(MCP_ATTRIBUTION_CLIENT_METADATA_KEY) + .map(String::as_str) + }), + Some(r#"{"status":"attribution_error"}"#), + ); Ok(()) } @@ -615,7 +681,9 @@ fn websocket_incremental_reuse_tracks_raw_result_metadata() -> anyhow::Result<() }); previous_output.set_turn_id_if_missing("previous-turn"); current_output.set_turn_id_if_missing("current-turn"); - let mut previous = client.build_responses_request( + api_provider.base_url = base_url.to_string(); + let include_internal = super::is_internal_metadata_destination(&api_provider); + let previous = client.build_responses_request( &Prompt { input: vec![previous_output], ..Default::default() @@ -625,16 +693,24 @@ fn websocket_incremental_reuse_tracks_raw_result_metadata() -> anyhow::Result<() codex_protocol::config_types::ReasoningSummary::None, /*service_tier*/ None, &responses_metadata, + include_internal, )?; let follow_up = ResponseItem::from(ResponseInputItem::FunctionCallOutput { call_id: "wait-call".to_string(), output: FunctionCallOutputPayload::from_text("done".to_string()), }); - let mut current = previous.clone(); - current.input = vec![current_output, follow_up.clone()]; - api_provider.base_url = base_url.to_string(); - ModelClient::filter_tool_result_metadata(&mut previous.input, &api_provider); - ModelClient::filter_tool_result_metadata(&mut current.input, &api_provider); + let current = client.build_responses_request( + &Prompt { + input: vec![current_output, follow_up.clone()], + ..Default::default() + }, + &test_model_info(), + /*effort*/ None, + codex_protocol::config_types::ReasoningSummary::None, + /*service_tier*/ None, + &responses_metadata, + include_internal, + )?; let mut session = client.new_session(); session.websocket_session.last_request = Some(previous); @@ -760,6 +836,7 @@ fn responses_lite_prefix_ids_track_thread_and_payload() -> anyhow::Result<()> { /*parent_thread_id*/ None, TestCodexResponsesRequestKind::Turn, ), + /*include_internal*/ true, ) }; @@ -834,6 +911,7 @@ fn websocket_continuation_reset_reason_survives_failed_reconnect_and_turn_bounda /*parent_thread_id*/ None, TestCodexResponsesRequestKind::Turn, ), + /*include_internal*/ true, ) .expect("build continuation request"); let mut session = client.new_session(); @@ -880,6 +958,7 @@ fn reasoning_effort_in_request( /*parent_thread_id*/ None, TestCodexResponsesRequestKind::Turn, ), + /*include_internal*/ true, ) .expect("build responses request") .reasoning @@ -1173,8 +1252,11 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() { Some(parent_thread_id), TestCodexResponsesRequestKind::Turn, ); - let client_metadata = - client.build_ws_client_metadata(&responses_metadata, /*use_responses_lite*/ false); + let client_metadata = client.build_ws_client_metadata( + &responses_metadata, + /*include_internal*/ true, + /*use_responses_lite*/ false, + ); let parent_thread_id = parent_thread_id.to_string(); let turn_metadata: serde_json::Value = serde_json::from_str( client_metadata diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index e102da28b4..c1e18ba7eb 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -267,14 +267,9 @@ async fn run_compact_task_inner_impl( let max_retries = turn_context.provider.info().stream_max_retries(); let mut retries = 0; + // Reuse one client session so turn-scoped state (sticky routing and websocket incremental + // request tracking) survives retries within this compact turn. let mut client_session = sess.services.model_client.new_session(); - // Reuse one client session so turn-scoped state (sticky routing, websocket incremental - // request tracking) - // survives retries within this compact turn. - let responses_metadata = sess - .compaction_responses_metadata(turn_context.as_ref(), compaction_metadata) - .await; - let compaction_response = loop { // Clone is required because of the loop let mut turn_input = history @@ -289,6 +284,9 @@ async fn run_compact_task_inner_impl( base_instructions: sess.get_prompt_base_instructions().await, ..Default::default() }; + let responses_metadata = sess + .compaction_responses_metadata(turn_context.as_ref(), compaction_metadata) + .await; let attempt_result = drain_to_completed( &sess, turn_context.as_ref(), diff --git a/codex-rs/core/src/guardian/input_budget.rs b/codex-rs/core/src/guardian/input_budget.rs index 9514eb31e8..2fce16ebaa 100644 --- a/codex-rs/core/src/guardian/input_budget.rs +++ b/codex-rs/core/src/guardian/input_budget.rs @@ -100,6 +100,7 @@ pub(crate) async fn finalize( &session .responses_metadata(step, CodexResponsesRequestKind::Turn) .await, + /*include_internal*/ true, )?; let mut existing = super::request_budget::estimate_request_tokens(&request) .max(usize::try_from(session.get_total_token_usage().await).unwrap_or(usize::MAX)); diff --git a/codex-rs/core/src/guardian/request_budget.rs b/codex-rs/core/src/guardian/request_budget.rs index 0f9fa0e0fe..8de9ff59ea 100644 --- a/codex-rs/core/src/guardian/request_budget.rs +++ b/codex-rs/core/src/guardian/request_budget.rs @@ -70,6 +70,7 @@ pub(crate) fn check_prompt( codex_protocol::config_types::ReasoningSummary::None, /*service_tier*/ None, metadata, + /*include_internal*/ true, )?; if estimate_request_tokens(&request) > effective_input_token_limit(model, config.model_context_window) diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 9c32660abb..ad8f420b2d 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -595,6 +595,21 @@ async fn handle_approved_mcp_tool_call( if let Some(elicitation_type) = elicitation_type { track_mcp_tool_call_elicitation(sess, turn_context, call_id, elicitation_type); } + // Direct and Code Mode calls share this boundary. Once an approved call enters + // it, conservatively attribute returned errors too: they may contain peer data. + let source_connector_id = prepared_call + .is_host_owned_apps() + .then(|| prepared_call.tool_info().connector_id.clone()) + .flatten(); + sess.services.executed_tool_calls.record_mcp_source( + codex_protocol::mcp::McpAttributionSource { + connector_id: source_connector_id, + plugin_id: prepared_call.plugin_id().map(str::to_string), + server_name: prepared_call.server_name().to_string(), + tool_name: prepared_call.tool_info().tool.name.to_string(), + first_turn_id: turn_context.sub_id.clone(), + }, + ); notify_mcp_tool_call_completed( sess, turn_context, diff --git a/codex-rs/core/src/responses_metadata.rs b/codex-rs/core/src/responses_metadata.rs index fbd3661c3b..dcc6f5e407 100644 --- a/codex-rs/core/src/responses_metadata.rs +++ b/codex-rs/core/src/responses_metadata.rs @@ -8,10 +8,12 @@ use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; use codex_git_utils::SanitizedGitUrl; use codex_protocol::ThreadId; +use codex_protocol::mcp::McpAttribution; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::ThreadSource; use codex_utils_string::to_ascii_json_string; +use codex_utils_string::to_json_string_bounded; use http::HeaderMap as ApiHeaderMap; use http::HeaderValue; use serde::Serialize; @@ -40,6 +42,8 @@ pub(crate) const TOOL_NAMESPACES_INFO_KEY: &str = "tool_namespaces_info"; pub(crate) const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms"; pub(crate) const HISTORY_INGEST_REQUESTED_KEY: &str = "history_ingest_requested"; pub(crate) const ANALYTICS_ENABLED_KEY: &str = "analytics_enabled"; +pub(crate) const MCP_ATTRIBUTION_CLIENT_METADATA_KEY: &str = "mcp_attribution"; +pub(crate) const MAX_MCP_ATTRIBUTION_BYTES: usize = 16 * 1024; pub(crate) const FORKED_FROM_THREAD_ID_KEY: &str = "forked_from_thread_id"; pub(crate) const FORKED_FROM_ORDINAL_EXCLUSIVE_KEY: &str = "forked_from_ordinal_exclusive"; @@ -60,6 +64,7 @@ pub(crate) const WORKSPACES_KEY: &str = "workspaces"; // when submitting a turn, but they must not override fields owned by core. const RESERVED_METADATA_KEYS: &[&str] = &[ "guardian_credits_requested", + MCP_ATTRIBUTION_CLIENT_METADATA_KEY, INSTALLATION_ID_KEY, X_CODEX_INSTALLATION_ID_HEADER, SESSION_ID_KEY, @@ -95,6 +100,7 @@ const RESERVED_METADATA_KEYS: &[&str] = &[ NODE_REPL_DISABLED_KEY, WORKSPACES_KEY, ]; + // These keys were previously valid user configuration. Accept existing configs while filtering // their values before constructing Core-owned request metadata. const BACKWARD_COMPATIBLE_RESERVED_METADATA_KEYS: &[&str] = &[ @@ -255,6 +261,8 @@ pub struct CodexResponsesMetadata { /// Selected session analytics client's collection state, independent of event eligibility or delivery. /// Absent when the request has no initialized session analytics context. pub(crate) analytics_enabled: Option, + /// Cumulative MCP attribution for this logical request; body-only and model-invisible. + pub(crate) mcp_attribution: Option, pub(crate) extra: BTreeMap, } @@ -296,6 +304,7 @@ impl CodexResponsesMetadata { turn_started_at_unix_ms: None, history_ingest_requested: None, analytics_enabled: None, + mcp_attribution: None, extra: BTreeMap::new(), } } @@ -312,7 +321,7 @@ impl CodexResponsesMetadata { serde_json::to_value(self.turn_metadata_payload()).ok() } - pub(crate) fn client_metadata(&self) -> HashMap { + pub(crate) fn client_metadata(&self, include_internal: bool) -> HashMap { let mut client_metadata = HashMap::from([ ( X_CODEX_INSTALLATION_ID_HEADER.to_string(), @@ -348,6 +357,11 @@ impl CodexResponsesMetadata { { client_metadata.insert(X_CODEX_TURN_METADATA_HEADER.to_string(), turn_metadata_json); } + if include_internal && let Some(attribution) = &self.mcp_attribution { + let serialized = to_json_string_bounded(attribution, MAX_MCP_ATTRIBUTION_BYTES) + .unwrap_or_else(|_| r#"{"status":"attribution_error"}"#.to_string()); + client_metadata.insert(MCP_ATTRIBUTION_CLIENT_METADATA_KEY.to_string(), serialized); + } client_metadata } diff --git a/codex-rs/core/src/session/inject_tests.rs b/codex-rs/core/src/session/inject_tests.rs index 92446c1d0e..b8a67a986a 100644 --- a/codex-rs/core/src/session/inject_tests.rs +++ b/codex-rs/core/src/session/inject_tests.rs @@ -14,7 +14,7 @@ async fn harness_authored_configuration_updates_preserve_metadata_and_resume() { let (session, turn_context, rx_event) = make_session_and_context_with_rx().await; assert!(!session.enabled(Feature::RetainClientDeveloperMessages)); - let expected = ResponseItemEnvelope { + let mut expected = ResponseItemEnvelope { item: ResponseItem::ConfigurationUpdate { reasoning: ConfigurationReasoning { effort: ReasoningEffort::High, @@ -33,6 +33,13 @@ async fn harness_authored_configuration_updates_preserve_metadata_and_resume() { ) .await; + expected.metadata.as_mut().unwrap().mcp_attribution = Some( + session + .services + .executed_tool_calls + .mcp_attribution_snapshot(), + ); + let recorded = session.clone_history().await.into_annotated_items(); assert_eq!(recorded, vec![expected.clone()]); let mut raw_items = Vec::new(); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index a4b4ba0032..ac7a8d431d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -158,6 +158,7 @@ use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; +use codex_rollout::should_persist_response_item; use codex_rollout::state_db; use codex_rollout_trace::ThreadStartedTraceMetadata; use codex_rollout_trace::ThreadTraceContext; @@ -3554,6 +3555,32 @@ impl Session { .get_or_insert_with(|| with_serialization_allowance(policy).token_budget()); } } + // Last-N-turn forks retain a suffix starting at a user turn boundary. Repeat the + // cumulative checkpoint there so the suffix remains self-contained even when the fork + // happens mid-turn; dirty checkpoints preserve new sources between boundaries. + let force_mcp_checkpoint = items + .iter() + .any(|envelope| crate::context_manager::is_user_turn_boundary(&envelope.item)); + let mcp_revision = self + .services + .executed_tool_calls + .mcp_attribution_checkpoint(force_mcp_checkpoint) + .and_then(|(attribution, revision)| { + let first_persisted = items + .iter() + .position(|envelope| should_persist_response_item(&envelope.item))?; + for (index, envelope) in items.iter_mut().enumerate() { + // Rollout batches may be partially written. Checkpoint the first persisted + // item, and repeat the checkpoint at turn boundaries retained by forks. + if index == first_persisted + || crate::context_manager::is_user_turn_boundary(&envelope.item) + { + envelope.metadata.get_or_insert_default().mcp_attribution = + Some(attribution.clone()); + } + } + Some(revision) + }); let response_items = items .iter() .map(|envelope| envelope.item.clone()) @@ -3593,7 +3620,13 @@ impl Session { } let rollout_items: Vec = items.into_iter().map(RolloutItem::ResponseItem).collect(); - self.persist_rollout_items(&rollout_items).await; + if self.persist_rollout_items(&rollout_items).await + && let Some(revision) = mcp_revision + { + self.services + .executed_tool_calls + .mark_mcp_attribution_persisted(revision); + } if turn_context.config.memories.disable_on_external_context && let Some(item) = response_items .iter() @@ -3959,6 +3992,16 @@ impl Session { for envelope in &mut items { Self::assign_missing_response_item_id(&mut envelope.item); } + let mcp_revision = self + .services + .executed_tool_calls + .mcp_attribution_checkpoint(/*force*/ true) + .and_then(|(attribution, revision)| { + items.last_mut().map(|envelope| { + envelope.metadata.get_or_insert_default().mcp_attribution = Some(attribution); + revision + }) + }); if let Some(checkpoint) = items.iter_mut().rev().find(|envelope| { matches!( envelope.item, @@ -4022,7 +4065,13 @@ impl Session { rollout_items.push(RolloutItem::EventMsg( thread_settings::applied_event(self).await, )); - self.persist_rollout_items(&rollout_items).await; + if self.persist_rollout_items(&rollout_items).await + && let Some(revision) = mcp_revision + { + self.services + .executed_tool_calls + .mark_mcp_attribution_persisted(revision); + } { let mut state = self.state.lock().await; state.queue_pending_session_start_source(codex_hooks::SessionStartSource::Compact); @@ -4354,12 +4403,14 @@ impl Session { } #[tracing::instrument(level = "trace", skip_all, fields(item_count = items.len()))] - pub(crate) async fn persist_rollout_items(&self, items: &[RolloutItem]) { + pub(crate) async fn persist_rollout_items(&self, items: &[RolloutItem]) -> bool { if let Some(live_thread) = self.live_thread() && let Err(e) = live_thread.append_items(items).await { error!("failed to record rollout items: {e:#}"); + return false; } + true } pub(crate) async fn clone_history(&self) -> ContextManager { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 92c1481375..0f729c2e21 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -716,6 +716,7 @@ impl Session { CodexResponsesMetadata { window_number: Some(window_number), context_window_id: Some(context_window_id), + mcp_attribution: Some(self.services.executed_tool_calls.mcp_attribution_snapshot()), analytics_enabled: Some(self.services.analytics_events_client.is_enabled()), history_ingest_requested: turn_context .config diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index fc1df5abf7..a4b3219867 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -33,8 +33,10 @@ use crate::shell_snapshot::ShellSnapshot; use crate::test_support::models_manager_with_provider; use crate::tools::format_exec_output_str; use crate::tools::registry::ToolRegistry; +use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; +use codex_analytics::CompactionTrigger; use codex_config::ConfigLayerStack; use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID; use codex_config::LoaderOverrides; @@ -107,6 +109,7 @@ use tracing::Span; use crate::connectors::AppInfo; use crate::responses_metadata::CodexResponsesRequestKind; +use crate::responses_metadata::CompactionTurnMetadata; use crate::rollout::recorder::RolloutRecorder; use crate::state::ActiveTurn; use crate::state::TaskKind; @@ -149,6 +152,9 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::items::HookPromptFragment; use codex_protocol::items::build_hook_prompt_message; +use codex_protocol::mcp::McpAttribution; +use codex_protocol::mcp::McpAttributionSource; +use codex_protocol::mcp::McpAttributionStatus; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::ContentItemKind; @@ -2662,6 +2668,12 @@ async fn annotated_history_uses_explicit_model_without_a_step( session .record_annotated_conversation_items(&turn_context, &model_info, expected.clone()) .await; + expected[0].metadata.get_or_insert_default().mcp_attribution = Some( + session + .services + .executed_tool_calls + .mcp_attribution_snapshot(), + ); for envelope in &mut expected { envelope .metadata @@ -3707,9 +3719,14 @@ async fn start_new_context_window_persists_checkpoint_state() { | RolloutItem::RealtimeItem(_) | RolloutItem::EventMsg(_) => None, }); + let mut expected_history = live_history.annotated_items().to_vec(); + // Compaction's parallel metadata vector represents absent entries as default metadata. + for envelope in &mut expected_history { + envelope.metadata.get_or_insert_default(); + } assert_eq!( persisted_compacted.and_then(|compacted| compacted.replacement_history.clone()), - Some(live_history.annotated_items().to_vec()) + Some(expected_history) ); assert_eq!( persisted_compacted.map(|compacted| { @@ -5603,6 +5620,102 @@ async fn settings_checkpoint_waits_for_accepted_settings_persistence() { ); } +#[tokio::test] +async fn mcp_attribution_checkpoints_cover_batch_prefixes_compaction_and_restore() { + let (mut session, turn_context) = make_session_and_context().await; + session.services.executed_tool_calls = + crate::state::ExecutedToolCalls::new(&turn_context.config.features, &InitialHistory::New); + let rollout_path = attach_thread_persistence(&mut session).await; + let source = McpAttributionSource { + connector_id: None, + plugin_id: None, + server_name: "example".to_string(), + tool_name: "search".to_string(), + first_turn_id: "turn_1".to_string(), + }; + session + .services + .executed_tool_calls + .record_mcp_source(source.clone()); + let expected = McpAttribution { + status: McpAttributionStatus::Complete, + sources: vec![source], + }; + session + .record_annotated_conversation_items( + &turn_context, + turn_context.model_info(), + vec![ + ResponseItemEnvelope::new(ResponseItem::FunctionCallOutput { + id: None, + call_id: Some("call_1".to_string()), + name: None, + namespace: None, + output: FunctionCallOutputPayload::from_text("result".to_string()), + internal_chat_message_metadata_passthrough: None, + }), + ResponseItemEnvelope::new(user_message("next turn")), + ], + ) + .await; + let checkpoints = session + .clone_history() + .await + .annotated_items() + .iter() + .map(|envelope| { + envelope + .metadata + .as_ref() + .and_then(|metadata| metadata.mcp_attribution.clone()) + }) + .collect::>(); + assert_eq!( + checkpoints, + vec![Some(expected.clone()), Some(expected.clone())] + ); + + let (window_number, window_ids) = session.advance_auto_compact_window().await; + session + .replace_compacted_history( + vec![ResponseItemEnvelope::new(user_message("compacted history"))], + /*reference_context_item*/ None, + /*world_state_baseline*/ None, + CompactedHistoryMetadata { + message: "summary".to_string(), + window_number, + window_ids, + compaction_response_id: None, + compaction_model_hash: None, + reviewer_compaction_hash: None, + }, + ) + .await; + session + .flush_rollout() + .await + .expect("flush compacted history"); + + let (items, _, _) = RolloutRecorder::load_rollout_items(&rollout_path) + .await + .expect("read compacted history"); + let checkpoint = items.iter().rev().find_map(|item| match item { + RolloutItem::Compacted(compacted) => compacted + .replacement_history + .as_ref()? + .iter() + .rev() + .find_map(|envelope| envelope.metadata.as_ref()?.mcp_attribution.as_ref()), + _ => None, + }); + assert_eq!(checkpoint, Some(&expected)); + let restored = crate::state::ExecutedToolCalls::new( + &turn_context.config.features, + &InitialHistory::Forked(items), + ); + assert_eq!(restored.mcp_attribution_snapshot(), expected); +} + #[tokio::test] async fn session_settings_commit_keeps_snapshot_across_postcommit_wait() { let (session, _turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( @@ -6018,6 +6131,50 @@ pub(crate) async fn build_world_state_from_turn_context( .expect("world state should build") } +#[tokio::test] +async fn response_metadata_builders_capture_fresh_mcp_attribution() { + let (mut session, turn_context) = make_session_and_context().await; + session.services.executed_tool_calls = + crate::state::ExecutedToolCalls::new(&turn_context.config.features, &InitialHistory::New); + let turn_context = Arc::new(turn_context); + let step_context = StepContext::for_test(Arc::clone(&turn_context)); + let before = session + .responses_metadata(&step_context, CodexResponsesRequestKind::Turn) + .await; + let source = McpAttributionSource { + connector_id: None, + plugin_id: None, + server_name: "example".to_string(), + tool_name: "search".to_string(), + first_turn_id: turn_context.sub_id.clone(), + }; + session + .services + .executed_tool_calls + .record_mcp_source(source.clone()); + let after = session + .responses_metadata(&step_context, CodexResponsesRequestKind::Turn) + .await; + let compaction = session + .compaction_responses_metadata( + &turn_context, + CompactionTurnMetadata::new( + CompactionTrigger::Auto, + CompactionReason::ContextLimit, + CompactionImplementation::Responses, + CompactionPhase::MidTurn, + ), + ) + .await; + let expected = Some(McpAttribution { + status: McpAttributionStatus::Complete, + sources: vec![source], + }); + assert_eq!(before.mcp_attribution, Some(McpAttribution::default())); + assert_eq!(after.mcp_attribution, expected); + assert_eq!(compaction.mcp_attribution, expected); +} + #[tokio::test] async fn responses_metadata_uses_selected_harness_analytics_client() { for enabled in [true, false] { diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 360607f3f4..c17ff03a7e 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -518,16 +518,12 @@ pub(crate) async fn run_turn( .instrument(trace_span!("run_turn.prepare_sampling_request_input")) .await; - let responses_metadata = sess - .responses_metadata(step_context.as_ref(), CodexResponsesRequestKind::Turn) - .await; run_sampling_request( Arc::clone(&sess), Arc::clone(&step_context), Arc::clone(&turn_context.extension_data), Arc::clone(&turn_diff_tracker), &mut client_session, - &responses_metadata, sampling_request_input, cancellation_token.child_token(), ) @@ -1590,7 +1586,6 @@ async fn run_sampling_request( turn_store: Arc, turn_diff_tracker: SharedTurnDiffTracker, client_session: &mut ModelClientSession, - responses_metadata: &CodexResponsesMetadata, input: Vec, cancellation_token: CancellationToken, ) -> CodexResult<(SamplingRequestResult, Vec)> { @@ -1631,13 +1626,16 @@ async fn run_sampling_request( step_context.as_ref(), base_instructions.clone(), ); + let responses_metadata = sess + .responses_metadata(step_context.as_ref(), CodexResponsesRequestKind::Turn) + .await; if crate::guardian::is_basic_session_source(&turn_context.session_source) { crate::guardian::check_guardian_prompt_budget( &sess, &prompt, &turn_context.config, &step_context.settings.model_info, - responses_metadata, + &responses_metadata, )?; } let err = match try_run_sampling_request( @@ -1646,7 +1644,7 @@ async fn run_sampling_request( Arc::clone(&step_context), Arc::clone(&turn_store), client_session, - responses_metadata, + &responses_metadata, Arc::clone(&turn_diff_tracker), &prompt, cancellation_token.child_token(), diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index b93c2ba700..0cf04f0f4c 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -64,6 +64,18 @@ pub async fn history_with_tool_call_metadata( items } +/// Returns the recorder state used by the next session metadata snapshot. +/// This does not change destination filtering or issue a request. +pub fn mcp_attribution_snapshot( + thread: &crate::CodexThread, +) -> codex_protocol::mcp::McpAttribution { + thread + .session + .services + .executed_tool_calls + .mcp_attribution_snapshot() +} + /// Test-only provider that supplies no user instructions. #[derive(Debug, Default)] pub struct EmptyUserInstructionsProvider; diff --git a/codex-rs/core/src/tools/executed_tool_calls.rs b/codex-rs/core/src/tools/executed_tool_calls.rs index 43e8aa534e..aec694bd24 100644 --- a/codex-rs/core/src/tools/executed_tool_calls.rs +++ b/codex-rs/core/src/tools/executed_tool_calls.rs @@ -4,6 +4,7 @@ #[cfg(test)] #[path = "executed_tool_calls_direct_tests.rs"] mod direct_tests; +mod mcp_attribution; mod request_metadata; use std::collections::HashMap; @@ -19,6 +20,8 @@ use codex_code_mode::CellId; use codex_features::Feature; use codex_features::Features; use codex_history::InitialHistory; +use codex_protocol::mcp::McpAttribution; +use codex_protocol::mcp::McpAttributionSource; use codex_protocol::models::ExecutedToolCall; use codex_protocol::models::ExecutedToolCallArguments; use codex_protocol::models::ResponseItem; @@ -58,6 +61,7 @@ pub(crate) struct ExecutedToolCalls { state: Arc>>, retained_direct_metadata_bytes: Arc, pending_direct_calls: Arc, + mcp_attribution: mcp_attribution::McpAttributionRecorder, } // The tool future owns this reservation, so completion or cancellation releases it. @@ -220,9 +224,26 @@ impl ExecutedToolCalls { }))), retained_direct_metadata_bytes: Arc::new(AtomicUsize::new(0)), pending_direct_calls: Arc::new(AtomicUsize::new(0)), + mcp_attribution: mcp_attribution::McpAttributionRecorder::new(history), } } + pub(crate) fn record_mcp_source(&self, source: McpAttributionSource) { + self.mcp_attribution.record(source); + } + + pub(crate) fn mcp_attribution_snapshot(&self) -> McpAttribution { + self.mcp_attribution.snapshot() + } + + pub(crate) fn mcp_attribution_checkpoint(&self, force: bool) -> Option<(McpAttribution, u64)> { + self.mcp_attribution.checkpoint(force) + } + + pub(crate) fn mark_mcp_attribution_persisted(&self, revision: u64) { + self.mcp_attribution.mark_persisted(revision); + } + fn lock_state(&self) -> MutexGuard<'_, Option> { self.state .lock() diff --git a/codex-rs/core/src/tools/executed_tool_calls/mcp_attribution.rs b/codex-rs/core/src/tools/executed_tool_calls/mcp_attribution.rs new file mode 100644 index 0000000000..fce3f8f36d --- /dev/null +++ b/codex-rs/core/src/tools/executed_tool_calls/mcp_attribution.rs @@ -0,0 +1,169 @@ +//! Cumulative MCP result attribution, independent of the best-effort call recorder. + +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::MutexGuard; + +use codex_history::InitialHistory; +use codex_history::RolloutItem; +use codex_protocol::mcp::McpAttribution; +use codex_protocol::mcp::McpAttributionSource; +use codex_protocol::mcp::McpAttributionStatus; + +#[derive(Clone)] +pub(super) struct McpAttributionRecorder(Arc>); + +struct State { + attribution: McpAttribution, + revision: u64, + persisted_revision: u64, +} + +#[derive(PartialEq, Eq)] +struct SourceIdentity<'a> { + connector_id: Option<&'a str>, + plugin_id: Option<&'a str>, + server_name: &'a str, + tool_name: &'a str, +} + +impl<'a> From<&'a McpAttributionSource> for SourceIdentity<'a> { + fn from(source: &'a McpAttributionSource) -> Self { + Self { + connector_id: source.connector_id.as_deref(), + plugin_id: source.plugin_id.as_deref(), + server_name: &source.server_name, + tool_name: &source.tool_name, + } + } +} + +impl Default for McpAttributionRecorder { + fn default() -> Self { + Self::new(&InitialHistory::New) + } +} + +impl McpAttributionRecorder { + pub(super) fn new(history: &InitialHistory) -> Self { + let mut state = State { + attribution: McpAttribution::default(), + // Persist an initial checkpoint even when no MCP result has been recorded. + revision: 1, + persisted_revision: 0, + }; + let mut found_checkpoint = matches!(history, InitialHistory::New | InitialHistory::Cleared); + for item in history.get_rollout_items() { + match item { + RolloutItem::ResponseItem(envelope) => { + if let Some(checkpoint) = envelope + .metadata + .as_ref() + .and_then(|metadata| metadata.mcp_attribution.as_ref()) + { + found_checkpoint = true; + state.merge_checkpoint(checkpoint); + } + } + RolloutItem::Compacted(compacted) => { + for checkpoint in compacted + .replacement_history + .iter() + .flatten() + .filter_map(|envelope| envelope.metadata.as_ref()) + .filter_map(|metadata| metadata.mcp_attribution.as_ref()) + { + found_checkpoint = true; + state.merge_checkpoint(checkpoint); + } + } + _ => {} + } + } + if !found_checkpoint { + // Pre-attribution history cannot establish that earlier context was MCP-free. + state.mark_error(); + } + Self(Arc::new(Mutex::new(state))) + } + + fn lock(&self) -> MutexGuard<'_, State> { + self.0.lock().unwrap_or_else(|poisoned| { + let mut state = poisoned.into_inner(); + state.mark_error(); + state + }) + } + + pub(super) fn record(&self, source: McpAttributionSource) { + self.lock().record(source, /*restoring*/ false); + } + + pub(super) fn snapshot(&self) -> McpAttribution { + self.lock().attribution.clone() + } + + pub(super) fn checkpoint(&self, force: bool) -> Option<(McpAttribution, u64)> { + let state = self.lock(); + (force || state.revision != state.persisted_revision) + .then(|| (state.attribution.clone(), state.revision)) + } + + pub(super) fn mark_persisted(&self, revision: u64) { + let mut state = self.lock(); + state.persisted_revision = state.persisted_revision.max(revision.min(state.revision)); + } +} + +impl State { + fn mark_error(&mut self) { + if self.attribution.status != McpAttributionStatus::AttributionError { + self.attribution.status = McpAttributionStatus::AttributionError; + self.revision += 1; + } + } + + fn merge_checkpoint(&mut self, checkpoint: &McpAttribution) { + if checkpoint.status == McpAttributionStatus::AttributionError + || (checkpoint.status == McpAttributionStatus::None && !checkpoint.sources.is_empty()) + || (checkpoint.status == McpAttributionStatus::Complete + && checkpoint.sources.is_empty()) + { + self.mark_error(); + } + for source in &checkpoint.sources { + self.record(source.clone(), /*restoring*/ true); + } + } + + fn record(&mut self, source: McpAttributionSource, restoring: bool) { + let identity = SourceIdentity::from(&source); + if let Some(existing) = self + .attribution + .sources + .iter() + .find(|existing| SourceIdentity::from(*existing) == identity) + { + if restoring && existing.first_turn_id != source.first_turn_id { + self.mark_error(); + } + return; + } + if source.server_name.is_empty() + || source.tool_name.is_empty() + || source.first_turn_id.is_empty() + { + self.mark_error(); + return; + } + if self.attribution.status == McpAttributionStatus::None { + self.attribution.status = McpAttributionStatus::Complete; + } + self.attribution.sources.push(source); + self.revision += 1; + } +} + +#[cfg(test)] +#[path = "mcp_attribution_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/executed_tool_calls/mcp_attribution_tests.rs b/codex-rs/core/src/tools/executed_tool_calls/mcp_attribution_tests.rs new file mode 100644 index 0000000000..87033720a2 --- /dev/null +++ b/codex-rs/core/src/tools/executed_tool_calls/mcp_attribution_tests.rs @@ -0,0 +1,129 @@ +//! Tests for cumulative MCP attribution restoration and checkpoint acknowledgements. + +use super::*; +use codex_history::CodexHarnessMetadata; +use codex_history::CompactedItem; +use codex_history::ResponseItemEnvelope; +use codex_protocol::models::ResponseItem; +use pretty_assertions::assert_eq; + +fn source(tool_name: &str, first_turn_id: &str) -> McpAttributionSource { + McpAttributionSource { + connector_id: None, + plugin_id: None, + server_name: "example".to_string(), + tool_name: tool_name.to_string(), + first_turn_id: first_turn_id.to_string(), + } +} + +fn envelope(attribution: Option) -> ResponseItemEnvelope { + ResponseItemEnvelope { + item: ResponseItem::Message { + id: None, + role: "user".to_string(), + content: Vec::new(), + phase: None, + internal_chat_message_metadata_passthrough: None, + }, + metadata: attribution.map(|mcp_attribution| CodexHarnessMetadata { + mcp_attribution: Some(mcp_attribution), + ..Default::default() + }), + } +} + +#[test] +fn records_the_first_turn_for_each_unique_source() { + let recorder = McpAttributionRecorder::default(); + recorder.record(source("search", "turn_1")); + recorder.record(source("search", "turn_2")); + recorder.record(source("fetch", "turn_2")); + + assert_eq!( + recorder.snapshot(), + McpAttribution { + status: McpAttributionStatus::Complete, + sources: vec![source("search", "turn_1"), source("fetch", "turn_2")], + } + ); +} + +#[test] +fn restores_cumulative_item_and_compaction_checkpoints() { + let initial = McpAttribution { + status: McpAttributionStatus::Complete, + sources: vec![source("search", "turn_1")], + }; + let cumulative = McpAttribution { + status: McpAttributionStatus::Complete, + sources: vec![source("search", "turn_1"), source("fetch", "turn_2")], + }; + let history = InitialHistory::Forked(vec![ + RolloutItem::ResponseItem(envelope(Some(initial))), + RolloutItem::Compacted(CompactedItem { + message: "summary".to_string(), + replacement_history: Some(vec![envelope(Some(cumulative.clone()))]), + guardian_history: None, + retained_context: None, + mcp_resource_origins: None, + window_number: None, + first_window_id: None, + previous_window_id: None, + window_id: None, + compaction_response_id: None, + latest_token_usage_record: None, + }), + ]); + + assert_eq!(McpAttributionRecorder::new(&history).snapshot(), cumulative); +} + +#[test] +fn legacy_or_conflicting_history_is_not_complete() { + let legacy = InitialHistory::Forked(vec![RolloutItem::ResponseItem(envelope( + /*attribution*/ None, + ))]); + assert_eq!( + McpAttributionRecorder::new(&legacy).snapshot(), + McpAttribution { + status: McpAttributionStatus::AttributionError, + sources: Vec::new(), + } + ); + + let history = InitialHistory::Forked( + ["turn_1", "turn_2"] + .map(|turn_id| { + RolloutItem::ResponseItem(envelope(Some(McpAttribution { + status: McpAttributionStatus::Complete, + sources: vec![source("search", turn_id)], + }))) + }) + .to_vec(), + ); + assert_eq!( + McpAttributionRecorder::new(&history).snapshot(), + McpAttribution { + status: McpAttributionStatus::AttributionError, + sources: vec![source("search", "turn_1")], + } + ); +} + +#[test] +fn acknowledging_an_older_checkpoint_does_not_clear_newer_changes() { + let recorder = McpAttributionRecorder::default(); + let (_, initial_revision) = recorder + .checkpoint(/*force*/ false) + .expect("initial checkpoint"); + recorder.record(source("search", "turn_1")); + recorder.mark_persisted(initial_revision); + + let (_, latest_revision) = recorder + .checkpoint(/*force*/ false) + .expect("dirty checkpoint"); + recorder.mark_persisted(latest_revision); + assert_eq!(recorder.checkpoint(/*force*/ false), None); + assert!(recorder.checkpoint(/*force*/ true).is_some()); +} diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index c0316896be..a4ea4b3597 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -9,6 +9,7 @@ use crate::responses_metadata::CompactionTurnMetadata; use crate::responses_metadata::FORKED_FROM_ORDINAL_EXCLUSIVE_KEY; use crate::responses_metadata::INSTALLATION_ID_KEY; use crate::responses_metadata::LEGACY_CODE_MODE_TOOL_NAMES_KEY; +use crate::responses_metadata::MCP_ATTRIBUTION_CLIENT_METADATA_KEY; use crate::responses_metadata::NODE_REPL_AUTO_REVIEW_REQUIRED_KEY; use crate::responses_metadata::NODE_REPL_DISABLED_KEY; use crate::responses_metadata::PARENT_TURN_ID_KEY; @@ -181,7 +182,7 @@ async fn detached_memory_responses_metadata_starts_an_independent_root_turn() { let turn_id = parsed["turn_id"].as_str().expect("memory turn ID"); uuid::Uuid::parse_str(turn_id).expect("memory turn ID is a UUID"); assert_eq!(parsed[ROOT_TURN_ID_KEY], parsed["turn_id"]); - let client_metadata = metadata.client_metadata(); + let client_metadata = metadata.client_metadata(/*include_internal*/ true); assert_eq!( client_metadata.get("turn_id").map(String::as_str), Some(turn_id) @@ -1100,6 +1101,7 @@ fn responses_api_metadata_rejects_reserved_keys() { TURN_TRIGGER_KEY, WINDOW_ID_KEY, CONTEXT_WINDOW_ID_KEY, + MCP_ATTRIBUTION_CLIENT_METADATA_KEY, ] { assert_eq!( validate_extra_metadata( diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index d443287687..a2bebfe6d0 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -5939,6 +5939,41 @@ contentLength=0" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_records_mcp_completion_even_when_result_is_not_printed() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (test, second_mock) = run_code_mode_turn_with_rmcp( + &server, + "call the rmcp echo tool without printing its result", + r#"await tools.mcp__rmcp__echo({ message: "discarded" }); text("done");"#, + ) + .await?; + let request = second_mock.single_request(); + assert_eq!( + custom_tool_output_body_and_success(&request, "call-1").0, + "done" + ); + + let first_turn_id = request.body_json()["client_metadata"]["turn_id"].clone(); + assert!(first_turn_id.is_string()); + assert_eq!( + serde_json::to_value(codex_core::test_support::mcp_attribution_snapshot( + &test.codex + ))?, + serde_json::json!({ + "status": "complete", + "sources": [{ + "server_name": "rmcp", + "tool_name": "echo", + "first_turn_id": first_turn_id, + }], + }) + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case("node_repl"; "node_repl")] #[test_case("cua_repl"; "cua_repl")] diff --git a/codex-rs/core/tests/suite/mcp_turn_metadata.rs b/codex-rs/core/tests/suite/mcp_turn_metadata.rs index 7e01e1381b..75b3ba1dfc 100644 --- a/codex-rs/core/tests/suite/mcp_turn_metadata.rs +++ b/codex-rs/core/tests/suite/mcp_turn_metadata.rs @@ -1493,6 +1493,28 @@ async fn apps_default_writes_prompts_for_writes_but_not_reads() -> Result<()> { recorded_apps_tool_call_by_call_id(&server, read_call_id).await; recorded_apps_tool_call_by_call_id(&server, write_call_id).await; + let first_turn_id = responses.requests()[2].body_json()["client_metadata"]["turn_id"].clone(); + assert!(first_turn_id.is_string()); + assert_eq!( + serde_json::to_value(codex_core::test_support::mcp_attribution_snapshot( + &test.codex + ))?, + json!({ + "status": "complete", + "sources": [{ + "connector_id": "calendar", + "server_name": "codex_apps", + "tool_name": "calendar_list_events", + "first_turn_id": first_turn_id, + }, { + "connector_id": "calendar", + "server_name": "codex_apps", + "tool_name": "calendar_create_event", + "first_turn_id": first_turn_id, + }], + }) + ); + test.codex.ensure_rollout_materialized().await; test.codex.flush_rollout().await?; let rollout_path = test.codex.rollout_path().expect("rollout path"); diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index a1fd36fe2f..bad5c1135b 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -670,7 +670,8 @@ async fn text_only_mcp_content_uses_content_items() -> anyhow::Result<()> { }) .await; - let output = final_mock.single_request().function_call_output(call_id); + let request = final_mock.single_request(); + let output = request.function_call_output(call_id); let header = output["output"][0]["text"] .as_str() .expect("first content item should contain the wall-time header"); @@ -689,6 +690,22 @@ async fn text_only_mcp_content_uses_content_items() -> anyhow::Result<()> { ]) ); + let first_turn_id = request.body_json()["client_metadata"]["turn_id"].clone(); + assert!(first_turn_id.is_string()); + assert_eq!( + serde_json::to_value(codex_core::test_support::mcp_attribution_snapshot( + &fixture.codex + ))?, + json!({ + "status": "complete", + "sources": [{ + "server_name": "rmcp", + "tool_name": "image_scenario", + "first_turn_id": first_turn_id, + }], + }) + ); + server.verify().await; Ok(()) } diff --git a/codex-rs/history/src/lib.rs b/codex-rs/history/src/lib.rs index 278d8dc839..4b611539cf 100644 --- a/codex-rs/history/src/lib.rs +++ b/codex-rs/history/src/lib.rs @@ -12,6 +12,8 @@ use std::sync::Arc; use codex_protocol::ThreadId; use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_protocol::dynamic_tools::DynamicToolSpec; +use codex_protocol::mcp::McpAttribution; +use codex_protocol::mcp::McpAttributionStatus; use codex_protocol::mcp::McpResourceOriginCheckpoint; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; @@ -83,11 +85,34 @@ pub struct CodexHarnessMetadata { #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub inherited_user_message: bool, + /// Cumulative MCP tools/call attribution checkpoint, never model-visible. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_mcp_attribution_checkpoint" + )] + pub mcp_attribution: Option, + /// Sender context captured by the host when this task message was accepted. #[serde(default, skip_serializing_if = "Option::is_none")] pub sender_user_messages: Option>, } +fn deserialize_mcp_attribution_checkpoint<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = serde_json::Value::deserialize(deserializer)?; + Ok(Some(serde_json::from_value(value).unwrap_or_else(|_| { + McpAttribution { + status: McpAttributionStatus::AttributionError, + sources: Vec::new(), + } + }))) +} + impl ResponseItemEnvelope { /// Wraps a raw Responses API item for persisted history. pub fn new(item: ResponseItem) -> Self { diff --git a/codex-rs/history/src/tests.rs b/codex-rs/history/src/tests.rs index b04d2448db..d4fcbfe2f8 100644 --- a/codex-rs/history/src/tests.rs +++ b/codex-rs/history/src/tests.rs @@ -161,6 +161,32 @@ fn response_item_envelope_accessors_preserve_item() { assert_eq!(envelope.into_item(), replacement_item); } +#[test] +fn mcp_checkpoint_handles_missing_or_unknown_identity() -> Result<()> { + let metadata: CodexHarnessMetadata = serde_json::from_value(json!({}))?; + assert_eq!(metadata.mcp_attribution, None); + + let restored: CodexHarnessMetadata = serde_json::from_value(json!({ + "mcp_attribution": { + "status": "complete", + "sources": [{ + "server_name": "example", + "tool_name": "search", + "first_turn_id": "turn_1", + "future_identity": "unknown to this reader" + }] + } + }))?; + assert_eq!( + restored.mcp_attribution, + Some(McpAttribution { + status: McpAttributionStatus::AttributionError, + sources: Vec::new(), + }) + ); + Ok(()) +} + #[test] /// Keeps legacy response-item rollout lines readable and byte-shape compatible. fn response_item_rollout_line_preserves_shape() -> Result<()> { diff --git a/codex-rs/protocol/src/mcp.rs b/codex-rs/protocol/src/mcp.rs index 317174a568..c898c2af4e 100644 --- a/codex-rs/protocol/src/mcp.rs +++ b/codex-rs/protocol/src/mcp.rs @@ -52,6 +52,42 @@ pub fn is_node_repl_backed_tool(name: &str, namespace: Option<&str>) -> bool { .is_some_and(|(server, _)| is_node_repl_backed_server(server)) } +/// Producer-reported completeness of the thread's MCP tools/call attribution. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum McpAttributionStatus { + #[default] + None, + Complete, + AttributionError, +} + +/// One source and the first runtime turn in which Codex recorded it. +/// +/// Server and tool names are retained when no stable identifier is available; +/// they are not guaranteed to be globally unique or independently authenticated. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct McpAttributionSource { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connector_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_id: Option, + pub server_name: String, + pub tool_name: String, + pub first_turn_id: String, +} + +/// Cumulative, model-invisible attribution reported on Responses API requests. +/// This is provenance data, not a training-eligibility decision or attestation. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct McpAttribution { + pub status: McpAttributionStatus, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sources: Vec, +} + /// Bounded app-resource provenance retained across a compaction checkpoint. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct McpResourceOriginCheckpoint { diff --git a/codex-rs/rollout/src/lib.rs b/codex-rs/rollout/src/lib.rs index c74d53ee98..5f2eb7f84c 100644 --- a/codex-rs/rollout/src/lib.rs +++ b/codex-rs/rollout/src/lib.rs @@ -143,6 +143,7 @@ pub use persistence_metrics::RolloutPersistenceTelemetry; pub use persistence_metrics::measure_and_filter_rollout_items; pub use policy::is_persisted_rollout_item; pub use policy::persisted_rollout_items; +pub use policy::should_persist_response_item; pub use policy::should_persist_response_item_for_memories; pub use recorder::RolloutRecorder; pub use recorder::RolloutRecorderParams; diff --git a/codex-rs/utils/string/src/json.rs b/codex-rs/utils/string/src/json.rs index fd5e7d65bc..da69ed6318 100644 --- a/codex-rs/utils/string/src/json.rs +++ b/codex-rs/utils/string/src/json.rs @@ -1,5 +1,4 @@ -//! JSON serialization helpers for output that must remain parseable as JSON -//! while staying safe for ASCII-only transports. +//! JSON serialization helpers for ASCII-only transports and bounded output. use std::io; @@ -54,6 +53,43 @@ where .map_err(|err| serde_json::Error::io(io::Error::new(io::ErrorKind::InvalidData, err))) } +/// Serialize UTF-8 JSON while retaining at most `max_bytes` of output. +pub fn to_json_string_bounded(value: &T, max_bytes: usize) -> serde_json::Result +where + T: Serialize + ?Sized, +{ + struct BoundedBuffer { + bytes: Vec, + max_bytes: usize, + } + + impl io::Write for BoundedBuffer { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if bytes.len() > self.max_bytes.saturating_sub(self.bytes.len()) { + return Err(io::Error::new( + io::ErrorKind::WriteZero, + "JSON byte limit exceeded", + )); + } + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + let mut buffer = BoundedBuffer { + bytes: Vec::new(), + max_bytes, + }; + let mut serializer = serde_json::Serializer::new(&mut buffer); + value.serialize(&mut serializer)?; + String::from_utf8(buffer.bytes) + .map_err(|err| serde_json::Error::io(io::Error::new(io::ErrorKind::InvalidData, err))) +} + #[cfg(test)] mod tests { use std::collections::BTreeMap; @@ -65,6 +101,7 @@ mod tests { use serde_json::json; use super::to_ascii_json_string; + use super::to_json_string_bounded; #[test] fn to_ascii_json_string_escapes_non_ascii_strings() { @@ -119,4 +156,14 @@ mod tests { let parsed: Value = serde_json::from_str(&serialized).expect("serialized json"); assert_eq!(parsed, expected_value); } + + #[test] + fn bounded_json_counts_utf8_output_bytes() { + assert_eq!( + to_json_string_bounded(&"é", /*max_bytes*/ 4).unwrap(), + r#""é""# + ); + assert!(to_json_string_bounded(&"é", /*max_bytes*/ 3).is_err()); + assert!(to_json_string_bounded(&"a".repeat(100_000), 16 * 1024).is_err()); + } } diff --git a/codex-rs/utils/string/src/lib.rs b/codex-rs/utils/string/src/lib.rs index 2d081f79e2..1618739b08 100644 --- a/codex-rs/utils/string/src/lib.rs +++ b/codex-rs/utils/string/src/lib.rs @@ -2,6 +2,7 @@ mod json; mod truncate; pub use json::to_ascii_json_string; +pub use json::to_json_string_bounded; pub use truncate::approx_bytes_for_tokens; pub use truncate::approx_token_count; pub use truncate::approx_tokens_from_byte_count;