use std::time::Duration; use std::time::Instant; use tracing::error; use crate::codex::Session; use crate::codex::TurnContext; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::protocol::EventMsg; use crate::protocol::McpInvocation; use crate::protocol::McpToolCallBeginEvent; use crate::protocol::McpToolCallEndEvent; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; 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 mcp_types::ToolAnnotations; use std::sync::Arc; /// Handles the specified tool call dispatches the appropriate /// `McpToolCallBegin` and `McpToolCallEnd` events to the `Session`. pub(crate) async fn handle_mcp_tool_call( sess: Arc, turn_context: &TurnContext, call_id: String, server: String, tool_name: String, arguments: String, ) -> ResponseInputItem { // Parse the `arguments` as JSON. An empty string is OK, but invalid JSON // is not. let arguments_value = if arguments.trim().is_empty() { None } else { match serde_json::from_str::(&arguments) { Ok(value) => Some(value), Err(e) => { error!("failed to parse tool call arguments: {e}"); return ResponseInputItem::FunctionCallOutput { call_id: call_id.clone(), output: FunctionCallOutputPayload { content: format!("err: {e}"), success: Some(false), ..Default::default() }, }; } } }; let invocation = McpInvocation { server: server.clone(), tool: tool_name.clone(), arguments: arguments_value.clone(), }; if let Some(decision) = maybe_request_mcp_tool_approval(sess.as_ref(), turn_context, &call_id, &server, &tool_name) .await { let result = match decision { McpToolApprovalDecision::Accept => { let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent { call_id: call_id.clone(), invocation: invocation.clone(), }); notify_mcp_tool_call_event(sess.as_ref(), turn_context, tool_call_begin_event) .await; let start = Instant::now(); let result = sess .call_tool(&server, &tool_name, arguments_value.clone()) .await .map_err(|e| format!("tool call error: {e:?}")); if let Err(e) = &result { tracing::warn!("MCP tool call error: {e:?}"); } let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent { call_id: call_id.clone(), invocation, duration: start.elapsed(), result: result.clone(), }); notify_mcp_tool_call_event( sess.as_ref(), turn_context, tool_call_end_event.clone(), ) .await; result } McpToolApprovalDecision::Decline => { let message = "user rejected MCP tool call".to_string(); notify_mcp_tool_call_skip( sess.as_ref(), turn_context, &call_id, invocation, message, ) .await } McpToolApprovalDecision::Cancel => { let message = "user cancelled MCP tool call".to_string(); notify_mcp_tool_call_skip( sess.as_ref(), turn_context, &call_id, invocation, message, ) .await } }; let status = if result.is_ok() { "ok" } else { "error" }; turn_context .client .get_otel_manager() .counter("codex.mcp.call", 1, &[("status", status)]); return ResponseInputItem::McpToolCallOutput { call_id, result }; } let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent { call_id: call_id.clone(), invocation: invocation.clone(), }); notify_mcp_tool_call_event(sess.as_ref(), turn_context, tool_call_begin_event).await; let start = Instant::now(); // Perform the tool call. let result = sess .call_tool(&server, &tool_name, arguments_value.clone()) .await .map_err(|e| format!("tool call error: {e:?}")); if let Err(e) = &result { tracing::warn!("MCP tool call error: {e:?}"); } let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent { call_id: call_id.clone(), invocation, duration: start.elapsed(), result: result.clone(), }); notify_mcp_tool_call_event(sess.as_ref(), turn_context, tool_call_end_event.clone()).await; let status = if result.is_ok() { "ok" } else { "error" }; turn_context .client .get_otel_manager() .counter("codex.mcp.call", 1, &[("status", status)]); ResponseInputItem::McpToolCallOutput { call_id, result } } async fn notify_mcp_tool_call_event(sess: &Session, turn_context: &TurnContext, event: EventMsg) { sess.send_event(turn_context, event).await; } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum McpToolApprovalDecision { Accept, Decline, Cancel, } struct McpToolApprovalMetadata { annotations: ToolAnnotations, connector_name: Option, tool_title: Option, } const MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX: &str = "mcp_tool_call_approval"; const MCP_TOOL_APPROVAL_ACCEPT: &str = "Accept"; const MCP_TOOL_APPROVAL_DECLINE: &str = "Decline"; const MCP_TOOL_APPROVAL_CANCEL: &str = "Cancel"; async fn maybe_request_mcp_tool_approval( sess: &Session, turn_context: &TurnContext, call_id: &str, server: &str, tool_name: &str, ) -> Option { if is_full_access_mode(turn_context) { return None; } if server != CODEX_APPS_MCP_SERVER_NAME { return None; } let metadata = lookup_mcp_tool_metadata(sess, server, tool_name).await?; if !requires_mcp_tool_approval(&metadata.annotations) { return None; } let question_id = format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}"); let question = build_mcp_tool_approval_question( question_id.clone(), tool_name, metadata.tool_title.as_deref(), metadata.connector_name.as_deref(), &metadata.annotations, ); let args = RequestUserInputArgs { questions: vec![question], }; let response = sess .request_user_input(turn_context, call_id.to_string(), args) .await; Some(parse_mcp_tool_approval_response(response, &question_id)) } fn is_full_access_mode(turn_context: &TurnContext) -> bool { matches!(turn_context.approval_policy, AskForApproval::Never) && matches!( turn_context.sandbox_policy, SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } ) } async fn lookup_mcp_tool_metadata( sess: &Session, server: &str, tool_name: &str, ) -> Option { let tools = sess .services .mcp_connection_manager .read() .await .list_all_tools() .await; tools.into_values().find_map(|tool_info| { if tool_info.server_name == server && tool_info.tool_name == tool_name { tool_info .tool .annotations .map(|annotations| McpToolApprovalMetadata { annotations, connector_name: tool_info.connector_name, tool_title: tool_info.tool.title, }) } else { None } }) } fn build_mcp_tool_approval_question( question_id: String, tool_name: &str, tool_title: Option<&str>, connector_name: Option<&str>, annotations: &ToolAnnotations, ) -> RequestUserInputQuestion { let destructive = annotations.destructive_hint == Some(true); let open_world = annotations.open_world_hint == Some(true); let reason = match (destructive, open_world) { (true, true) => "may modify data and access external systems", (true, false) => "may modify or delete data", (false, true) => "may access external systems", (false, false) => "may have side effects", }; let tool_label = tool_title.unwrap_or(tool_name); let app_label = connector_name .map(|name| format!("The {name} app")) .unwrap_or_else(|| "This app".to_string()); let question = format!( "{app_label} wants to run the tool \"{tool_label}\", which {reason}. Allow this action?" ); RequestUserInputQuestion { id: question_id, header: "Approve app tool call?".to_string(), question, 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(), }, RequestUserInputQuestionOption { label: MCP_TOOL_APPROVAL_CANCEL.to_string(), description: "Cancel this tool call".to_string(), }, ]), } } fn parse_mcp_tool_approval_response( response: Option, question_id: &str, ) -> McpToolApprovalDecision { let Some(response) = response else { return McpToolApprovalDecision::Cancel; }; let answers = response .answers .get(question_id) .map(|answer| answer.answers.as_slice()); let Some(answers) = answers else { return McpToolApprovalDecision::Cancel; }; if answers .iter() .any(|answer| answer == MCP_TOOL_APPROVAL_ACCEPT) { McpToolApprovalDecision::Accept } else if answers .iter() .any(|answer| answer == MCP_TOOL_APPROVAL_CANCEL) { McpToolApprovalDecision::Cancel } else { McpToolApprovalDecision::Decline } } fn requires_mcp_tool_approval(annotations: &ToolAnnotations) -> bool { annotations.read_only_hint == Some(false) && (annotations.destructive_hint == Some(true) || annotations.open_world_hint == Some(true)) } async fn notify_mcp_tool_call_skip( sess: &Session, turn_context: &TurnContext, call_id: &str, invocation: McpInvocation, message: String, ) -> Result { let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent { call_id: call_id.to_string(), invocation: invocation.clone(), }); notify_mcp_tool_call_event(sess, turn_context, tool_call_begin_event).await; let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent { call_id: call_id.to_string(), invocation, duration: Duration::ZERO, result: Err(message.clone()), }); notify_mcp_tool_call_event(sess, turn_context, tool_call_end_event).await; Err(message) } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; fn annotations( read_only: Option, destructive: Option, open_world: Option, ) -> ToolAnnotations { ToolAnnotations { destructive_hint: destructive, idempotent_hint: None, open_world_hint: open_world, read_only_hint: read_only, title: None, } } #[test] fn approval_required_when_read_only_false_and_destructive() { let annotations = annotations(Some(false), Some(true), None); assert_eq!(requires_mcp_tool_approval(&annotations), true); } #[test] fn approval_required_when_read_only_false_and_open_world() { let annotations = annotations(Some(false), None, Some(true)); assert_eq!(requires_mcp_tool_approval(&annotations), true); } #[test] fn approval_not_required_when_read_only_true() { let annotations = annotations(Some(true), Some(true), Some(true)); assert_eq!(requires_mcp_tool_approval(&annotations), false); } }