mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
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:
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user