From bab2a28f26223da6a3a6f61bb9656d2f8d6cc4ee Mon Sep 17 00:00:00 2001 From: nicholasclark-openai Date: Wed, 18 Mar 2026 21:40:32 -0700 Subject: [PATCH 1/8] Revert "Forward session and turn headers to MCP HTTP requests (#15011)" This reverts commit b14689df3b97245faa9c29a0b8f3f6c4d09393bf. --- codex-rs/core/src/codex.rs | 41 ---------- codex-rs/core/src/mcp_connection_manager.rs | 35 +------- .../core/src/mcp_connection_manager_tests.rs | 5 -- codex-rs/core/src/tasks/mod.rs | 6 -- codex-rs/rmcp-client/src/rmcp_client.rs | 79 +++---------------- .../tests/streamable_http_recovery.rs | 3 - 6 files changed, 12 insertions(+), 157 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 45227d8136ff..a916f3311d31 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -125,8 +125,6 @@ use futures::future::BoxFuture; use futures::future::Shared; use futures::prelude::*; use futures::stream::FuturesOrdered; -use reqwest::header::HeaderMap; -use reqwest::header::HeaderValue; use rmcp::model::ListResourceTemplatesResult; use rmcp::model::ListResourcesResult; use rmcp::model::PaginatedRequestParams; @@ -3952,45 +3950,6 @@ impl Session { .await } - pub(crate) async fn sync_mcp_request_headers_for_turn(&self, turn_context: &TurnContext) { - let mut request_headers = HeaderMap::new(); - let session_id = self.conversation_id.to_string(); - if let Ok(value) = HeaderValue::from_str(&session_id) { - request_headers.insert("session_id", value.clone()); - request_headers.insert("x-client-request-id", value); - } - if let Some(turn_metadata) = turn_context.turn_metadata_state.current_header_value() - && let Ok(value) = HeaderValue::from_str(&turn_metadata) - { - request_headers.insert(crate::X_CODEX_TURN_METADATA_HEADER, value); - } - - let request_headers = if request_headers.is_empty() { - None - } else { - Some(request_headers) - }; - self.services - .mcp_connection_manager - .read() - .await - .set_request_headers_for_server( - crate::mcp::CODEX_APPS_MCP_SERVER_NAME, - request_headers, - ); - } - - pub(crate) async fn clear_mcp_request_headers(&self) { - self.services - .mcp_connection_manager - .read() - .await - .set_request_headers_for_server( - crate::mcp::CODEX_APPS_MCP_SERVER_NAME, - /*request_headers*/ None, - ); - } - pub(crate) async fn parse_mcp_tool_name( &self, name: &str, diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 7c8a34307022..938d6d0b2bf3 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -423,7 +423,6 @@ impl ManagedClient { #[derive(Clone)] struct AsyncManagedClient { client: Shared>>, - request_headers: Arc>>, startup_snapshot: Option>, startup_complete: Arc, tool_plugin_provenance: Arc, @@ -449,26 +448,17 @@ impl AsyncManagedClient { codex_apps_tools_cache_context.as_ref(), ) .map(|tools| filter_tools(tools, &tool_filter)); - let request_headers = Arc::new(StdMutex::new(None)); let startup_tool_filter = tool_filter; let startup_complete = Arc::new(AtomicBool::new(false)); let startup_complete_for_fut = Arc::clone(&startup_complete); - let request_headers_for_client = Arc::clone(&request_headers); let fut = async move { let outcome = async { if let Err(error) = validate_mcp_server_name(&server_name) { return Err(error.into()); } - let client = Arc::new( - make_rmcp_client( - &server_name, - config.transport, - store_mode, - request_headers_for_client, - ) - .await?, - ); + let client = + Arc::new(make_rmcp_client(&server_name, config.transport, store_mode).await?); match start_server_task( server_name, client, @@ -505,7 +495,6 @@ impl AsyncManagedClient { Self { client, - request_headers, startup_snapshot, startup_complete, tool_plugin_provenance, @@ -587,14 +576,6 @@ impl AsyncManagedClient { let managed = self.client().await?; managed.notify_sandbox_state_change(sandbox_state).await } - - fn set_request_headers(&self, request_headers: Option) { - let mut guard = self - .request_headers - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - *guard = request_headers; - } } pub const MCP_SANDBOX_STATE_CAPABILITY: &str = "codex/sandbox-state"; @@ -1065,16 +1046,6 @@ impl McpConnectionManager { }) } - pub(crate) fn set_request_headers_for_server( - &self, - server_name: &str, - request_headers: Option, - ) { - if let Some(client) = self.clients.get(server_name) { - client.set_request_headers(request_headers); - } - } - /// List resources from the specified server. pub async fn list_resources( &self, @@ -1458,7 +1429,6 @@ async fn make_rmcp_client( server_name: &str, transport: McpServerTransportConfig, store_mode: OAuthCredentialsStoreMode, - request_headers: Arc>>, ) -> Result { match transport { McpServerTransportConfig::Stdio { @@ -1492,7 +1462,6 @@ async fn make_rmcp_client( http_headers, env_http_headers, store_mode, - request_headers, ) .await .map_err(StartupOutcomeError::from) diff --git a/codex-rs/core/src/mcp_connection_manager_tests.rs b/codex-rs/core/src/mcp_connection_manager_tests.rs index 9401b379bcbf..c5f7fc4a4086 100644 --- a/codex-rs/core/src/mcp_connection_manager_tests.rs +++ b/codex-rs/core/src/mcp_connection_manager_tests.rs @@ -4,7 +4,6 @@ use codex_protocol::protocol::McpAuthStatus; use rmcp::model::JsonObject; use std::collections::HashSet; use std::sync::Arc; -use std::sync::Mutex as StdMutex; use tempfile::tempdir; fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { @@ -414,7 +413,6 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, - request_headers: Arc::new(StdMutex::new(None)), startup_snapshot: Some(startup_tools), startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), @@ -440,7 +438,6 @@ async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot( CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, - request_headers: Arc::new(StdMutex::new(None)), startup_snapshot: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), @@ -463,7 +460,6 @@ async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty( CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, - request_headers: Arc::new(StdMutex::new(None)), startup_snapshot: Some(Vec::new()), startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), @@ -496,7 +492,6 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: failed_client, - request_headers: Arc::new(StdMutex::new(None)), startup_snapshot: Some(startup_tools), startup_complete, tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 049ed56d45f2..c52e4f91780e 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -153,8 +153,6 @@ impl Session { ) { self.abort_all_tasks(TurnAbortReason::Replaced).await; self.clear_connector_selection().await; - self.sync_mcp_request_headers_for_turn(turn_context.as_ref()) - .await; let task: Arc = Arc::new(task); let task_kind = task.kind(); @@ -235,7 +233,6 @@ impl Session { // in-flight approval wait can surface as a model-visible rejection before TurnAborted. active_turn.clear_pending().await; } - self.clear_mcp_request_headers().await; } pub async fn on_task_finished( @@ -265,9 +262,6 @@ impl Session { *active = None; } drop(active); - if should_clear_active_turn { - self.clear_mcp_request_headers().await; - } if !pending_input.is_empty() { for pending_input_item in pending_input { match inspect_pending_input(self, &turn_context, pending_input_item).await { diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index cf4f90ad3b05..b898403b25c7 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -5,7 +5,6 @@ use std::io; use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; -use std::sync::Mutex as StdMutex; use std::time::Duration; use anyhow::Result; @@ -23,7 +22,6 @@ use reqwest::header::HeaderMap; use reqwest::header::WWW_AUTHENTICATE; use rmcp::model::CallToolRequestParams; use rmcp::model::CallToolResult; -use rmcp::model::ClientJsonRpcMessage; use rmcp::model::ClientNotification; use rmcp::model::ClientRequest; use rmcp::model::CreateElicitationRequestParams; @@ -85,45 +83,14 @@ const HEADER_LAST_EVENT_ID: &str = "Last-Event-Id"; const HEADER_SESSION_ID: &str = "Mcp-Session-Id"; const NON_JSON_RESPONSE_BODY_PREVIEW_BYTES: usize = 8_192; -fn message_uses_request_scoped_headers(message: &ClientJsonRpcMessage) -> bool { - matches!( - message, - ClientJsonRpcMessage::Request(request) - if request.request.method() == "tools/call" - ) -} - -fn apply_request_scoped_headers( - mut request: reqwest::RequestBuilder, - request_headers_state: &Arc>>, -) -> reqwest::RequestBuilder { - let extra_headers = request_headers_state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone(); - if let Some(extra_headers) = extra_headers { - for (name, value) in &extra_headers { - request = request.header(name, value.clone()); - } - } - request -} - #[derive(Clone)] struct StreamableHttpResponseClient { inner: reqwest::Client, - request_headers_state: Arc>>, } impl StreamableHttpResponseClient { - fn new( - inner: reqwest::Client, - request_headers_state: Arc>>, - ) -> Self { - Self { - inner, - request_headers_state, - } + fn new(inner: reqwest::Client) -> Self { + Self { inner } } fn reqwest_error( @@ -166,9 +133,6 @@ impl StreamableHttpClient for StreamableHttpResponseClient { if let Some(session_id_value) = session_id.as_ref() { request = request.header(HEADER_SESSION_ID, session_id_value.as_ref()); } - if message_uses_request_scoped_headers(&message) { - request = apply_request_scoped_headers(request, &self.request_headers_state); - } let response = request .json(&message) @@ -508,7 +472,6 @@ pub struct RmcpClient { transport_recipe: TransportRecipe, initialize_context: Mutex>, session_recovery_lock: Mutex<()>, - request_headers: Option>>>, } impl RmcpClient { @@ -526,10 +489,9 @@ impl RmcpClient { env_vars: env_vars.to_vec(), cwd, }; - let transport = - Self::create_pending_transport(&transport_recipe, /*request_headers*/ None) - .await - .map_err(io::Error::other)?; + let transport = Self::create_pending_transport(&transport_recipe) + .await + .map_err(io::Error::other)?; Ok(Self { state: Mutex::new(ClientState::Connecting { @@ -538,7 +500,6 @@ impl RmcpClient { transport_recipe, initialize_context: Mutex::new(None), session_recovery_lock: Mutex::new(()), - request_headers: None, }) } @@ -550,7 +511,6 @@ impl RmcpClient { http_headers: Option>, env_http_headers: Option>, store_mode: OAuthCredentialsStoreMode, - request_headers: Arc>>, ) -> Result { let transport_recipe = TransportRecipe::StreamableHttp { server_name: server_name.to_string(), @@ -560,9 +520,7 @@ impl RmcpClient { env_http_headers, store_mode, }; - let transport = - Self::create_pending_transport(&transport_recipe, Some(Arc::clone(&request_headers))) - .await?; + let transport = Self::create_pending_transport(&transport_recipe).await?; Ok(Self { state: Mutex::new(ClientState::Connecting { transport: Some(transport), @@ -570,7 +528,6 @@ impl RmcpClient { transport_recipe, initialize_context: Mutex::new(None), session_recovery_lock: Mutex::new(()), - request_headers: Some(request_headers), }) } @@ -873,7 +830,6 @@ impl RmcpClient { async fn create_pending_transport( transport_recipe: &TransportRecipe, - request_headers: Option>>>, ) -> Result { match transport_recipe { TransportRecipe::Stdio { @@ -990,12 +946,7 @@ impl RmcpClient { .auth_header(access_token); let http_client = build_http_client(&default_headers)?; let transport = StreamableHttpClientTransport::with_client( - StreamableHttpResponseClient::new( - http_client, - request_headers - .clone() - .unwrap_or_else(|| Arc::new(StdMutex::new(None))), - ), + StreamableHttpResponseClient::new(http_client), http_config, ); Ok(PendingTransport::StreamableHttp { transport }) @@ -1012,12 +963,7 @@ impl RmcpClient { let http_client = build_http_client(&default_headers)?; let transport = StreamableHttpClientTransport::with_client( - StreamableHttpResponseClient::new( - http_client, - request_headers - .clone() - .unwrap_or_else(|| Arc::new(StdMutex::new(None))), - ), + StreamableHttpResponseClient::new(http_client), http_config, ); Ok(PendingTransport::StreamableHttp { transport }) @@ -1165,9 +1111,7 @@ impl RmcpClient { .await .clone() .ok_or_else(|| anyhow!("MCP client cannot recover before initialize succeeds"))?; - let pending_transport = - Self::create_pending_transport(&self.transport_recipe, self.request_headers.clone()) - .await?; + let pending_transport = Self::create_pending_transport(&self.transport_recipe).await?; let (service, oauth_persistor, process_group_guard) = Self::connect_pending_transport( pending_transport, initialize_context.handler, @@ -1222,10 +1166,7 @@ async fn create_oauth_transport_and_runtime( } }; - let auth_client = AuthClient::new( - StreamableHttpResponseClient::new(http_client, Arc::new(StdMutex::new(None))), - manager, - ); + let auth_client = AuthClient::new(StreamableHttpResponseClient::new(http_client), manager); let auth_manager = auth_client.auth_manager.clone(); let transport = StreamableHttpClientTransport::with_client( diff --git a/codex-rs/rmcp-client/tests/streamable_http_recovery.rs b/codex-rs/rmcp-client/tests/streamable_http_recovery.rs index 8b03da8f1ad6..fb2fc96d20f1 100644 --- a/codex-rs/rmcp-client/tests/streamable_http_recovery.rs +++ b/codex-rs/rmcp-client/tests/streamable_http_recovery.rs @@ -1,7 +1,5 @@ use std::net::TcpListener; use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Mutex as StdMutex; use std::time::Duration; use std::time::Instant; @@ -79,7 +77,6 @@ async fn create_client(base_url: &str) -> anyhow::Result { None, None, OAuthCredentialsStoreMode::File, - Arc::new(StdMutex::new(None)), ) .await?; From 9124fe9cbc405bde42d244d27ddf31fdbc6be106 Mon Sep 17 00:00:00 2001 From: nicholasclark-openai Date: Thu, 19 Mar 2026 09:29:03 -0700 Subject: [PATCH 2/8] Plumb MCP turn metadata through _meta Co-authored-by: Codex --- codex-rs/core/src/mcp_tool_call.rs | 27 ++++++++++++++----- codex-rs/core/src/mcp_tool_call_tests.rs | 34 +++++++++++++++++++++--- codex-rs/core/src/turn_metadata.rs | 5 ++++ codex-rs/core/tests/suite/search_tool.rs | 30 +++++++++++++++++++++ 4 files changed, 86 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 06d801cbac8c..16a05df95713 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -119,7 +119,8 @@ pub(crate) async fn handle_mcp_tool_call( ); return CallToolResult::from_result(result); } - let request_meta = build_mcp_tool_call_request_meta(&server, metadata.as_ref()); + let request_meta = + build_mcp_tool_call_request_meta(turn_context.as_ref(), &server, metadata.as_ref()); let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent { call_id: call_id.clone(), @@ -390,18 +391,30 @@ pub(crate) struct McpToolApprovalMetadata { const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; fn build_mcp_tool_call_request_meta( + turn_context: &TurnContext, server: &str, metadata: Option<&McpToolApprovalMetadata>, ) -> Option { - if server != CODEX_APPS_MCP_SERVER_NAME { - return None; + let mut request_meta = serde_json::Map::new(); + + if let Some(turn_metadata) = turn_context.turn_metadata_state.current_meta_value() { + request_meta.insert( + crate::X_CODEX_TURN_METADATA_HEADER.to_string(), + turn_metadata, + ); } - let codex_apps_meta = metadata.and_then(|metadata| metadata.codex_apps_meta.as_ref())?; + if server == CODEX_APPS_MCP_SERVER_NAME + && let Some(codex_apps_meta) = + metadata.and_then(|metadata| metadata.codex_apps_meta.clone()) + { + request_meta.insert( + MCP_TOOL_CODEX_APPS_META_KEY.to_string(), + serde_json::Value::Object(codex_apps_meta), + ); + } - Some(serde_json::json!({ - MCP_TOOL_CODEX_APPS_META_KEY: codex_apps_meta, - })) + (!request_meta.is_empty()).then_some(serde_json::Value::Object(request_meta)) } #[derive(Clone, Copy)] diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 7b1da0f9d748..99aa0e0c695b 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -439,8 +439,28 @@ fn sanitize_mcp_tool_result_for_model_preserves_image_when_supported() { assert_eq!(got, original); } -#[test] -fn codex_apps_tool_call_request_meta_includes_codex_apps_meta() { +#[tokio::test] +async fn mcp_tool_call_request_meta_includes_turn_metadata_for_custom_server() { + let (_session, turn_context) = make_session_and_context().await; + + let meta = + build_mcp_tool_call_request_meta(&turn_context, "custom_server", /*metadata*/ None) + .expect("custom servers should receive turn metadata"); + + assert_eq!( + meta, + serde_json::json!({ + crate::X_CODEX_TURN_METADATA_HEADER: { + "turn_id": turn_context.sub_id, + "sandbox": "workspace-write", + }, + }) + ); +} + +#[tokio::test] +async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps_meta() { + let (_session, turn_context) = make_session_and_context().await; let metadata = McpToolApprovalMetadata { annotations: None, connector_id: Some("calendar".to_string()), @@ -461,8 +481,16 @@ fn codex_apps_tool_call_request_meta_includes_codex_apps_meta() { }; assert_eq!( - build_mcp_tool_call_request_meta(CODEX_APPS_MCP_SERVER_NAME, Some(&metadata)), + build_mcp_tool_call_request_meta( + &turn_context, + CODEX_APPS_MCP_SERVER_NAME, + Some(&metadata), + ), Some(serde_json::json!({ + crate::X_CODEX_TURN_METADATA_HEADER: { + "turn_id": turn_context.sub_id, + "sandbox": "workspace-write", + }, MCP_TOOL_CODEX_APPS_META_KEY: { "resource_uri": "connector://calendar/tools/calendar_create_event", "contains_mcp_source": true, diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index c0298c522122..2545355825cf 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -168,6 +168,11 @@ impl TurnMetadataState { Some(self.base_header.clone()) } + pub(crate) fn current_meta_value(&self) -> Option { + self.current_header_value() + .and_then(|header| serde_json::from_str(&header).ok()) + } + pub(crate) fn spawn_git_enrichment_task(&self) { if self.repo_root.is_none() { return; diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 118f1bd585c9..35071287aec4 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -424,6 +424,36 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - let requests = mock.requests(); assert_eq!(requests.len(), 3); + let apps_tool_call = server + .received_requests() + .await + .unwrap_or_default() + .into_iter() + .find_map(|request| { + let body: Value = serde_json::from_slice(&request.body) + .expect("apps request body should be valid json"); + (request.url.path() == "/api/codex/apps" + && body.get("method").and_then(Value::as_str) == Some("tools/call")) + .then_some(body) + }) + .expect("apps tools/call request should be recorded"); + + assert_eq!( + apps_tool_call.pointer("/params/_meta/_codex_apps"), + Some(&json!({ + "resource_uri": CALENDAR_CREATE_EVENT_RESOURCE_URI, + "contains_mcp_source": true, + "connector_id": "calendar", + })) + ); + assert!( + apps_tool_call + .pointer("/params/_meta/x-codex-turn-metadata/turn_id") + .and_then(Value::as_str) + .is_some_and(|turn_id| !turn_id.is_empty()), + "apps tools/call should include turn metadata turn_id: {apps_tool_call:?}" + ); + let first_request_tools = tool_names(&requests[0].body_json()); assert!( first_request_tools From edc8e17c5c30439f70575a2aca479996762f5b84 Mon Sep 17 00:00:00 2001 From: nicholasclark-openai Date: Thu, 19 Mar 2026 10:31:51 -0700 Subject: [PATCH 3/8] Merge RMCP tool call _meta entries Co-authored-by: Codex --- codex-rs/rmcp-client/src/rmcp_client.rs | 30 ++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index b898403b25c7..1896c5c9e3b7 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -20,6 +20,7 @@ use reqwest::header::AUTHORIZATION; use reqwest::header::CONTENT_TYPE; use reqwest::header::HeaderMap; use reqwest::header::WWW_AUTHENTICATE; +use rmcp::model::CallToolRequest; use rmcp::model::CallToolRequestParams; use rmcp::model::CallToolResult; use rmcp::model::ClientNotification; @@ -713,7 +714,7 @@ impl RmcpClient { } None => None, }; - let meta = match meta { + let request_meta = match meta { Some(Value::Object(map)) => Some(rmcp::model::Meta(map)), Some(other) => { return Err(anyhow!( @@ -723,7 +724,7 @@ impl RmcpClient { None => None, }; let rmcp_params = CallToolRequestParams { - meta, + meta: None, name: name.into(), arguments, task: None, @@ -731,7 +732,30 @@ impl RmcpClient { let result = self .run_service_operation("tools/call", timeout, move |service| { let rmcp_params = rmcp_params.clone(); - async move { service.call_tool(rmcp_params).await }.boxed() + let request_meta = request_meta.clone(); + async move { + let result = service + .peer() + .send_request_with_option( + ClientRequest::CallToolRequest(CallToolRequest { + method: Default::default(), + params: rmcp_params, + extensions: Default::default(), + }), + rmcp::service::PeerRequestOptions { + timeout: None, + meta: request_meta, + }, + ) + .await? + .await_response() + .await?; + match result { + ServerResult::CallToolResult(result) => Ok(result), + _ => Err(rmcp::service::ServiceError::UnexpectedResponse), + } + } + .boxed() }) .await?; self.persist_oauth_tokens().await; From e84f54294aa2f05fffd616e28293070a58383e02 Mon Sep 17 00:00:00 2001 From: nicholasclark-openai Date: Thu, 19 Mar 2026 11:18:05 -0700 Subject: [PATCH 4/8] Include session id in MCP turn metadata Co-authored-by: Codex --- codex-rs/core/src/codex.rs | 4 ++++ codex-rs/core/src/codex_tests.rs | 2 ++ codex-rs/core/src/mcp_tool_call_tests.rs | 28 +++++++++++++++--------- codex-rs/core/src/turn_metadata.rs | 8 +++++++ codex-rs/core/src/turn_metadata_tests.rs | 3 +++ codex-rs/core/tests/suite/search_tool.rs | 4 ++++ 6 files changed, 39 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8197c8cb64ab..931300cf4e51 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1285,6 +1285,7 @@ impl Session { #[allow(clippy::too_many_arguments)] fn make_turn_context( + conversation_id: ThreadId, auth_manager: Option>, session_telemetry: &SessionTelemetry, provider: ModelProviderInfo, @@ -1335,6 +1336,7 @@ impl Session { let cwd = session_configuration.cwd.clone(); let turn_metadata_state = Arc::new(TurnMetadataState::new( + conversation_id.to_string(), sub_id.clone(), cwd.clone(), session_configuration.sandbox_policy.get(), @@ -2391,6 +2393,7 @@ impl Session { .skills_for_config(&per_turn_config), ); let mut turn_context: TurnContext = Self::make_turn_context( + self.conversation_id, Some(Arc::clone(&self.services.auth_manager)), &self.services.session_telemetry, session_configuration.provider.clone(), @@ -5217,6 +5220,7 @@ async fn spawn_review_thread( let per_turn_config = Arc::new(per_turn_config); let review_turn_id = sub_id.to_string(); let turn_metadata_state = Arc::new(TurnMetadataState::new( + sess.conversation_id.to_string(), review_turn_id.clone(), parent_turn_context.cwd.clone(), parent_turn_context.sandbox_policy.get(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 787ad399b1e9..c7cbcf225267 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2513,6 +2513,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config)); let turn_context = Session::make_turn_context( + conversation_id, Some(Arc::clone(&auth_manager)), &session_telemetry, session_configuration.provider.clone(), @@ -3307,6 +3308,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config)); let turn_context = Arc::new(Session::make_turn_context( + conversation_id, Some(Arc::clone(&auth_manager)), &session_telemetry, session_configuration.provider.clone(), diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 99aa0e0c695b..5537e680edc8 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -441,7 +441,14 @@ fn sanitize_mcp_tool_result_for_model_preserves_image_when_supported() { #[tokio::test] async fn mcp_tool_call_request_meta_includes_turn_metadata_for_custom_server() { - let (_session, turn_context) = make_session_and_context().await; + let (_, turn_context) = make_session_and_context().await; + let expected_turn_metadata = serde_json::from_str::( + &turn_context + .turn_metadata_state + .current_header_value() + .expect("turn metadata header"), + ) + .expect("turn metadata json"); let meta = build_mcp_tool_call_request_meta(&turn_context, "custom_server", /*metadata*/ None) @@ -450,17 +457,21 @@ async fn mcp_tool_call_request_meta_includes_turn_metadata_for_custom_server() { assert_eq!( meta, serde_json::json!({ - crate::X_CODEX_TURN_METADATA_HEADER: { - "turn_id": turn_context.sub_id, - "sandbox": "workspace-write", - }, + crate::X_CODEX_TURN_METADATA_HEADER: expected_turn_metadata, }) ); } #[tokio::test] async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps_meta() { - let (_session, turn_context) = make_session_and_context().await; + let (_, turn_context) = make_session_and_context().await; + let expected_turn_metadata = serde_json::from_str::( + &turn_context + .turn_metadata_state + .current_header_value() + .expect("turn metadata header"), + ) + .expect("turn metadata json"); let metadata = McpToolApprovalMetadata { annotations: None, connector_id: Some("calendar".to_string()), @@ -487,10 +498,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps Some(&metadata), ), Some(serde_json::json!({ - crate::X_CODEX_TURN_METADATA_HEADER: { - "turn_id": turn_context.sub_id, - "sandbox": "workspace-write", - }, + crate::X_CODEX_TURN_METADATA_HEADER: expected_turn_metadata, MCP_TOOL_CODEX_APPS_META_KEY: { "resource_uri": "connector://calendar/tools/calendar_create_event", "contains_mcp_source": true, diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 2545355825cf..3a4bac011dd5 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -53,6 +53,8 @@ impl From for TurnMetadataWorkspace { #[derive(Clone, Debug, Serialize, Default)] pub(crate) struct TurnMetadataBag { + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] turn_id: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -68,6 +70,7 @@ impl TurnMetadataBag { } fn build_turn_metadata_bag( + session_id: Option, turn_id: Option, sandbox: Option, repo_root: Option, @@ -81,6 +84,7 @@ fn build_turn_metadata_bag( } TurnMetadataBag { + session_id, turn_id, workspaces, sandbox, @@ -104,6 +108,7 @@ pub async fn build_turn_metadata_header(cwd: &Path, sandbox: Option<&str>) -> Op } build_turn_metadata_bag( + /*session_id*/ None, /*turn_id*/ None, sandbox.map(ToString::to_string), repo_root, @@ -128,6 +133,7 @@ pub(crate) struct TurnMetadataState { impl TurnMetadataState { pub(crate) fn new( + session_id: String, turn_id: String, cwd: PathBuf, sandbox_policy: &SandboxPolicy, @@ -136,6 +142,7 @@ impl TurnMetadataState { let repo_root = get_git_repo_root(&cwd).map(|root| root.to_string_lossy().into_owned()); let sandbox = Some(sandbox_tag(sandbox_policy, windows_sandbox_level).to_string()); let base_metadata = build_turn_metadata_bag( + Some(session_id), Some(turn_id), sandbox, /*repo_root*/ None, @@ -194,6 +201,7 @@ impl TurnMetadataState { }; let enriched_metadata = build_turn_metadata_bag( + state.base_metadata.session_id.clone(), state.base_metadata.turn_id.clone(), state.base_metadata.sandbox.clone(), Some(repo_root), diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index 5124213de339..5da26563f659 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -67,6 +67,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { let sandbox_policy = SandboxPolicy::new_read_only_policy(); let state = TurnMetadataState::new( + "session-a".to_string(), "turn-a".to_string(), cwd, &sandbox_policy, @@ -76,7 +77,9 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { let header = state.current_header_value().expect("header"); let json: Value = serde_json::from_str(&header).expect("json"); let sandbox_name = json.get("sandbox").and_then(Value::as_str); + let session_id = json.get("session_id").and_then(Value::as_str); let expected_sandbox = sandbox_tag(&sandbox_policy, WindowsSandboxLevel::Disabled); assert_eq!(sandbox_name, Some(expected_sandbox)); + assert_eq!(session_id, Some("session-a")); } diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 35071287aec4..2b8cffa3de07 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -446,6 +446,10 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - "connector_id": "calendar", })) ); + assert_eq!( + apps_tool_call.pointer("/params/_meta/x-codex-turn-metadata/session_id"), + Some(&json!(test.session_configured.session_id.to_string())) + ); assert!( apps_tool_call .pointer("/params/_meta/x-codex-turn-metadata/turn_id") From 98f97df37fdbc59afb69e9acad434475017951a8 Mon Sep 17 00:00:00 2001 From: nicholasclark-openai Date: Thu, 19 Mar 2026 11:35:21 -0700 Subject: [PATCH 5/8] Skip non-JSON apps requests in search tool test Co-authored-by: Codex --- codex-rs/core/tests/suite/search_tool.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 2b8cffa3de07..dd182befb5c6 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -430,8 +430,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - .unwrap_or_default() .into_iter() .find_map(|request| { - let body: Value = serde_json::from_slice(&request.body) - .expect("apps request body should be valid json"); + let body: Value = serde_json::from_slice(&request.body).ok()?; (request.url.path() == "/api/codex/apps" && body.get("method").and_then(Value::as_str) == Some("tools/call")) .then_some(body) From 88653c9245444c06747da275d812ec04998a82e9 Mon Sep 17 00:00:00 2001 From: nicholasclark-openai Date: Thu, 19 Mar 2026 11:50:06 -0700 Subject: [PATCH 6/8] Simplify RMCP tool call _meta plumbing Co-authored-by: Codex --- codex-rs/rmcp-client/src/rmcp_client.rs | 28 ++----------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 1896c5c9e3b7..7a865eefe2fb 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -20,7 +20,6 @@ use reqwest::header::AUTHORIZATION; use reqwest::header::CONTENT_TYPE; use reqwest::header::HeaderMap; use reqwest::header::WWW_AUTHENTICATE; -use rmcp::model::CallToolRequest; use rmcp::model::CallToolRequestParams; use rmcp::model::CallToolResult; use rmcp::model::ClientNotification; @@ -724,7 +723,7 @@ impl RmcpClient { None => None, }; let rmcp_params = CallToolRequestParams { - meta: None, + meta: request_meta, name: name.into(), arguments, task: None, @@ -732,30 +731,7 @@ impl RmcpClient { let result = self .run_service_operation("tools/call", timeout, move |service| { let rmcp_params = rmcp_params.clone(); - let request_meta = request_meta.clone(); - async move { - let result = service - .peer() - .send_request_with_option( - ClientRequest::CallToolRequest(CallToolRequest { - method: Default::default(), - params: rmcp_params, - extensions: Default::default(), - }), - rmcp::service::PeerRequestOptions { - timeout: None, - meta: request_meta, - }, - ) - .await? - .await_response() - .await?; - match result { - ServerResult::CallToolResult(result) => Ok(result), - _ => Err(rmcp::service::ServiceError::UnexpectedResponse), - } - } - .boxed() + async move { service.call_tool(rmcp_params).await }.boxed() }) .await?; self.persist_oauth_tokens().await; From db3ddeae35b13a91d19788da3a23ad5ea0984782 Mon Sep 17 00:00:00 2001 From: nicholasclark-openai Date: Thu, 19 Mar 2026 11:58:51 -0700 Subject: [PATCH 7/8] Restore RMCP meta local variable name Co-authored-by: Codex --- codex-rs/rmcp-client/src/rmcp_client.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 7a865eefe2fb..b898403b25c7 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -713,7 +713,7 @@ impl RmcpClient { } None => None, }; - let request_meta = match meta { + let meta = match meta { Some(Value::Object(map)) => Some(rmcp::model::Meta(map)), Some(other) => { return Err(anyhow!( @@ -723,7 +723,7 @@ impl RmcpClient { None => None, }; let rmcp_params = CallToolRequestParams { - meta: request_meta, + meta, name: name.into(), arguments, task: None, From cbd07008379354b09034fd9ee1eec591f83f0a05 Mon Sep 17 00:00:00 2001 From: nicholasclark-openai Date: Thu, 19 Mar 2026 12:38:16 -0700 Subject: [PATCH 8/8] Restore explicit RMCP tools/call request path Co-authored-by: Codex --- codex-rs/rmcp-client/src/rmcp_client.rs | 27 +++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index b898403b25c7..55a3603ed7a6 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -723,7 +723,7 @@ impl RmcpClient { None => None, }; let rmcp_params = CallToolRequestParams { - meta, + meta: None, name: name.into(), arguments, task: None, @@ -731,7 +731,30 @@ impl RmcpClient { let result = self .run_service_operation("tools/call", timeout, move |service| { let rmcp_params = rmcp_params.clone(); - async move { service.call_tool(rmcp_params).await }.boxed() + let meta = meta.clone(); + async move { + let result = service + .peer() + .send_request_with_option( + ClientRequest::CallToolRequest(rmcp::model::CallToolRequest { + method: Default::default(), + params: rmcp_params, + extensions: Default::default(), + }), + rmcp::service::PeerRequestOptions { + timeout: None, + meta, + }, + ) + .await? + .await_response() + .await?; + match result { + ServerResult::CallToolResult(result) => Ok(result), + _ => Err(rmcp::service::ServiceError::UnexpectedResponse), + } + } + .boxed() }) .await?; self.persist_oauth_tokens().await;