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
This commit is contained in:
peilin-openai
2026-09-21 18:19:07 +00:00
committed by copyberry
parent 7cec3e110c
commit ac98537678
28 changed files with 1119 additions and 73 deletions
@@ -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<Mutex<Option<ExecutedToolCallRecorderState>>>,
retained_direct_metadata_bytes: Arc<AtomicUsize>,
pending_direct_calls: Arc<AtomicUsize>,
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<ExecutedToolCallRecorderState>> {
self.state
.lock()
@@ -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<Mutex<State>>);
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;
@@ -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<McpAttribution>) -> 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());
}