From fb8598df3ff05366a295c407c91f3d9316a1630c Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 26 Jun 2026 02:28:14 +0100 Subject: [PATCH] Keep MCP elicitation routable across runtime refreshes (#30127) ## Why An MCP tool call can still be waiting for an elicitation response when an environment update replaces the thread's MCP runtime. Before this change: ```text runtime A starts a tool call and asks the user environment becomes ready, so runtime B is published client answers the prompt through runtime B runtime B cannot find runtime A's pending responder ``` The response is lost and the original tool call stays blocked. ## What changed All MCP runtimes for one thread now share a small elicitation router: ```text runtime A ---\ shared router: response token -> exact pending responder runtime B ---/ ``` When Codex surfaces an MCP elicitation, it assigns a unique opaque response token. The router records which pending request owns that token. A replacement runtime reuses the same router, so the latest runtime can deliver a response to a request started by the previous runtime. The Codex-owned token also prevents two runtime connections that reuse the same MCP server request ID from receiving each other's responses. This does not retain or search old MCP managers. Only the pending responder map is shared. ## Covered scenario The integration test exercises the complete failure mode: 1. A thread starts while its selected environment is still unavailable. 2. A configured MCP server starts a tool call and asks the client for input. 3. The environment becomes ready, causing Codex to publish a replacement MCP runtime. 4. The client answers the original prompt after the replacement. 5. The original tool call receives that answer and completes. A focused routing test also creates two runtimes with the same server request ID and verifies that each response reaches the exact request that emitted its token. ## Scope This PR changes only elicitation response routing across MCP runtime replacement. It does not change when runtimes are rebuilt, which environments contribute MCP configuration, or how environment availability is detected. --- .../app-server/tests/suite/v2/mcp_tool.rs | 233 ++++++++++++++++++ codex-rs/codex-mcp/src/connection_manager.rs | 8 + .../codex-mcp/src/connection_manager_tests.rs | 98 ++++++++ codex-rs/codex-mcp/src/elicitation.rs | 70 ++++-- codex-rs/codex-mcp/src/lib.rs | 1 + codex-rs/codex-mcp/src/mcp/mod.rs | 2 + codex-rs/core/src/connectors.rs | 1 + codex-rs/core/src/mcp_tool_call_tests.rs | 1 + codex-rs/core/src/session/mcp.rs | 9 +- codex-rs/core/src/session/session.rs | 1 + 10 files changed, 398 insertions(+), 26 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index 3b5afec3f..f3a387245 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -10,6 +10,8 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::to_response; use app_test_support::write_mock_responses_config_toml; use axum::Router; +use codex_app_server_protocol::CapabilityRootLocation; +use codex_app_server_protocol::EnvironmentAddResponse; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; @@ -22,15 +24,21 @@ use codex_app_server_protocol::McpServerToolCallParams; use codex_app_server_protocol::McpServerToolCallResponse; use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SelectedCapabilityRoot; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnEnvironmentParams; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; +use codex_features::Feature; +use codex_utils_path_uri::PathUri; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use core_test_support::responses; +use futures::SinkExt; +use futures::StreamExt; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; use rmcp::model::BooleanSchema; @@ -56,8 +64,13 @@ use rmcp::transport::streamable_http_server::session::local::LocalSessionManager use serde_json::json; use tempfile::TempDir; use tokio::net::TcpListener; +use tokio::net::TcpStream; +use tokio::sync::oneshot; use tokio::task::JoinHandle; use tokio::time::timeout; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::tungstenite::Message; const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); const TEST_SERVER_NAME: &str = "tool_server"; @@ -68,6 +81,7 @@ const ELICITATION_MESSAGE: &str = "Allow this request?"; const URL_ELICITATION_TRIGGER_MESSAGE: &str = "auth"; const URL_ELICITATION_MESSAGE: &str = "Sign in to GitHub to continue."; const URL_ELICITATION_URL: &str = "https://github.example/login/device"; +const LATE_ENVIRONMENT_ID: &str = "late-environment"; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_server_tool_call_returns_tool_result() -> Result<()> { @@ -297,6 +311,145 @@ url = "{mcp_server_url}/mcp" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_server_elicitation_survives_environment_runtime_refresh() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?; + let exec_listener = TcpListener::bind("127.0.0.1:0").await?; + let exec_server_url = format!("ws://{}", exec_listener.local_addr()?); + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &responses_server.uri(), + &BTreeMap::from([(Feature::DeferredExecutor, true)]), + /*auto_compact_limit*/ 1024, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + let config_path = codex_home.path().join("config.toml"); + let mut config_toml = std::fs::read_to_string(&config_path)?; + config_toml.push_str(&format!( + r#" +[mcp_servers.{TEST_SERVER_NAME}] +url = "{mcp_server_url}/mcp" +"# + )); + std::fs::write(config_path, config_toml)?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let add_environment_id = mcp + .send_raw_request( + "environment/add", + Some(json!({ + "environmentId": LATE_ENVIRONMENT_ID, + "execServerUrl": exec_server_url, + "connectTimeoutMs": 10_000, + })), + ) + .await?; + let add_environment_response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(add_environment_id)), + ) + .await??; + let _: EnvironmentAddResponse = to_response(add_environment_response)?; + + let capability_root = TempDir::new()?; + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted), + environments: Some(vec![TurnEnvironmentParams { + environment_id: LATE_ENVIRONMENT_ID.to_string(), + cwd: codex_utils_absolute_path::AbsolutePathBuf::try_from( + capability_root.path().to_path_buf(), + )? + .into(), + }]), + selected_capability_roots: Some(vec![SelectedCapabilityRoot { + id: "late-plugin@1".to_string(), + location: CapabilityRootLocation::Environment { + environment_id: LATE_ENVIRONMENT_ID.to_string(), + path: PathUri::from_host_native_path(capability_root.path())?, + }, + }]), + ..Default::default() + }) + .await?; + let thread_start_response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_response)?; + + let tool_call_request_id = mcp + .send_mcp_server_tool_call_request(McpServerToolCallParams { + thread_id: thread.id.clone(), + server: TEST_SERVER_NAME.to_string(), + tool: TEST_TOOL_NAME.to_string(), + arguments: Some(json!({"message": ELICITATION_TRIGGER_MESSAGE})), + meta: None, + }) + .await?; + let server_request = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::McpServerElicitationRequest { request_id, .. } = server_request else { + panic!("expected MCP elicitation request, got: {server_request:?}"); + }; + + let (filesystem_request_tx, filesystem_request_rx) = oneshot::channel(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let exec_server_handle = tokio::spawn(serve_environment_until_shutdown( + exec_listener, + filesystem_request_tx, + shutdown_rx, + )); + let mut filesystem_request_rx = filesystem_request_rx; + timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let status_request_id = mcp + .send_raw_request("mcpServerStatus/list", Some(json!({"threadId": thread.id}))) + .await?; + mcp.read_stream_until_response_message(RequestId::Integer(status_request_id)) + .await?; + if filesystem_request_rx.try_recv().is_ok() { + return Ok::<_, anyhow::Error>(()); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await??; + + mcp.send_response( + request_id, + serde_json::to_value(McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: Some(json!({"confirmed": true})), + meta: None, + })?, + ) + .await?; + let tool_call_response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(tool_call_request_id)), + ) + .await??; + let response: McpServerToolCallResponse = to_response(tool_call_response)?; + assert_eq!(response.content[0].get("text"), Some(&json!("accepted"))); + + let _ = shutdown_tx.send(()); + exec_server_handle.await??; + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_server_tool_call_forwards_url_elicitation() -> Result<()> { let responses_server = responses::start_mock_server().await; @@ -683,6 +836,86 @@ async fn start_mcp_server() -> Result<(String, JoinHandle<()>)> { Ok((format!("http://{addr}"), handle)) } +async fn serve_environment_until_shutdown( + listener: TcpListener, + filesystem_request_tx: oneshot::Sender<()>, + mut shutdown_rx: oneshot::Receiver<()>, +) -> Result<()> { + let (stream, _) = listener.accept().await?; + let mut websocket = accept_async(stream).await?; + + let initialize = read_exec_server_json(&mut websocket).await?; + assert_eq!(initialize["method"], "initialize"); + websocket + .send(Message::Text( + json!({ + "id": initialize["id"], + "result": {"sessionId": "test-session"}, + }) + .to_string() + .into(), + )) + .await?; + let initialized = read_exec_server_json(&mut websocket).await?; + assert_eq!(initialized["method"], "initialized"); + let environment_info = read_exec_server_json(&mut websocket).await?; + assert_eq!(environment_info["method"], "environment/info"); + websocket + .send(Message::Text( + json!({ + "id": environment_info["id"], + "result": {"shell": {"name": "zsh", "path": "/bin/zsh"}}, + }) + .to_string() + .into(), + )) + .await?; + + let mut filesystem_request_tx = Some(filesystem_request_tx); + loop { + let request = tokio::select! { + request = read_exec_server_json(&mut websocket) => request?, + _ = &mut shutdown_rx => return Ok(()), + }; + if request["method"] + .as_str() + .is_some_and(|method| method.starts_with("fs/")) + && let Some(tx) = filesystem_request_tx.take() + { + let _ = tx.send(()); + } + if request.get("id").is_some() { + websocket + .send(Message::Text( + json!({ + "id": request["id"], + "error": {"code": -32004, "message": "not found"}, + }) + .to_string() + .into(), + )) + .await?; + } + } +} + +async fn read_exec_server_json( + websocket: &mut WebSocketStream, +) -> Result { + loop { + match websocket + .next() + .await + .ok_or_else(|| anyhow::anyhow!("exec-server websocket closed"))?? + { + Message::Text(text) => return Ok(serde_json::from_str(text.as_ref())?), + Message::Binary(bytes) => return Ok(serde_json::from_slice(bytes.as_ref())?), + Message::Ping(_) | Message::Pong(_) => {} + message => anyhow::bail!("expected JSON-RPC message, got {message:?}"), + } + } +} + async fn wait_for_mcp_tool_call_completed( mcp: &mut TestAppServer, call_id: &str, diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 5902bc960..3107f678b 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -18,6 +18,7 @@ use crate::codex_apps_cache::CodexAppsToolsCache; use crate::codex_apps_cache::CodexAppsToolsCacheKey; use crate::codex_apps_cache::CodexAppsToolsFetchSource; use crate::elicitation::ElicitationRequestManager; +use crate::elicitation::ElicitationRequestRouter; use crate::elicitation::ElicitationReviewerHandle; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::ToolPluginProvenance; @@ -141,6 +142,7 @@ impl McpConnectionManager { tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, elicitation_reviewer: Option, + elicitation_router: ElicitationRequestRouter, ) -> Self { let mut required_servers = mcp_servers .iter() @@ -155,6 +157,7 @@ impl McpConnectionManager { approval_policy.value(), initial_permission_profile, elicitation_reviewer, + elicitation_router, ); let tool_plugin_provenance = Arc::new(tool_plugin_provenance); let startup_submit_id = submit_id.clone(); @@ -352,6 +355,7 @@ impl McpConnectionManager { approval_policy.value(), permission_profile.clone(), /*reviewer*/ None, + ElicitationRequestRouter::default(), ), startup_cancellation_token: CancellationToken::new(), } @@ -444,6 +448,10 @@ impl McpConnectionManager { self.elicitation_requests.set_auto_deny(auto_deny); } + pub fn elicitation_router(&self) -> ElicitationRequestRouter { + self.elicitation_requests.router() + } + pub async fn resolve_elicitation( &self, server_name: String, diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index e11fcb591..d2869fe5c 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -3,6 +3,7 @@ use crate::codex_apps_cache::CodexAppsToolsCache; use crate::codex_apps_cache::CodexAppsToolsCacheContext; use crate::declared_openai_file_input_param_names; use crate::elicitation::ElicitationRequestManager; +use crate::elicitation::ElicitationRequestRouter; use crate::elicitation::elicitation_is_rejected_by_policy; use crate::rmcp_client::AsyncManagedClient; use crate::rmcp_client::ManagedClient; @@ -274,6 +275,7 @@ async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() { AskForApproval::Never, PermissionProfile::Disabled, /*reviewer*/ None, + ElicitationRequestRouter::default(), ); let (tx_event, _rx_event) = async_channel::bounded(1); let sender = manager.make_sender("server".to_string(), tx_event); @@ -309,6 +311,7 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel AskForApproval::Never, PermissionProfile::Disabled, /*reviewer*/ None, + ElicitationRequestRouter::default(), ); let (tx_event, _rx_event) = async_channel::bounded(1); let sender = manager.make_sender("server".to_string(), tx_event); @@ -342,6 +345,100 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel ); } +#[tokio::test] +async fn shared_elicitation_router_targets_the_exact_pending_request() { + let router = ElicitationRequestRouter::default(); + let manager_a = ElicitationRequestManager::new( + AskForApproval::OnRequest, + PermissionProfile::default(), + /*reviewer*/ None, + router.clone(), + ); + let manager_b = ElicitationRequestManager::new( + AskForApproval::OnRequest, + PermissionProfile::default(), + /*reviewer*/ None, + router, + ); + let (tx_event, rx_event) = async_channel::bounded(2); + let sender_a = manager_a.make_sender("server".to_string(), tx_event.clone()); + let sender_b = manager_b.make_sender("server".to_string(), tx_event); + let elicitation = codex_rmcp_client::Elicitation::Mcp( + CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: "Which runtime?".to_string(), + requested_schema: rmcp::model::ElicitationSchema::builder() + .required_property( + "runtime", + rmcp::model::PrimitiveSchema::String(rmcp::model::StringSchema::new()), + ) + .build() + .expect("schema should build"), + }, + ); + + let pending_a = tokio::spawn(sender_a(NumberOrString::Number(1), elicitation.clone())); + let EventMsg::ElicitationRequest(request_a) = rx_event.recv().await.expect("request A").msg + else { + panic!("expected elicitation request"); + }; + let pending_b = tokio::spawn(sender_b(NumberOrString::Number(1), elicitation)); + let EventMsg::ElicitationRequest(request_b) = rx_event.recv().await.expect("request B").msg + else { + panic!("expected elicitation request"); + }; + let ( + codex_protocol::mcp::RequestId::String(request_a_id), + codex_protocol::mcp::RequestId::String(request_b_id), + ) = (request_a.id, request_b.id) + else { + panic!("expected Codex-owned string request IDs"); + }; + assert_ne!(request_a_id, request_b_id); + + let response_a = ElicitationResponse { + action: ElicitationAction::Accept, + content: Some(serde_json::json!({"runtime": "a"})), + meta: None, + }; + manager_b + .resolve( + "server".to_string(), + NumberOrString::String(request_a_id.into()), + response_a.clone(), + ) + .await + .expect("runtime B should route a response to runtime A"); + let response_b = ElicitationResponse { + action: ElicitationAction::Accept, + content: Some(serde_json::json!({"runtime": "b"})), + meta: None, + }; + manager_a + .resolve( + "server".to_string(), + NumberOrString::String(request_b_id.into()), + response_b.clone(), + ) + .await + .expect("runtime A should route a response to runtime B"); + + assert_eq!( + pending_a + .await + .expect("request A task") + .expect("request A response"), + response_a + ); + assert_eq!( + pending_b + .await + .expect("request B task") + .expect("request B response"), + response_b + ); +} + #[test] fn test_normalize_tools_short_non_duplicated_names() { let tools = vec![ @@ -1158,6 +1255,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { ToolPluginProvenance::default(), /*auth*/ None, /*elicitation_reviewer*/ None, + ElicitationRequestRouter::default(), ) .await; diff --git a/codex-rs/codex-mcp/src/elicitation.rs b/codex-rs/codex-mcp/src/elicitation.rs index d6a685b50..e4fcf49d2 100644 --- a/codex-rs/codex-mcp/src/elicitation.rs +++ b/codex-rs/codex-mcp/src/elicitation.rs @@ -8,6 +8,8 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex as StdMutex; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; use crate::mcp::McpPermissionPromptAutoApproveContext; use crate::mcp::mcp_permission_prompt_is_auto_approved; @@ -32,6 +34,8 @@ use rmcp::model::RequestId; use tokio::sync::Mutex; use tokio::sync::oneshot; +static NEXT_ELICITATION_REQUEST_ID: AtomicU64 = AtomicU64::new(0); + #[derive(Debug, Clone)] pub struct ElicitationReviewRequest { pub server_name: String, @@ -48,9 +52,36 @@ pub trait ElicitationReviewer: Send + Sync { pub type ElicitationReviewerHandle = Arc; +/// Routes model-visible elicitation response tokens to their exact pending responders. +/// +/// One router is shared by every MCP runtime created for a thread. The public response token is +/// generated by Codex rather than copied from the MCP connection, so separate runtimes may reuse +/// the same server request ID without colliding. +#[derive(Clone, Default)] +pub struct ElicitationRequestRouter { + requests: Arc>, +} + +impl ElicitationRequestRouter { + async fn resolve( + &self, + server_name: String, + id: RequestId, + response: ElicitationResponse, + ) -> Result<()> { + self.requests + .lock() + .await + .remove(&(server_name, id)) + .ok_or_else(|| anyhow!("elicitation request not found"))? + .send(response) + .map_err(|e| anyhow!("failed to send elicitation response: {e:?}")) + } +} + #[derive(Clone)] pub(crate) struct ElicitationRequestManager { - requests: Arc>, + router: ElicitationRequestRouter, pub(crate) approval_policy: Arc>, pub(crate) permission_profile: Arc>, auto_deny: Arc>, @@ -62,9 +93,10 @@ impl ElicitationRequestManager { approval_policy: AskForApproval, permission_profile: PermissionProfile, reviewer: Option, + router: ElicitationRequestRouter, ) -> Self { Self { - requests: Arc::new(Mutex::new(HashMap::new())), + router, approval_policy: Arc::new(StdMutex::new(approval_policy)), permission_profile: Arc::new(StdMutex::new(permission_profile)), auto_deny: Arc::new(StdMutex::new(false)), @@ -91,13 +123,11 @@ impl ElicitationRequestManager { id: RequestId, response: ElicitationResponse, ) -> Result<()> { - self.requests - .lock() - .await - .remove(&(server_name, id)) - .ok_or_else(|| anyhow!("elicitation request not found"))? - .send(response) - .map_err(|e| anyhow!("failed to send elicitation response: {e:?}")) + self.router.resolve(server_name, id, response).await + } + + pub(crate) fn router(&self) -> ElicitationRequestRouter { + self.router.clone() } pub(crate) fn make_sender( @@ -105,13 +135,13 @@ impl ElicitationRequestManager { server_name: String, tx_event: Sender, ) -> SendElicitation { - let elicitation_requests = self.requests.clone(); + let router = self.router.clone(); let approval_policy = self.approval_policy.clone(); let permission_profile = self.permission_profile.clone(); let auto_deny = self.auto_deny.clone(); let reviewer = self.reviewer.clone(); Box::new(move |id, elicitation| { - let elicitation_requests = elicitation_requests.clone(); + let router = router.clone(); let tx_event = tx_event.clone(); let server_name = server_name.clone(); let approval_policy = approval_policy.clone(); @@ -171,6 +201,11 @@ impl ElicitationRequestManager { } } + let public_request_id = format!( + "codex-mcp-elicitation-{}", + NEXT_ELICITATION_REQUEST_ID.fetch_add(1, Ordering::Relaxed) + ); + let routed_request_id = RequestId::String(public_request_id.clone().into()); let request = match elicitation { Elicitation::Mcp( rmcp::model::CreateElicitationRequestParams::FormElicitationParams { @@ -215,8 +250,8 @@ impl ElicitationRequestManager { }; let (tx, rx) = oneshot::channel(); { - let mut lock = elicitation_requests.lock().await; - lock.insert((server_name.clone(), id.clone()), tx); + let mut lock = router.requests.lock().await; + lock.insert((server_name.clone(), routed_request_id), tx); } let _ = tx_event .send(Event { @@ -224,14 +259,7 @@ impl ElicitationRequestManager { msg: EventMsg::ElicitationRequest(ElicitationRequestEvent { turn_id: None, server_name, - id: match id.clone() { - rmcp::model::NumberOrString::String(value) => { - ProtocolRequestId::String(value.to_string()) - } - rmcp::model::NumberOrString::Number(value) => { - ProtocolRequestId::Integer(value) - } - }, + id: ProtocolRequestId::String(public_request_id), request, }), }) diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index e611623d4..eda7ac239 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -1,5 +1,6 @@ pub use connection_manager::McpConnectionManager; pub use connection_manager::tool_is_model_visible; +pub use elicitation::ElicitationRequestRouter; pub use elicitation::ElicitationReviewRequest; pub use elicitation::ElicitationReviewer; pub use elicitation::ElicitationReviewerHandle; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 3f24a276e..ffb797f26 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -339,6 +339,7 @@ pub async fn read_mcp_resource( tool_plugin_provenance(config), auth, /*elicitation_reviewer*/ None, + crate::elicitation::ElicitationRequestRouter::default(), ) .await; @@ -415,6 +416,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( tool_plugin_provenance, auth, /*elicitation_reviewer*/ None, + crate::elicitation::ElicitationRequestRouter::default(), ) .await; diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 97f24e043..b45cc841a 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -283,6 +283,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, + codex_mcp::ElicitationRequestRouter::default(), ) .await; diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 8c8d82037..271141613 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1416,6 +1416,7 @@ async fn host_owned_codex_apps_manager( codex_mcp::ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, + codex_mcp::ElicitationRequestRouter::default(), ) .await; Arc::new(manager) diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 50921f159..83a59c960 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -316,6 +316,7 @@ impl Session { *guard = cancellation_token.clone(); cancellation_token }; + let current_runtime = self.services.latest_mcp_runtime(); let refreshed_manager = McpConnectionManager::new( &mcp_servers, mcp_config.mcp_oauth_credentials_store_mode, @@ -338,13 +339,11 @@ impl Session { tool_plugin_provenance, auth.as_ref(), elicitation_reviewer, + current_runtime.manager().elicitation_router(), ) .await; - { - let current_manager = self.services.latest_mcp_runtime(); - refreshed_manager - .set_elicitations_auto_deny(current_manager.manager().elicitations_auto_deny()); - } + refreshed_manager + .set_elicitations_auto_deny(current_runtime.manager().elicitations_auto_deny()); self.services.publish_mcp_runtime( mcp_config, mcp_runtime_context, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 0e1609207..ed07352b1 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1207,6 +1207,7 @@ impl Session { tool_plugin_provenance, auth, Some(sess.mcp_elicitation_reviewer()), + codex_mcp::ElicitationRequestRouter::default(), ) .instrument(info_span!( "session_init.mcp_manager_init",