diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 7199782c3a..6e2087204e 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -443,6 +443,9 @@ "steer": { "type": "boolean" }, + "tool_call_mcp_elicitation": { + "type": "boolean" + }, "undo": { "type": "boolean" }, @@ -1834,6 +1837,9 @@ "steer": { "type": "boolean" }, + "tool_call_mcp_elicitation": { + "type": "boolean" + }, "undo": { "type": "boolean" }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a9e7883d3f..dc33717359 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -55,6 +55,8 @@ use async_channel::Receiver; use async_channel::Sender; use chrono::Local; use chrono::Utc; +use codex_app_server_protocol::McpServerElicitationRequest; +use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_hooks::HookEvent; use codex_hooks::HookEventAfterAgent; use codex_hooks::HookPayload; @@ -67,6 +69,7 @@ use codex_network_proxy::normalize_host; use codex_otel::current_span_w3c_trace_context; use codex_otel::set_parent_from_w3c_trace_context; use codex_protocol::ThreadId; +use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::approvals::NetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction; @@ -2807,6 +2810,69 @@ impl Session { rx_response.await.ok() } + pub async fn request_mcp_server_elicitation( + &self, + turn_context: &TurnContext, + request_id: RequestId, + params: McpServerElicitationRequestParams, + ) -> Option { + let (tx_response, rx_response) = oneshot::channel(); + let server_name = params.server_name.clone(); + let prev_entry = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.insert_pending_elicitation( + server_name.clone(), + request_id.clone(), + tx_response, + ) + } + None => None, + } + }; + if prev_entry.is_some() { + warn!( + "Overwriting existing pending elicitation for server_name: {server_name}, request_id: {request_id}" + ); + } + + let request = match params.request { + McpServerElicitationRequest::Form { + message, + requested_schema, + } => codex_protocol::approvals::ElicitationRequest::Form { + message, + requested_schema, + }, + McpServerElicitationRequest::Url { + message, + url, + elicitation_id, + } => codex_protocol::approvals::ElicitationRequest::Url { + message, + url, + elicitation_id, + }, + }; + let id = match request_id { + rmcp::model::NumberOrString::String(value) => { + codex_protocol::mcp::RequestId::String(value.to_string()) + } + rmcp::model::NumberOrString::Number(value) => { + codex_protocol::mcp::RequestId::Integer(value) + } + }; + let event = EventMsg::ElicitationRequest(ElicitationRequestEvent { + server_name, + id, + request, + }); + self.send_event(turn_context, event).await; + rx_response.await.ok() + } + pub async fn notify_user_input_response( &self, sub_id: &str, @@ -2880,6 +2946,23 @@ impl Session { id: RequestId, response: ElicitationResponse, ) -> anyhow::Result<()> { + let entry = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.remove_pending_elicitation(&server_name, &id) + } + None => None, + } + }; + if let Some(tx_response) = entry { + tx_response + .send(response) + .map_err(|e| anyhow::anyhow!("failed to send elicitation response: {e:?}"))?; + return Ok(()); + } + self.services .mcp_connection_manager .read() diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 8aa5e181d4..a400c30536 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -147,6 +147,8 @@ pub enum Feature { /// Enable collaboration modes (Plan, Default). /// Kept for config backward compatibility; behavior is always collaboration-modes-enabled. CollaborationModes, + /// Route MCP tool approval prompts through the MCP elicitation request path. + ToolCallMcpElicitation, /// Enable personality selection in the TUI. Personality, /// Enable native artifact tools. @@ -693,6 +695,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Removed, default_enabled: true, }, + FeatureSpec { + id: Feature::ToolCallMcpElicitation, + key: "tool_call_mcp_elicitation", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::Personality, key: "personality", diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index f76eddec6a..0c1e810cc9 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -1,6 +1,8 @@ use std::time::Duration; use std::time::Instant; +use codex_app_server_protocol::McpServerElicitationRequest; +use codex_app_server_protocol::McpServerElicitationRequestParams; use tracing::error; use crate::analytics_client::AppInvocation; @@ -10,6 +12,7 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::config::types::AppToolApproval; use crate::connectors; +use crate::features::Feature; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::protocol::EventMsg; use crate::protocol::McpInvocation; @@ -24,12 +27,16 @@ use codex_protocol::openai_models::InputModality; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_protocol::request_user_input::RequestUserInputQuestion; use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_rmcp_client::ElicitationAction; +use codex_rmcp_client::ElicitationResponse; use rmcp::model::ToolAnnotations; use serde::Serialize; +use serde_json::json; use std::sync::Arc; /// Handles the specified tool call dispatches the appropriate @@ -402,6 +409,34 @@ async fn maybe_request_mcp_tool_approval( annotations, approval_key.is_some(), ); + if turn_context + .config + .features + .enabled(Feature::ToolCallMcpElicitation) + { + let request_id = rmcp::model::RequestId::String( + format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}").into(), + ); + let params = build_mcp_tool_approval_elicitation_request( + sess, + turn_context, + server, + question.clone(), + ); + let decision = parse_mcp_tool_approval_elicitation_response( + sess.request_mcp_server_elicitation(turn_context, request_id, params) + .await, + &question_id, + ); + let decision = normalize_approval_decision_for_mode(decision, approval_mode); + if matches!(decision, McpToolApprovalDecision::AcceptAndRemember) + && let Some(key) = approval_key + { + remember_mcp_tool_approval(sess, key).await; + } + return Some(decision); + } + let args = RequestUserInputArgs { questions: vec![question], }; @@ -544,6 +579,126 @@ fn build_mcp_tool_approval_question( } } +fn build_mcp_tool_approval_elicitation_request( + sess: &Session, + turn_context: &TurnContext, + server: &str, + question: RequestUserInputQuestion, +) -> McpServerElicitationRequestParams { + let requested_schema = + request_user_input_questions_to_elicitation_schema(std::slice::from_ref(&question)); + let message = if question.header.trim().is_empty() { + question.question + } else { + format!( + "{header}\n\n{prompt}", + header = question.header, + prompt = question.question + ) + }; + + McpServerElicitationRequestParams { + thread_id: sess.conversation_id.to_string(), + turn_id: Some(turn_context.sub_id.clone()), + server_name: server.to_string(), + request: McpServerElicitationRequest::Form { + message, + requested_schema, + }, + } +} + +fn request_user_input_questions_to_elicitation_schema( + questions: &[RequestUserInputQuestion], +) -> serde_json::Value { + let questions_metadata = match serde_json::to_value(questions) { + Ok(value) => value, + Err(_) => serde_json::Value::Null, + }; + let properties = questions + .iter() + .map(|question| { + let mut property = json!({ + "title": question.header, + "description": question.question, + }); + if let Some(options) = question.options.as_ref() { + property["type"] = json!("string"); + property["enum"] = json!( + options + .iter() + .map(|option| option.label.clone()) + .collect::>() + ); + property["x-codex-options"] = json!(options); + } else { + property["type"] = json!("string"); + } + if question.is_secret { + property["writeOnly"] = json!(true); + } + (question.id.clone(), property) + }) + .collect::>(); + + json!({ + "type": "object", + "properties": properties, + "required": questions + .iter() + .map(|question| question.id.clone()) + .collect::>(), + "additionalProperties": false, + "x-codex-request-user-input": { + "questions": questions_metadata, + }, + }) +} + +fn parse_mcp_tool_approval_elicitation_response( + response: Option, + question_id: &str, +) -> McpToolApprovalDecision { + let Some(response) = response else { + return McpToolApprovalDecision::Cancel; + }; + match response.action { + ElicitationAction::Accept => parse_mcp_tool_approval_response( + request_user_input_response_from_elicitation_content(response.content), + question_id, + ), + ElicitationAction::Decline => McpToolApprovalDecision::Decline, + ElicitationAction::Cancel => McpToolApprovalDecision::Cancel, + } +} + +fn request_user_input_response_from_elicitation_content( + content: Option, +) -> Option { + let Some(content) = content else { + return Some(RequestUserInputResponse { + answers: std::collections::HashMap::new(), + }); + }; + let content = content.as_object()?; + let answers = content + .iter() + .filter_map(|(question_id, value)| { + let answers = match value { + serde_json::Value::String(answer) => vec![answer.clone()], + serde_json::Value::Array(values) => values + .iter() + .filter_map(|value| value.as_str().map(ToString::to_string)) + .collect(), + _ => return None, + }; + Some((question_id.clone(), RequestUserInputAnswer { answers })) + }) + .collect(); + + Some(RequestUserInputResponse { answers }) +} + fn parse_mcp_tool_approval_response( response: Option, question_id: &str, @@ -780,4 +935,112 @@ mod tests { assert_eq!(got, original); } + + #[test] + fn elicitation_schema_reuses_request_user_input_question_shape() { + let schema = + request_user_input_questions_to_elicitation_schema(&[RequestUserInputQuestion { + id: "approval".to_string(), + header: "Approve app tool call?".to_string(), + question: "Allow this action?".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ + RequestUserInputQuestionOption { + label: MCP_TOOL_APPROVAL_ACCEPT.to_string(), + description: "Run the tool and continue.".to_string(), + }, + RequestUserInputQuestionOption { + label: MCP_TOOL_APPROVAL_DECLINE.to_string(), + description: "Decline this tool call and continue.".to_string(), + }, + ]), + }]); + + assert_eq!( + schema, + serde_json::json!({ + "type": "object", + "properties": { + "approval": { + "title": "Approve app tool call?", + "description": "Allow this action?", + "type": "string", + "enum": [ + MCP_TOOL_APPROVAL_ACCEPT, + MCP_TOOL_APPROVAL_DECLINE, + ], + "x-codex-options": [ + { + "label": MCP_TOOL_APPROVAL_ACCEPT, + "description": "Run the tool and continue.", + }, + { + "label": MCP_TOOL_APPROVAL_DECLINE, + "description": "Decline this tool call and continue.", + }, + ], + }, + }, + "required": ["approval"], + "additionalProperties": false, + "x-codex-request-user-input": { + "questions": [{ + "id": "approval", + "header": "Approve app tool call?", + "question": "Allow this action?", + "isOther": false, + "isSecret": false, + "options": [ + { + "label": MCP_TOOL_APPROVAL_ACCEPT, + "description": "Run the tool and continue.", + }, + { + "label": MCP_TOOL_APPROVAL_DECLINE, + "description": "Decline this tool call and continue.", + }, + ], + }], + }, + }) + ); + } + + #[test] + fn accepted_elicitation_content_converts_to_request_user_input_response() { + let response = + request_user_input_response_from_elicitation_content(Some(serde_json::json!( + { + "approval": MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER, + } + ))); + + assert_eq!( + response, + Some(RequestUserInputResponse { + answers: std::collections::HashMap::from([( + "approval".to_string(), + RequestUserInputAnswer { + answers: vec![MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string()], + }, + )]), + }) + ); + } + + #[test] + fn declined_elicitation_response_stays_decline() { + let response = parse_mcp_tool_approval_elicitation_response( + Some(ElicitationResponse { + action: ElicitationAction::Decline, + content: Some(serde_json::json!({ + "approval": MCP_TOOL_APPROVAL_ACCEPT, + })), + }), + "approval", + ); + + assert_eq!(response, McpToolApprovalDecision::Decline); + } } diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 28ae8fec65..4bbcf21c92 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -11,6 +11,8 @@ use tokio_util::task::AbortOnDropHandle; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::models::ResponseInputItem; use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_rmcp_client::ElicitationResponse; +use rmcp::model::RequestId; use tokio::sync::oneshot; use crate::codex::TurnContext; @@ -72,6 +74,7 @@ impl ActiveTurn { pub(crate) struct TurnState { pending_approvals: HashMap>, pending_user_input: HashMap>, + pending_elicitations: HashMap<(String, RequestId), oneshot::Sender>, pending_dynamic_tools: HashMap>, pending_input: Vec, pub(crate) tool_calls: u64, @@ -97,6 +100,7 @@ impl TurnState { pub(crate) fn clear_pending(&mut self) { self.pending_approvals.clear(); self.pending_user_input.clear(); + self.pending_elicitations.clear(); self.pending_dynamic_tools.clear(); self.pending_input.clear(); } @@ -116,6 +120,25 @@ impl TurnState { self.pending_user_input.remove(key) } + pub(crate) fn insert_pending_elicitation( + &mut self, + server_name: String, + request_id: RequestId, + tx: oneshot::Sender, + ) -> Option> { + self.pending_elicitations + .insert((server_name, request_id), tx) + } + + pub(crate) fn remove_pending_elicitation( + &mut self, + server_name: &str, + request_id: &RequestId, + ) -> Option> { + self.pending_elicitations + .remove(&(server_name.to_string(), request_id.clone())) + } + pub(crate) fn insert_pending_dynamic_tool( &mut self, key: String,