mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
398 lines
13 KiB
Rust
398 lines
13 KiB
Rust
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<Session>,
|
|
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::<serde_json::Value>(&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<String>,
|
|
tool_title: Option<String>,
|
|
}
|
|
|
|
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<McpToolApprovalDecision> {
|
|
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<McpToolApprovalMetadata> {
|
|
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<RequestUserInputResponse>,
|
|
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<mcp_types::CallToolResult, String> {
|
|
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<bool>,
|
|
destructive: Option<bool>,
|
|
open_world: Option<bool>,
|
|
) -> 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);
|
|
}
|
|
}
|