Stop exposing Codex context to direct MCP servers

Direct user-configured and plugin MCP servers sit outside the managed connector trust boundary, but Codex currently attaches its raw turn, thread, workspace, plugin, and trace metadata to their tool calls. That leaks host implementation context to arbitrary servers and makes direct egress broader than its intended MCP contract.

Introduce a source-derived direct_mcp_v1 profile and enforce it again at the final connection-manager boundary. Direct calls now omit host-generated context and strip reserved OpenAI/Codex metadata supplied through app-server calls while retaining ordinary MCP metadata and sandbox state only when the server negotiated that capability. Compatibility and extension registrations retain host-owned behavior.

Hoopa still depends on threadId for its legacy per-thread routing and is registered as an ordinary config server, so temporarily reserve the exact logical name hoopa as host-owned. This is intentional migration debt rather than a trust signal; it can be removed once Hoopa moves to a trusted registration or no longer routes through request metadata.

Move authoritative hosted thread metadata behind the profile boundary and remove tests and fixtures that asserted direct servers receive the now-private context.
This commit is contained in:
Max Stoiber
2026-07-07 09:16:54 -07:00
parent f659eb12bc
commit c8bbae079f
14 changed files with 256 additions and 240 deletions
+11 -4
View File
@@ -651,10 +651,17 @@ impl CodexThread {
arguments: Option<serde_json::Value>,
meta: Option<serde_json::Value>,
) -> anyhow::Result<CallToolResult> {
self.current_mcp_runtime()
.await
.manager_arc()
.call_tool(server, tool, arguments, meta)
let manager = self.current_mcp_runtime().await.manager_arc();
let meta = if manager.server_uses_direct_mcp_v1(server) {
meta
} else {
crate::mcp_tool_call::with_mcp_tool_call_thread_id_meta(
meta,
&self.session_configured.thread_id.to_string(),
)
};
manager
.call_tool_from_app(server, tool, arguments, meta)
.await
}
+18 -6
View File
@@ -394,8 +394,11 @@ async fn handle_approved_mcp_tool_call(
let result = async {
let result = async {
let rewritten_arguments = rewrite?;
let request_meta =
build_mcp_tool_call_request_meta(turn_context, &server, call_id, metadata);
let request_meta = if manager.server_uses_direct_mcp_v1(&server) {
None
} else {
build_host_mcp_tool_call_request_meta(turn_context, &server, call_id, metadata)
};
execute_mcp_tool_call(
sess,
step_context,
@@ -574,7 +577,12 @@ async fn execute_mcp_tool_call(
) -> Result<CallToolResult, String> {
let turn_context = step_context.turn.as_ref();
let manager = step_context.mcp.manager();
let request_meta = with_mcp_tool_call_thread_id_meta(request_meta, &sess.thread_id.to_string());
let include_host_metadata = !manager.server_uses_direct_mcp_v1(&invocation.server);
let request_meta = if include_host_metadata {
with_mcp_tool_call_thread_id_meta(request_meta, &sess.thread_id.to_string())
} else {
request_meta
};
let request_meta = augment_mcp_tool_request_meta_with_sandbox_state(
step_context,
manager,
@@ -587,7 +595,11 @@ async fn execute_mcp_tool_call(
.services
.rollout_thread_trace
.start_mcp_call_trace(call_id);
let request_meta = mcp_call_trace.add_request_meta(request_meta);
let request_meta = if include_host_metadata {
mcp_call_trace.add_request_meta(request_meta)
} else {
request_meta
};
let result = manager
.call_tool(
&invocation.server,
@@ -1080,7 +1092,7 @@ async fn custom_mcp_tool_approval_mode(
.unwrap_or_default()
}
fn build_mcp_tool_call_request_meta(
fn build_host_mcp_tool_call_request_meta(
turn_context: &TurnContext,
server: &str,
call_id: &str,
@@ -1124,7 +1136,7 @@ fn build_mcp_tool_call_request_meta(
(!request_meta.is_empty()).then_some(serde_json::Value::Object(request_meta))
}
fn with_mcp_tool_call_thread_id_meta(
pub(crate) fn with_mcp_tool_call_thread_id_meta(
meta: Option<serde_json::Value>,
thread_id: &str,
) -> Option<serde_json::Value> {
+2 -94
View File
@@ -1065,75 +1065,6 @@ fn truncate_mcp_tool_result_for_event_bounds_large_error() {
assert!(got.contains("truncated"));
}
#[tokio::test]
async fn mcp_tool_call_request_meta_includes_turn_metadata_for_custom_server() {
let (_, turn_context) = make_session_and_context().await;
let expected_turn_metadata = turn_context
.turn_metadata_state
.current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context))
.expect("turn metadata");
let meta = build_mcp_tool_call_request_meta(
&turn_context,
"custom_server",
"call-custom",
/*metadata*/ None,
)
.expect("custom servers should receive turn metadata");
let turn_metadata = meta
.get(crate::X_CODEX_TURN_METADATA_HEADER)
.expect("turn metadata should be present");
assert_eq!(
turn_metadata
.get("model")
.and_then(serde_json::Value::as_str),
Some(turn_context.model_info.slug.as_str())
);
assert_eq!(
turn_metadata
.get("reasoning_effort")
.and_then(serde_json::Value::as_str),
turn_context
.effective_reasoning_effort()
.map(|effort| effort.to_string())
.as_deref()
);
assert_eq!(
meta,
serde_json::json!({
crate::X_CODEX_TURN_METADATA_HEADER: expected_turn_metadata,
})
);
}
#[tokio::test]
async fn mcp_tool_call_request_meta_includes_turn_started_at_unix_ms() {
let (_, turn_context) = make_session_and_context().await;
turn_context
.turn_metadata_state
.set_turn_started_at_unix_ms(/*turn_started_at_unix_ms*/ 1_700_000_000_123);
let meta = build_mcp_tool_call_request_meta(
&turn_context,
"custom_server",
"call-custom",
/*metadata*/ None,
)
.expect("custom servers should receive turn metadata");
let turn_metadata = meta
.get(crate::X_CODEX_TURN_METADATA_HEADER)
.expect("turn metadata should be present");
assert_eq!(
turn_metadata
.get("turn_started_at_unix_ms")
.and_then(serde_json::Value::as_i64),
Some(1_700_000_000_123)
);
}
#[tokio::test]
async fn mcp_sandbox_cwd_uses_matching_server_environment_uri() -> anyhow::Result<()> {
let (_, mut turn_context) = make_session_and_context().await;
@@ -1169,29 +1100,6 @@ async fn mcp_sandbox_cwd_is_none_for_unselected_server_environment() -> anyhow::
Ok(())
}
#[tokio::test]
async fn plugin_mcp_tool_call_request_meta_includes_plugin_id() {
let (_, turn_context) = make_session_and_context().await;
let expected_turn_metadata = turn_context
.turn_metadata_state
.current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context))
.expect("turn metadata");
let mut metadata = approval_metadata(
/*connector_id*/ None, /*connector_name*/ None,
/*connector_description*/ None, /*tool_title*/ None,
/*tool_description*/ None,
);
metadata.plugin_id = Some("sample@test".to_string());
assert_eq!(
build_mcp_tool_call_request_meta(&turn_context, "sample", "call-plugin", Some(&metadata),),
Some(serde_json::json!({
crate::X_CODEX_TURN_METADATA_HEADER: expected_turn_metadata,
MCP_TOOL_PLUGIN_ID_META_KEY: "sample@test",
}))
);
}
#[test]
fn mcp_tool_call_item_metadata_only_trusts_codex_apps_identity() {
let mut metadata = approval_metadata(
@@ -1321,7 +1229,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps
};
assert_eq!(
build_mcp_tool_call_request_meta(
build_host_mcp_tool_call_request_meta(
&turn_context,
CODEX_APPS_MCP_SERVER_NAME,
"call_abc123xyz789",
@@ -1348,7 +1256,7 @@ async fn codex_apps_tool_call_request_meta_includes_call_id_without_existing_cod
.expect("turn metadata");
assert_eq!(
build_mcp_tool_call_request_meta(
build_host_mcp_tool_call_request_meta(
&turn_context,
CODEX_APPS_MCP_SERVER_NAME,
"call_abc123xyz789",
+1 -3
View File
@@ -3364,9 +3364,7 @@ impl Session {
"notes",
"thread_hint",
/*arguments*/ None,
Some(serde_json::json!({
"threadId": self.thread_id().to_string(),
})),
/*meta*/ None,
)
.await
.ok()
-90
View File
@@ -1,6 +1,4 @@
use anyhow::Result;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerTransportConfig;
use codex_core::config::TokenBudgetConfig;
use codex_features::Feature;
use codex_model_provider_info::built_in_model_providers;
@@ -32,18 +30,14 @@ use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::stdio_server_bin;
use core_test_support::test_codex::local;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use core_test_support::wait_for_mcp_server;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
const CONFIGURED_CONTEXT_WINDOW: i64 = 128_000;
@@ -259,90 +253,6 @@ async fn token_budget_guidance_follows_context_window() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn token_budget_context_injects_plain_thread_hint_text() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let rmcp_test_server_bin = stdio_server_bin()?;
let test = test_codex()
.with_config(move |config| {
config.model_context_window = Some(CONFIGURED_CONTEXT_WINDOW);
config
.features
.enable(Feature::TokenBudget)
.expect("test config should allow token budget");
let mut servers = config.mcp_servers.get().clone();
servers.insert(
"notes".to_string(),
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: None,
},
environment_id: "local".to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
})
.build(&server)
.await?;
wait_for_mcp_server(&test.codex, "notes").await?;
let responses = mount_sse_sequence(
&server,
vec![sse(vec![
ev_response_created("resp-1"),
ev_completed("resp-1"),
])],
)
.await;
test.submit_turn("inject the history hint").await?;
let request = responses.single_request();
let thread_id = test.session_configured.thread_id;
let token_budgets = token_budget_contexts(&request);
assert_eq!(token_budgets.len(), 1);
let captures = assert_regex_match(
&format!(
r"^{CONTEXT_WINDOW_OPEN_TAG}\nThread id: {thread_id}\nFirst context window id: ([0-9a-f-]{{36}})\nCurrent context window id: ([0-9a-f-]{{36}})\nmanual history hint for thread {thread_id}\nunstructured notes/thread_hint fixture result\n{CONTEXT_WINDOW_CLOSE_TAG}$"
),
&token_budgets[0],
);
assert_eq!(
captures.get(1).expect("first window id capture").as_str(),
captures.get(2).expect("current window id capture").as_str()
);
assert!(
!tool_names(&request)
.iter()
.any(|name| name == "mcp__notes__thread_hint"),
"thread_hint should be hidden from model tool exposure"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn token_budget_reminder_emits_after_crossing_compaction_threshold() -> Result<()> {
skip_if_no_network!(Ok(()));