mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
## Why Managed command policies need to require a human decision independently of automatic review, hook allowances, and saved approvals. ## What changed - Support `decision = "prompt-user"` in managed `requirements.toml` prefix rules, requiring a fresh, one-time human decision for matching commands. - Add experimental `humanCommand` approval requests for clients advertising both `experimentalApi` and `requiredHumanApproval`. Accept only `accept`, `decline`, or `cancel`; reject commands when no capable client is available. - Restrict responses to capable clients that received the request, and replay pending prompts to compatible clients on reconnect. - Enable the capability in the TUI and update protocol schemas and client bindings. ## Testing Add coverage for fresh decisions overriding saved allowances, hook allowances, and automatic review, plus child-thread approval routing and rejection of responses from incompatible or unrelated clients. GitOrigin-RevId: 0b95ff6aab7f8a04b7298c8a4329d1fddf6a603e
2622 lines
91 KiB
Rust
2622 lines
91 KiB
Rust
use std::collections::HashMap;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
|
|
use crate::config::Config;
|
|
use crate::config::edit::ConfigEdit;
|
|
use crate::config::edit::ConfigEditsBuilder;
|
|
use crate::connectors;
|
|
use crate::guardian::GuardianApprovalRequest;
|
|
use crate::guardian::GuardianMcpAnnotations;
|
|
use crate::guardian::GuardianReviewContext;
|
|
use crate::mcp_openai_file::rewrite_mcp_tool_arguments_for_openai_files;
|
|
use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam;
|
|
use crate::mcp_tool_approval_templates::render_mcp_tool_approval_template;
|
|
use crate::session::session::Session;
|
|
use crate::session::step_context::StepContext;
|
|
use crate::session::turn_context::TurnContext;
|
|
use crate::tools::ApprovalContext;
|
|
use crate::tools::ApprovalResolution;
|
|
use crate::tools::ApprovalResolutionSource;
|
|
use crate::tools::context::ToolCallOrigin;
|
|
use crate::tools::hook_names::HookToolName;
|
|
use crate::tools::lifecycle::process_mcp_tool_result;
|
|
use crate::tools::sandboxing::ApprovalAction;
|
|
use crate::tools::sandboxing::ToolError;
|
|
use crate::turn_metadata::ExecutionMetadata;
|
|
use codex_analytics::AppInvocation;
|
|
use codex_analytics::ElicitationType;
|
|
use codex_analytics::InvocationType;
|
|
use codex_analytics::McpToolCallElicitation;
|
|
use codex_analytics::build_track_events_context;
|
|
use codex_api::HostedFileUploadContext;
|
|
use codex_config::ConfigLayerSource;
|
|
use codex_config::types::AppToolApproval;
|
|
use codex_connectors::AppToolPolicy;
|
|
use codex_connectors::AppToolPolicyEvaluator;
|
|
use codex_connectors::AppToolPolicyInput;
|
|
use codex_extension_api::McpToolContext;
|
|
use codex_features::Feature;
|
|
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
|
use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY;
|
|
use codex_mcp::McpPermissionPromptAutoApproveContext;
|
|
use codex_mcp::PreparedMcpCall;
|
|
use codex_mcp::SandboxState;
|
|
use codex_mcp::ToolInfo;
|
|
use codex_mcp::auth_elicitation_completed_result;
|
|
use codex_mcp::build_auth_elicitation_plan;
|
|
use codex_mcp::is_connector_auth_failure_from_tool_result;
|
|
use codex_mcp::mcp_permission_prompt_is_auto_approved;
|
|
use codex_prompts::ResolvedModelMessages;
|
|
use codex_protocol::approvals::ElicitationRequest;
|
|
use codex_protocol::items::McpAppDisplayMode;
|
|
use codex_protocol::items::McpAppUi;
|
|
use codex_protocol::items::McpToolCallError;
|
|
use codex_protocol::items::McpToolCallItem;
|
|
use codex_protocol::items::McpToolCallStatus;
|
|
use codex_protocol::items::TurnItem;
|
|
use codex_protocol::mcp::CONFIRMATION_POLICIES_META_KEY;
|
|
use codex_protocol::mcp::CallToolResult;
|
|
use codex_protocol::mcp::is_node_repl_backed_connector;
|
|
use codex_protocol::mcp::is_node_repl_backed_server;
|
|
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as MCP_TOOL_APPROVAL_KIND_KEY;
|
|
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL;
|
|
use codex_protocol::mcp_approval_meta::CANCELLATION_REASON_KEY;
|
|
use codex_protocol::mcp_approval_meta::CONNECTOR_DESCRIPTION_KEY as MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY;
|
|
use codex_protocol::mcp_approval_meta::CONNECTOR_ID_KEY as MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY;
|
|
use codex_protocol::mcp_approval_meta::CONNECTOR_NAME_KEY as MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY;
|
|
use codex_protocol::mcp_approval_meta::McpApprovalCancellationReason;
|
|
use codex_protocol::mcp_approval_meta::PERSIST_ALWAYS as MCP_TOOL_APPROVAL_PERSIST_ALWAYS;
|
|
use codex_protocol::mcp_approval_meta::PERSIST_KEY as MCP_TOOL_APPROVAL_PERSIST_KEY;
|
|
use codex_protocol::mcp_approval_meta::PERSIST_SESSION as MCP_TOOL_APPROVAL_PERSIST_SESSION;
|
|
use codex_protocol::mcp_approval_meta::SOURCE_CONNECTOR as MCP_TOOL_APPROVAL_SOURCE_CONNECTOR;
|
|
use codex_protocol::mcp_approval_meta::SOURCE_KEY as MCP_TOOL_APPROVAL_SOURCE_KEY;
|
|
use codex_protocol::mcp_approval_meta::TOOL_DESCRIPTION_KEY as MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY;
|
|
use codex_protocol::mcp_approval_meta::TOOL_PARAMS_DISPLAY_KEY as MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY;
|
|
use codex_protocol::mcp_approval_meta::TOOL_PARAMS_KEY as MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY;
|
|
use codex_protocol::mcp_approval_meta::TOOL_TITLE_KEY as MCP_TOOL_APPROVAL_TOOL_TITLE_KEY;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::openai_models::InputModality;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::McpInvocation;
|
|
use codex_protocol::protocol::ReviewDecision;
|
|
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 codex_rollout::state_db;
|
|
use codex_tools::ToolName;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_output_truncation::TruncationPolicy;
|
|
use codex_utils_output_truncation::truncate_mcp_tool_result;
|
|
use codex_utils_output_truncation::truncate_text;
|
|
use codex_utils_path_uri::PathUri;
|
|
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
|
|
use rmcp::model::ToolAnnotations;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use serde_json::Value as JsonValue;
|
|
use std::sync::Arc;
|
|
use tokio_util::sync::CancellationToken;
|
|
use toml_edit::value;
|
|
use tracing::Instrument;
|
|
use tracing::Span;
|
|
use tracing::error;
|
|
use tracing::field::Empty;
|
|
use url::Url;
|
|
|
|
mod account;
|
|
pub(crate) mod conversation_history;
|
|
mod telemetry;
|
|
|
|
use account::McpToolAccountError;
|
|
use telemetry::McpCallMetricOutcome;
|
|
use telemetry::emit_mcp_call_metrics;
|
|
use telemetry::mcp_call_metric_outcome;
|
|
use telemetry::record_mcp_call_outcome_span_telemetry;
|
|
|
|
const MCP_RESULT_TELEMETRY_META_KEY: &str = "codex/telemetry";
|
|
const MCP_RESULT_TELEMETRY_SPAN_KEY: &str = "span";
|
|
const MCP_RESULT_TELEMETRY_TARGET_ID_KEY: &str = "target_id";
|
|
const MCP_RESULT_TELEMETRY_DID_TRIGGER_SERVER_USER_FLOW_KEY: &str = "did_trigger_server_user_flow";
|
|
const MCP_RESULT_TELEMETRY_TARGET_ID_SPAN_ATTR: &str = "codex.mcp.target.id";
|
|
const MCP_RESULT_TELEMETRY_SERVER_USER_FLOW_SPAN_ATTR: &str =
|
|
"codex.mcp.server_user_flow.triggered";
|
|
const MCP_RESULT_TELEMETRY_TARGET_ID_MAX_CHARS: usize = 256;
|
|
const MCP_TOOL_CALL_EVENT_RESULT_MAX_BYTES: usize = DEFAULT_OUTPUT_BYTES_CAP;
|
|
|
|
/// Handles the specified tool call and dispatches the appropriate MCP tool-call
|
|
/// item lifecycle events to the `Session`.
|
|
#[expect(clippy::too_many_arguments)]
|
|
pub(crate) async fn handle_mcp_tool_call(
|
|
sess: Arc<Session>,
|
|
step_context: &Arc<StepContext>,
|
|
cancellation_token: &CancellationToken,
|
|
call_id: String,
|
|
originating_call: Option<ToolCallOrigin>,
|
|
tool_info: &ToolInfo,
|
|
prepared_call: Option<PreparedMcpCall>,
|
|
hook_tool_name: HookToolName,
|
|
invocation_tool_name: ToolName,
|
|
source: &crate::tools::context::ToolCallSource,
|
|
arguments: String,
|
|
) -> HandledMcpToolCall {
|
|
let turn_context = &step_context.turn;
|
|
let server = tool_info.server_name.clone();
|
|
let tool_name = tool_info.tool.name.to_string();
|
|
// 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 HandledMcpToolCall {
|
|
result: CallToolResult::from_error_text(format!("err: {e}")),
|
|
tool_input: JsonValue::Object(serde_json::Map::new()),
|
|
};
|
|
}
|
|
}
|
|
};
|
|
|
|
let invocation = McpInvocation {
|
|
server: server.clone(),
|
|
tool: tool_name.clone(),
|
|
arguments: arguments_value.clone(),
|
|
};
|
|
|
|
let Some(prepared_call) = prepared_call else {
|
|
let item_metadata =
|
|
McpToolCallItemMetadata::from_tool_metadata(&server, /*metadata*/ None);
|
|
let result = notify_mcp_tool_call_skip(
|
|
sess.as_ref(),
|
|
turn_context.as_ref(),
|
|
&call_id,
|
|
invocation,
|
|
item_metadata,
|
|
format!("MCP tool `{server}/{tool_name}` is not available to the model"),
|
|
/*already_started*/ false,
|
|
)
|
|
.await;
|
|
return HandledMcpToolCall {
|
|
result: CallToolResult::from_result(result),
|
|
tool_input: arguments_value
|
|
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())),
|
|
};
|
|
};
|
|
let metadata = match mcp_tool_metadata(
|
|
prepared_call.tool_info(),
|
|
prepared_call.plugin_id(),
|
|
invocation.arguments.as_ref(),
|
|
) {
|
|
Ok(metadata) => metadata,
|
|
Err(err) => {
|
|
let item_metadata =
|
|
McpToolCallItemMetadata::from_tool_metadata(&server, /*metadata*/ None);
|
|
let result = notify_mcp_tool_call_skip(
|
|
sess.as_ref(),
|
|
turn_context.as_ref(),
|
|
&call_id,
|
|
invocation,
|
|
item_metadata,
|
|
err.to_string(),
|
|
/*already_started*/ false,
|
|
)
|
|
.await;
|
|
return HandledMcpToolCall {
|
|
result: CallToolResult::from_result(result),
|
|
tool_input: arguments_value
|
|
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())),
|
|
};
|
|
}
|
|
};
|
|
let item_metadata = McpToolCallItemMetadata::from_tool_metadata(&server, Some(&metadata));
|
|
let runtime_config = prepared_call.config();
|
|
let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME {
|
|
app_tool_policy(runtime_config, &metadata, &tool_name)
|
|
} else {
|
|
AppToolPolicy::default()
|
|
};
|
|
let approval_mode = if server == CODEX_APPS_MCP_SERVER_NAME {
|
|
app_tool_policy.approval
|
|
} else {
|
|
prepared_call.tool_approval_mode()
|
|
};
|
|
|
|
let connector_id = metadata.connector_id.clone();
|
|
let connector_name = metadata.connector_name.clone();
|
|
|
|
if server == CODEX_APPS_MCP_SERVER_NAME && !app_tool_policy.enabled {
|
|
let result = notify_mcp_tool_call_skip(
|
|
sess.as_ref(),
|
|
turn_context.as_ref(),
|
|
&call_id,
|
|
invocation,
|
|
item_metadata.clone(),
|
|
"MCP tool call blocked by app configuration".to_string(),
|
|
/*already_started*/ false,
|
|
)
|
|
.await;
|
|
let status = if result.is_ok() { "ok" } else { "error" };
|
|
let outcome = McpCallMetricOutcome::from_status(status);
|
|
emit_mcp_call_metrics(
|
|
&step_context.session_telemetry,
|
|
&outcome,
|
|
&server,
|
|
&tool_name,
|
|
connector_id.as_deref(),
|
|
connector_name.as_deref(),
|
|
/*duration*/ None,
|
|
);
|
|
return HandledMcpToolCall {
|
|
result: CallToolResult::from_result(result),
|
|
tool_input: arguments_value
|
|
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())),
|
|
};
|
|
}
|
|
let _approval_metadata = sess.register_mcp_tool_approval_metadata(
|
|
&call_id,
|
|
&invocation,
|
|
metadata.clone(),
|
|
GuardianReviewContext::from(step_context),
|
|
);
|
|
notify_mcp_tool_call_started(
|
|
sess.as_ref(),
|
|
turn_context.as_ref(),
|
|
&call_id,
|
|
invocation.clone(),
|
|
item_metadata.clone(),
|
|
)
|
|
.await;
|
|
|
|
let approval_policy = if prepared_call.is_selected_plugin_server() {
|
|
McpToolApprovalPolicy::for_selected_plugin(approval_mode)
|
|
} else {
|
|
McpToolApprovalPolicy::for_server(approval_mode)
|
|
};
|
|
if let Some(approval) = maybe_request_mcp_tool_approval(
|
|
&sess,
|
|
step_context,
|
|
cancellation_token,
|
|
&call_id,
|
|
&invocation,
|
|
&invocation_tool_name,
|
|
&hook_tool_name,
|
|
&metadata,
|
|
prepared_call.config(),
|
|
prepared_call.permission_profile(),
|
|
prepared_call.server_environment_id(),
|
|
approval_policy,
|
|
)
|
|
.await
|
|
{
|
|
let cancellation_outcome = matches!(approval.decision, ReviewDecision::Abort)
|
|
.then(|| McpCallMetricOutcome::approval_cancelled(approval.source));
|
|
let result = match approval.decision {
|
|
decision @ (ReviewDecision::Approved
|
|
| ReviewDecision::ApprovedForSession
|
|
| ReviewDecision::ApprovedMcpPolicyAmendment
|
|
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
|
| ReviewDecision::NetworkPolicyAmendment { .. }) => {
|
|
return handle_approved_mcp_tool_call(
|
|
&sess,
|
|
step_context.as_ref(),
|
|
&call_id,
|
|
originating_call.as_ref(),
|
|
invocation,
|
|
prepared_call,
|
|
metadata,
|
|
item_metadata,
|
|
source,
|
|
McpToolApprovalApplication::Apply {
|
|
decision,
|
|
policy: approval_policy,
|
|
},
|
|
)
|
|
.await;
|
|
}
|
|
ReviewDecision::Denied { rejection } => {
|
|
notify_mcp_tool_call_skip(
|
|
sess.as_ref(),
|
|
turn_context.as_ref(),
|
|
&call_id,
|
|
invocation,
|
|
item_metadata.clone(),
|
|
rejection,
|
|
/*already_started*/ true,
|
|
)
|
|
.await
|
|
}
|
|
ReviewDecision::TimedOut => {
|
|
notify_mcp_tool_call_skip(
|
|
sess.as_ref(),
|
|
turn_context.as_ref(),
|
|
&call_id,
|
|
invocation,
|
|
item_metadata.clone(),
|
|
ResolvedModelMessages::from_model(turn_context.model_info())
|
|
.auto_review()
|
|
.timeout_instructions
|
|
.to_string(),
|
|
/*already_started*/ true,
|
|
)
|
|
.await
|
|
}
|
|
ReviewDecision::Abort => {
|
|
let message = match approval.cancellation_reason {
|
|
// Older clients do not send provenance. Preserve their
|
|
// cancellation behavior without assigning them a user reason.
|
|
Some(McpApprovalCancellationReason::User) | None => {
|
|
"user cancelled MCP tool call"
|
|
}
|
|
Some(McpApprovalCancellationReason::Interrupted) => {
|
|
"MCP tool call cancelled before approval"
|
|
}
|
|
Some(McpApprovalCancellationReason::NonInteractive) => {
|
|
"MCP tool call cancelled: interactive approval is unavailable in exec mode"
|
|
}
|
|
}
|
|
.to_string();
|
|
notify_mcp_tool_call_skip(
|
|
sess.as_ref(),
|
|
turn_context.as_ref(),
|
|
&call_id,
|
|
invocation,
|
|
item_metadata.clone(),
|
|
message,
|
|
/*already_started*/ true,
|
|
)
|
|
.await
|
|
}
|
|
};
|
|
|
|
let status = if result.is_ok() { "ok" } else { "error" };
|
|
let outcome =
|
|
cancellation_outcome.unwrap_or_else(|| McpCallMetricOutcome::from_status(status));
|
|
emit_mcp_call_metrics(
|
|
&step_context.session_telemetry,
|
|
&outcome,
|
|
&server,
|
|
&tool_name,
|
|
connector_id.as_deref(),
|
|
connector_name.as_deref(),
|
|
/*duration*/ None,
|
|
);
|
|
|
|
return HandledMcpToolCall {
|
|
result: CallToolResult::from_result(result),
|
|
tool_input: arguments_value
|
|
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())),
|
|
};
|
|
}
|
|
|
|
handle_approved_mcp_tool_call(
|
|
&sess,
|
|
step_context.as_ref(),
|
|
&call_id,
|
|
originating_call.as_ref(),
|
|
invocation,
|
|
prepared_call,
|
|
metadata,
|
|
item_metadata,
|
|
source,
|
|
McpToolApprovalApplication::NotRequired,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(crate) struct HandledMcpToolCall {
|
|
pub(crate) result: CallToolResult,
|
|
pub(crate) tool_input: JsonValue,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct McpToolCallItemMetadata {
|
|
connector_id: Option<String>,
|
|
link_id: Option<String>,
|
|
mcp_app_resource_uri: Option<String>,
|
|
mcp_app_ui: Option<McpAppUi>,
|
|
app_name: Option<String>,
|
|
action_name: Option<String>,
|
|
plugin_id: Option<String>,
|
|
read_only_hint: Option<bool>,
|
|
}
|
|
|
|
impl McpToolCallItemMetadata {
|
|
fn from_tool_metadata(server: &str, metadata: Option<&McpToolApprovalMetadata>) -> Self {
|
|
let trusted_mcp_app_metadata = if server == CODEX_APPS_MCP_SERVER_NAME {
|
|
metadata
|
|
} else {
|
|
None
|
|
};
|
|
Self {
|
|
connector_id: trusted_mcp_app_metadata
|
|
.and_then(|metadata| metadata.connector_id.clone()),
|
|
link_id: trusted_mcp_app_metadata.and_then(|metadata| metadata.link_id.clone()),
|
|
mcp_app_resource_uri: metadata
|
|
.and_then(|metadata| metadata.mcp_app_resource_uri.clone()),
|
|
mcp_app_ui: metadata.and_then(|metadata| metadata.mcp_app_ui.clone()),
|
|
app_name: trusted_mcp_app_metadata.and_then(|metadata| metadata.connector_name.clone()),
|
|
action_name: trusted_mcp_app_metadata
|
|
.and_then(|metadata| metadata.codex_apps_meta.as_ref())
|
|
.and_then(|meta| meta.get(MCP_TOOL_RESOURCE_URI_META_KEY))
|
|
.and_then(serde_json::Value::as_str)
|
|
.and_then(|resource_uri| resource_uri.trim_matches('/').rsplit('/').next())
|
|
.filter(|action_name| !action_name.is_empty())
|
|
.map(str::to_string),
|
|
plugin_id: metadata.and_then(|metadata| metadata.plugin_id.clone()),
|
|
read_only_hint: metadata
|
|
.and_then(|metadata| metadata.annotations.as_ref())
|
|
.and_then(|annotations| annotations.read_only_hint),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[expect(
|
|
clippy::too_many_arguments,
|
|
reason = "MCP approval must be applied inside the prepared call's catalog lease"
|
|
)]
|
|
async fn handle_approved_mcp_tool_call(
|
|
sess: &Arc<Session>,
|
|
step_context: &StepContext,
|
|
call_id: &str,
|
|
originating_call: Option<&ToolCallOrigin>,
|
|
invocation: McpInvocation,
|
|
prepared_call: PreparedMcpCall,
|
|
metadata: McpToolApprovalMetadata,
|
|
item_metadata: McpToolCallItemMetadata,
|
|
source: &crate::tools::context::ToolCallSource,
|
|
approval_application: McpToolApprovalApplication,
|
|
) -> HandledMcpToolCall {
|
|
let turn_context = step_context.turn.as_ref();
|
|
let server = invocation.server.clone();
|
|
let tool_name = invocation.tool.clone();
|
|
let arguments_value = invocation.arguments.clone();
|
|
let connector_id = metadata.connector_id.as_deref();
|
|
let connector_name = metadata.connector_name.as_deref();
|
|
let server_origin = prepared_call.server_origin().map(str::to_string);
|
|
|
|
let start = Instant::now();
|
|
let mut tool_input = arguments_value
|
|
.clone()
|
|
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new()));
|
|
let mut elicitation_type = None;
|
|
let mut request_error_code = None;
|
|
let result = async {
|
|
let result = async {
|
|
let result = prepared_call
|
|
.call_with_preparation(/*requested_timeout*/ None, || async {
|
|
if let McpToolApprovalApplication::Apply { decision, policy } =
|
|
&approval_application
|
|
{
|
|
let session_approval_key = session_mcp_tool_approval_key(
|
|
&invocation,
|
|
Some(&metadata),
|
|
policy.mode,
|
|
);
|
|
let persistent_approval_key = if policy.allow_persistent {
|
|
persistent_mcp_tool_approval_key(
|
|
&invocation,
|
|
Some(&metadata),
|
|
policy.mode,
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
apply_mcp_tool_approval_decision(
|
|
sess,
|
|
turn_context,
|
|
decision,
|
|
session_approval_key,
|
|
persistent_approval_key,
|
|
)
|
|
.await;
|
|
}
|
|
maybe_mark_thread_memory_mode_polluted(sess, turn_context, &prepared_call)
|
|
.await;
|
|
let hosted_upload = item_metadata
|
|
.connector_id
|
|
.as_ref()
|
|
.zip(item_metadata.action_name.as_ref())
|
|
.map(|(connector_id, action_name)| HostedFileUploadContext {
|
|
connector_id: connector_id.clone(),
|
|
action_name: action_name.clone(),
|
|
model: step_context.settings.model_info.slug.clone(),
|
|
});
|
|
let rewritten_arguments = rewrite_mcp_tool_arguments_for_openai_files(
|
|
sess,
|
|
step_context,
|
|
arguments_value,
|
|
metadata.openai_file_input_optional_fields.as_ref(),
|
|
hosted_upload.as_ref(),
|
|
)
|
|
.await
|
|
.map_err(anyhow::Error::msg)?;
|
|
if let Some(rewritten_arguments) = rewritten_arguments.as_ref() {
|
|
tool_input = rewritten_arguments.clone();
|
|
}
|
|
let request_meta = build_mcp_tool_call_request_meta(
|
|
step_context,
|
|
&server,
|
|
call_id,
|
|
Some(&metadata),
|
|
prepared_call.is_host_owned_apps(),
|
|
);
|
|
let request_meta = with_mcp_tool_call_ids_meta(
|
|
request_meta,
|
|
&sess.thread_id.to_string(),
|
|
&sess.session_id().to_string(),
|
|
originating_call,
|
|
);
|
|
let request_meta = augment_mcp_tool_request_meta_with_sandbox_state(
|
|
step_context,
|
|
&prepared_call,
|
|
request_meta,
|
|
)
|
|
.await?;
|
|
let mcp_call_trace = sess
|
|
.services
|
|
.rollout_thread_trace
|
|
.start_mcp_call_trace(call_id);
|
|
Ok((
|
|
rewritten_arguments,
|
|
mcp_call_trace.add_request_meta(request_meta),
|
|
))
|
|
})
|
|
.await
|
|
.map_err(|error| {
|
|
request_error_code = codex_rmcp_client::request_error_code(&error);
|
|
format!("tool call error: {error:?}")
|
|
});
|
|
// Keep the backend response intact, before callbacks or model/UI limits can change it.
|
|
sess.services.rollout_thread_trace.record_mcp_tool_result(
|
|
&turn_context.sub_id,
|
|
call_id,
|
|
&result,
|
|
);
|
|
let mut result = result?;
|
|
crate::tools::record_confirmed_code_mode_send(
|
|
sess,
|
|
&turn_context.sub_id,
|
|
call_id,
|
|
source,
|
|
&prepared_call,
|
|
&tool_input,
|
|
&result,
|
|
);
|
|
// Capture trusted server metadata before result callbacks or model-facing rewrites.
|
|
elicitation_type = mcp_tool_call_auth_elicitation_type(&server, connector_id, &result);
|
|
let mcp_tool = McpToolContext::from_prepared_call(
|
|
&prepared_call,
|
|
turn_context.config.mcp_servers.get().get(&server),
|
|
);
|
|
process_mcp_tool_result(
|
|
sess,
|
|
turn_context,
|
|
call_id,
|
|
&mcp_tool,
|
|
&tool_input,
|
|
&mut result,
|
|
)
|
|
.await;
|
|
let result = sanitize_mcp_tool_result_for_model(
|
|
&step_context.settings.model_info.input_modalities,
|
|
Ok(result),
|
|
)?;
|
|
Ok(maybe_request_codex_apps_auth_elicitation(
|
|
sess,
|
|
turn_context,
|
|
prepared_call.config().approval_policy.value(),
|
|
call_id,
|
|
&invocation.server,
|
|
Some(&metadata),
|
|
result,
|
|
)
|
|
.await)
|
|
}
|
|
.await;
|
|
record_mcp_result_span_telemetry(&Span::current(), &result, request_error_code);
|
|
result
|
|
}
|
|
.instrument(mcp_tool_call_span(
|
|
sess,
|
|
turn_context,
|
|
McpToolCallSpanFields {
|
|
server_name: &server,
|
|
tool_name: &tool_name,
|
|
call_id,
|
|
server_origin: server_origin.as_deref(),
|
|
connector_id,
|
|
connector_name,
|
|
},
|
|
))
|
|
.await;
|
|
if let Err(error) = &result {
|
|
tracing::warn!("MCP tool call error: {error:?}");
|
|
}
|
|
let duration = start.elapsed();
|
|
if let Some(elicitation_type) = elicitation_type {
|
|
track_mcp_tool_call_elicitation(sess, turn_context, call_id, elicitation_type);
|
|
}
|
|
// Direct and Code Mode calls share this boundary. Once an approved call enters
|
|
// it, conservatively attribute returned errors too: they may contain peer data.
|
|
let source_connector_id = prepared_call
|
|
.is_host_owned_apps()
|
|
.then(|| prepared_call.tool_info().connector_id.clone())
|
|
.flatten();
|
|
sess.services.executed_tool_calls.record_mcp_source(
|
|
codex_protocol::mcp::McpAttributionSource {
|
|
connector_id: source_connector_id,
|
|
plugin_id: prepared_call.plugin_id().map(str::to_string),
|
|
server_name: prepared_call.server_name().to_string(),
|
|
tool_name: prepared_call.tool_info().tool.name.to_string(),
|
|
first_turn_id: turn_context.sub_id.clone(),
|
|
},
|
|
);
|
|
notify_mcp_tool_call_completed(
|
|
sess,
|
|
turn_context,
|
|
call_id,
|
|
invocation,
|
|
item_metadata,
|
|
duration,
|
|
truncate_mcp_tool_result_for_event(&result),
|
|
)
|
|
.await;
|
|
maybe_track_codex_app_used(sess, step_context, &server, &metadata, elicitation_type).await;
|
|
|
|
let outcome = mcp_call_metric_outcome(&result).with_request_error_code(request_error_code);
|
|
emit_mcp_call_metrics(
|
|
&step_context.session_telemetry,
|
|
&outcome,
|
|
&server,
|
|
&tool_name,
|
|
connector_id,
|
|
connector_name,
|
|
Some(duration),
|
|
);
|
|
|
|
HandledMcpToolCall {
|
|
result: CallToolResult::from_result(result),
|
|
tool_input,
|
|
}
|
|
}
|
|
|
|
fn mcp_tool_call_span(
|
|
session: &Session,
|
|
turn_context: &TurnContext,
|
|
fields: McpToolCallSpanFields<'_>,
|
|
) -> Span {
|
|
let transport = match fields.server_origin {
|
|
Some("stdio") => "stdio",
|
|
Some("in_process") => "in_process",
|
|
Some(_) => "streamable_http",
|
|
None => "",
|
|
};
|
|
let span = tracing::info_span!(
|
|
"mcp.tools.call",
|
|
otel.kind = "client",
|
|
rpc.system = "jsonrpc",
|
|
rpc.method = "tools/call",
|
|
mcp.server.name = fields.server_name,
|
|
mcp.server.origin = fields.server_origin.unwrap_or(""),
|
|
mcp.transport = transport,
|
|
mcp.connector.id = fields.connector_id.unwrap_or(""),
|
|
mcp.connector.name = fields.connector_name.unwrap_or(""),
|
|
tool.name = fields.tool_name,
|
|
tool.call_id = fields.call_id,
|
|
conversation.id = %session.thread_id,
|
|
session.id = %session.thread_id,
|
|
turn.id = turn_context.sub_id.as_str(),
|
|
server.address = Empty,
|
|
server.port = Empty,
|
|
codex.mcp.target.id = Empty,
|
|
codex.mcp.server_user_flow.triggered = Empty,
|
|
error.type = Empty,
|
|
codex.mcp.error.code = Empty,
|
|
);
|
|
record_server_fields(&span, fields.server_origin);
|
|
span
|
|
}
|
|
|
|
struct McpToolCallSpanFields<'a> {
|
|
server_name: &'a str,
|
|
tool_name: &'a str,
|
|
call_id: &'a str,
|
|
server_origin: Option<&'a str>,
|
|
connector_id: Option<&'a str>,
|
|
connector_name: Option<&'a str>,
|
|
}
|
|
|
|
fn record_server_fields(span: &Span, url: Option<&str>) {
|
|
let Some(url) = url else {
|
|
return;
|
|
};
|
|
let Ok(parsed) = Url::parse(url) else {
|
|
return;
|
|
};
|
|
if let Some(host) = parsed.host_str() {
|
|
span.record("server.address", host);
|
|
}
|
|
if let Some(port) = parsed.port_or_known_default() {
|
|
span.record("server.port", port as i64);
|
|
}
|
|
}
|
|
|
|
fn record_mcp_result_span_telemetry(
|
|
span: &Span,
|
|
result: &Result<CallToolResult, String>,
|
|
request_error_code: Option<&str>,
|
|
) {
|
|
let outcome = mcp_call_metric_outcome(result).with_request_error_code(request_error_code);
|
|
record_mcp_call_outcome_span_telemetry(span, &outcome);
|
|
|
|
let Some(span_telemetry) = result
|
|
.as_ref()
|
|
.ok()
|
|
.and_then(|result| result.meta.as_ref())
|
|
.and_then(JsonValue::as_object)
|
|
.and_then(|meta| meta.get(MCP_RESULT_TELEMETRY_META_KEY))
|
|
.and_then(JsonValue::as_object)
|
|
.and_then(|telemetry| telemetry.get(MCP_RESULT_TELEMETRY_SPAN_KEY))
|
|
.and_then(JsonValue::as_object)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
if let Some(target_id) = span_telemetry
|
|
.get(MCP_RESULT_TELEMETRY_TARGET_ID_KEY)
|
|
.and_then(JsonValue::as_str)
|
|
.filter(|target_id| !target_id.is_empty())
|
|
{
|
|
span.record(
|
|
MCP_RESULT_TELEMETRY_TARGET_ID_SPAN_ATTR,
|
|
truncate_str_to_char_boundary(target_id, MCP_RESULT_TELEMETRY_TARGET_ID_MAX_CHARS),
|
|
);
|
|
}
|
|
|
|
if let Some(did_trigger_server_user_flow) = span_telemetry
|
|
.get(MCP_RESULT_TELEMETRY_DID_TRIGGER_SERVER_USER_FLOW_KEY)
|
|
.and_then(JsonValue::as_bool)
|
|
{
|
|
span.record(
|
|
MCP_RESULT_TELEMETRY_SERVER_USER_FLOW_SPAN_ATTR,
|
|
did_trigger_server_user_flow,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn truncate_str_to_char_boundary(value: &str, max_chars: usize) -> &str {
|
|
match value.char_indices().nth(max_chars) {
|
|
Some((index, _)) => &value[..index],
|
|
None => value,
|
|
}
|
|
}
|
|
|
|
async fn maybe_request_codex_apps_auth_elicitation(
|
|
sess: &Arc<Session>,
|
|
turn_context: &TurnContext,
|
|
approval_policy: AskForApproval,
|
|
call_id: &str,
|
|
server: &str,
|
|
metadata: Option<&McpToolApprovalMetadata>,
|
|
result: CallToolResult,
|
|
) -> CallToolResult {
|
|
if server != CODEX_APPS_MCP_SERVER_NAME {
|
|
return result;
|
|
}
|
|
|
|
if !turn_context
|
|
.config
|
|
.features
|
|
.enabled(Feature::AuthElicitation)
|
|
{
|
|
return result;
|
|
}
|
|
|
|
match approval_policy {
|
|
AskForApproval::Never => return result,
|
|
AskForApproval::Granular(granular_config) if !granular_config.allows_mcp_elicitations() => {
|
|
return result;
|
|
}
|
|
AskForApproval::OnRequest | AskForApproval::UnlessTrusted | AskForApproval::Granular(_) => {
|
|
}
|
|
}
|
|
|
|
let connector_id = metadata.and_then(|metadata| metadata.connector_id.as_deref());
|
|
let connector_name = metadata.and_then(|metadata| metadata.connector_name.as_deref());
|
|
let install_url = connector_id.map(|connector_id| {
|
|
codex_connectors::metadata::connector_install_url(
|
|
connector_name.unwrap_or(connector_id),
|
|
connector_id,
|
|
)
|
|
});
|
|
let Some(plan) =
|
|
build_auth_elicitation_plan(call_id, &result, connector_id, connector_name, install_url)
|
|
else {
|
|
return result;
|
|
};
|
|
|
|
let request_id = rmcp::model::RequestId::String(plan.elicitation.elicitation_id.clone().into());
|
|
let request = ElicitationRequest::Url {
|
|
meta: Some(plan.elicitation.meta),
|
|
message: plan.elicitation.message,
|
|
url: plan.elicitation.url,
|
|
elicitation_id: plan.elicitation.elicitation_id,
|
|
};
|
|
let outcome = sess
|
|
.request_mcp_server_elicitation(
|
|
turn_context,
|
|
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
|
request_id,
|
|
request,
|
|
)
|
|
.await;
|
|
if !outcome
|
|
.response
|
|
.as_ref()
|
|
.is_some_and(|response| response.action == ElicitationAction::Accept)
|
|
{
|
|
return result;
|
|
}
|
|
|
|
refresh_codex_apps_after_connector_auth(sess, turn_context).await;
|
|
auth_elicitation_completed_result(&plan.auth_failure, result.meta)
|
|
}
|
|
|
|
async fn refresh_codex_apps_after_connector_auth(sess: &Arc<Session>, turn_context: &TurnContext) {
|
|
let mcp_tools_result = sess.hard_refresh_latest_codex_apps_tools().await;
|
|
|
|
match mcp_tools_result {
|
|
Ok(mcp_tools) => {
|
|
let auth = sess.services.auth_manager.auth().await;
|
|
connectors::refresh_accessible_connectors_cache_from_mcp_tools(
|
|
&turn_context.config,
|
|
auth.as_ref(),
|
|
&mcp_tools,
|
|
);
|
|
}
|
|
Err(err) => {
|
|
tracing::warn!("failed to refresh Codex Apps tools after connector auth: {err:#}");
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn augment_mcp_tool_request_meta_with_sandbox_state(
|
|
step_context: &StepContext,
|
|
prepared_call: &PreparedMcpCall,
|
|
mut meta: Option<serde_json::Value>,
|
|
) -> anyhow::Result<Option<serde_json::Value>> {
|
|
let supports_sandbox_state_meta = prepared_call
|
|
.server_supports_sandbox_state_meta_capability()
|
|
.await
|
|
.unwrap_or(false);
|
|
if !supports_sandbox_state_meta {
|
|
return Ok(meta);
|
|
}
|
|
|
|
let server_environment_id = prepared_call.server_environment_id();
|
|
let Some(sandbox_cwd) = prepared_call
|
|
.config()
|
|
.environment_cwds
|
|
.get(server_environment_id)
|
|
.cloned()
|
|
.or_else(|| sandbox_cwd_for_mcp_server(step_context, server_environment_id))
|
|
else {
|
|
return Ok(meta);
|
|
};
|
|
// TODO(anp): Build this metadata from the server's captured
|
|
// TurnEnvironment::sandbox_context instead of the runtime-wide Landlock value.
|
|
prepared_call
|
|
.augment_sandbox_metadata(
|
|
&mut meta,
|
|
SandboxState {
|
|
permission_profile: prepared_call.permission_profile().clone(),
|
|
codex_linux_sandbox_exe: prepared_call.config().codex_linux_sandbox_exe.clone(),
|
|
sandbox_cwd,
|
|
use_legacy_landlock: prepared_call.config().use_legacy_landlock,
|
|
use_mxc: prepared_call
|
|
.config()
|
|
.environment_use_mxc
|
|
.get(server_environment_id)
|
|
.copied()
|
|
.unwrap_or(false),
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
Ok(meta)
|
|
}
|
|
|
|
fn sandbox_cwd_for_mcp_server(step_context: &StepContext, environment_id: &str) -> Option<PathUri> {
|
|
if let Some(environment) = step_context
|
|
.environments
|
|
.turn_environments()
|
|
.find(|environment| environment.selection.environment_id == environment_id)
|
|
{
|
|
return Some(environment.cwd().clone());
|
|
}
|
|
|
|
if environment_id == codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID {
|
|
#[allow(deprecated)]
|
|
return Some(PathUri::from_abs_path(&step_context.turn.cwd));
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
async fn maybe_mark_thread_memory_mode_polluted(
|
|
sess: &Session,
|
|
turn_context: &TurnContext,
|
|
prepared_call: &PreparedMcpCall,
|
|
) {
|
|
if !turn_context.config.memories.disable_on_external_context {
|
|
return;
|
|
}
|
|
if !prepared_call.server_pollutes_memory() {
|
|
return;
|
|
}
|
|
state_db::mark_thread_memory_mode_polluted(
|
|
sess.services.state_db.as_deref(),
|
|
sess.thread_id,
|
|
"mcp_tool_call",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
fn sanitize_mcp_tool_result_for_model(
|
|
input_modalities: &[InputModality],
|
|
result: Result<CallToolResult, String>,
|
|
) -> Result<CallToolResult, String> {
|
|
let supports_image_input = input_modalities.contains(&InputModality::Image);
|
|
let supports_audio_input = input_modalities.contains(&InputModality::Audio);
|
|
if supports_image_input && supports_audio_input {
|
|
return result;
|
|
}
|
|
|
|
result.map(|call_tool_result| CallToolResult {
|
|
content: call_tool_result
|
|
.content
|
|
.iter()
|
|
.map(|block| {
|
|
if let Some(content_type) = block.get("type").and_then(serde_json::Value::as_str) {
|
|
if content_type == "image" && !supports_image_input {
|
|
return serde_json::json!({
|
|
"type": "text",
|
|
"text": "<image content omitted because you do not support image input>",
|
|
});
|
|
}
|
|
if content_type == "audio" && !supports_audio_input {
|
|
return serde_json::json!({
|
|
"type": "text",
|
|
"text": "<audio content omitted because you do not support audio input>",
|
|
});
|
|
}
|
|
}
|
|
|
|
block.clone()
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
structured_content: call_tool_result.structured_content,
|
|
is_error: call_tool_result.is_error,
|
|
meta: call_tool_result.meta,
|
|
})
|
|
}
|
|
|
|
fn truncate_mcp_tool_result_for_event(
|
|
result: &Result<CallToolResult, String>,
|
|
) -> Result<CallToolResult, String> {
|
|
match result {
|
|
Ok(call_tool_result) => Ok(truncate_mcp_tool_result(
|
|
call_tool_result,
|
|
MCP_TOOL_CALL_EVENT_RESULT_MAX_BYTES,
|
|
)
|
|
.into_owned()),
|
|
Err(message) => Err(truncate_text(
|
|
message,
|
|
TruncationPolicy::Bytes(MCP_TOOL_CALL_EVENT_RESULT_MAX_BYTES),
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn notify_mcp_tool_call_started(
|
|
sess: &Session,
|
|
turn_context: &TurnContext,
|
|
call_id: &str,
|
|
invocation: McpInvocation,
|
|
item_metadata: McpToolCallItemMetadata,
|
|
) {
|
|
let McpInvocation {
|
|
server,
|
|
tool,
|
|
arguments,
|
|
} = invocation;
|
|
let item = TurnItem::McpToolCall(McpToolCallItem {
|
|
id: call_id.to_string(),
|
|
server,
|
|
tool,
|
|
arguments: arguments.unwrap_or(JsonValue::Null),
|
|
connector_id: item_metadata.connector_id,
|
|
mcp_app_resource_uri: item_metadata.mcp_app_resource_uri,
|
|
mcp_app_ui: item_metadata.mcp_app_ui,
|
|
link_id: item_metadata.link_id,
|
|
app_name: item_metadata.app_name,
|
|
action_name: item_metadata.action_name,
|
|
plugin_id: item_metadata.plugin_id,
|
|
read_only_hint: item_metadata.read_only_hint,
|
|
status: McpToolCallStatus::InProgress,
|
|
result: None,
|
|
error: None,
|
|
duration: None,
|
|
});
|
|
sess.emit_turn_item_started(turn_context, &item).await;
|
|
}
|
|
|
|
async fn notify_mcp_tool_call_completed(
|
|
sess: &Session,
|
|
turn_context: &TurnContext,
|
|
call_id: &str,
|
|
invocation: McpInvocation,
|
|
item_metadata: McpToolCallItemMetadata,
|
|
duration: Duration,
|
|
result: Result<CallToolResult, String>,
|
|
) {
|
|
let (status, result, error) = match result {
|
|
Ok(result) if result.is_error.unwrap_or(false) => {
|
|
(McpToolCallStatus::Failed, Some(result), None)
|
|
}
|
|
Ok(result) => (McpToolCallStatus::Completed, Some(result), None),
|
|
Err(message) => (
|
|
McpToolCallStatus::Failed,
|
|
None,
|
|
Some(McpToolCallError { message }),
|
|
),
|
|
};
|
|
let McpInvocation {
|
|
server,
|
|
tool,
|
|
arguments,
|
|
} = invocation;
|
|
let item = TurnItem::McpToolCall(McpToolCallItem {
|
|
id: call_id.to_string(),
|
|
server,
|
|
tool,
|
|
arguments: arguments.unwrap_or(JsonValue::Null),
|
|
connector_id: item_metadata.connector_id,
|
|
mcp_app_resource_uri: item_metadata.mcp_app_resource_uri,
|
|
mcp_app_ui: item_metadata.mcp_app_ui,
|
|
link_id: item_metadata.link_id,
|
|
app_name: item_metadata.app_name,
|
|
action_name: item_metadata.action_name,
|
|
plugin_id: item_metadata.plugin_id,
|
|
read_only_hint: item_metadata.read_only_hint,
|
|
status,
|
|
result,
|
|
error,
|
|
duration: Some(duration),
|
|
});
|
|
sess.emit_turn_item_completed(turn_context, item).await;
|
|
}
|
|
|
|
async fn maybe_track_codex_app_used(
|
|
sess: &Session,
|
|
step_context: &StepContext,
|
|
server: &str,
|
|
metadata: &McpToolApprovalMetadata,
|
|
elicitation_type: Option<ElicitationType>,
|
|
) {
|
|
if server != CODEX_APPS_MCP_SERVER_NAME {
|
|
return;
|
|
}
|
|
let connector_id = metadata.connector_id.clone();
|
|
let app_name = metadata.connector_name.clone();
|
|
let invocation_type = if let Some(connector_id) = connector_id.as_deref() {
|
|
let mentioned_connector_ids = sess.get_connector_selection().await;
|
|
if mentioned_connector_ids.contains(connector_id) {
|
|
InvocationType::Explicit
|
|
} else {
|
|
InvocationType::Implicit
|
|
}
|
|
} else {
|
|
InvocationType::Implicit
|
|
};
|
|
|
|
let turn_context = &step_context.turn;
|
|
let tracking = build_track_events_context(
|
|
step_context.settings.model_info.slug.clone(),
|
|
sess.thread_id.to_string(),
|
|
turn_context.sub_id.clone(),
|
|
turn_context.originator.clone(),
|
|
Some(turn_context.turn_metadata_state.clone()),
|
|
);
|
|
sess.services.analytics_events_client.track_app_used(
|
|
tracking,
|
|
AppInvocation {
|
|
connector_id,
|
|
app_name,
|
|
invocation_type: Some(invocation_type),
|
|
},
|
|
elicitation_type,
|
|
);
|
|
}
|
|
|
|
fn track_mcp_tool_call_elicitation(
|
|
sess: &Session,
|
|
turn_context: &TurnContext,
|
|
call_id: &str,
|
|
elicitation_type: ElicitationType,
|
|
) {
|
|
sess.services
|
|
.analytics_events_client
|
|
.track_mcp_tool_call_elicitation(McpToolCallElicitation {
|
|
thread_id: sess.thread_id.to_string(),
|
|
turn_id: turn_context.sub_id.clone(),
|
|
item_id: call_id.to_string(),
|
|
elicitation_type,
|
|
});
|
|
}
|
|
|
|
fn mcp_tool_call_auth_elicitation_type(
|
|
server: &str,
|
|
connector_id: Option<&str>,
|
|
result: &CallToolResult,
|
|
) -> Option<ElicitationType> {
|
|
(server == CODEX_APPS_MCP_SERVER_NAME
|
|
&& is_connector_auth_failure_from_tool_result(result, connector_id))
|
|
.then_some(ElicitationType::AuthOrLink)
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct McpToolApprovalPolicy {
|
|
mode: AppToolApproval,
|
|
allow_persistent: bool,
|
|
}
|
|
|
|
enum McpToolApprovalApplication {
|
|
NotRequired,
|
|
Apply {
|
|
decision: ReviewDecision,
|
|
policy: McpToolApprovalPolicy,
|
|
},
|
|
}
|
|
|
|
impl McpToolApprovalPolicy {
|
|
fn for_server(mode: AppToolApproval) -> Self {
|
|
Self {
|
|
mode,
|
|
allow_persistent: true,
|
|
}
|
|
}
|
|
|
|
fn for_selected_plugin(mode: AppToolApproval) -> Self {
|
|
Self {
|
|
mode,
|
|
allow_persistent: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct McpToolApprovalMetadata {
|
|
annotations: Option<ToolAnnotations>,
|
|
pub(crate) connector_id: Option<String>,
|
|
link_id: Option<String>,
|
|
connector_name: Option<String>,
|
|
connector_description: Option<String>,
|
|
connected_account_email: Option<String>,
|
|
plugin_id: Option<String>,
|
|
tool_title: Option<String>,
|
|
tool_description: Option<String>,
|
|
mcp_app_resource_uri: Option<String>,
|
|
mcp_app_ui: Option<McpAppUi>,
|
|
codex_apps_meta: Option<serde_json::Map<String, serde_json::Value>>,
|
|
openai_file_input_optional_fields: Option<HashMap<String, Vec<String>>>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct McpToolApprovalContext {
|
|
pub(crate) invocation: Option<McpInvocation>,
|
|
pub(crate) metadata: McpToolApprovalMetadata,
|
|
pub(crate) review_context: GuardianReviewContext,
|
|
}
|
|
|
|
impl Session {
|
|
fn register_mcp_tool_approval_metadata(
|
|
&self,
|
|
call_id: &str,
|
|
invocation: &McpInvocation,
|
|
metadata: McpToolApprovalMetadata,
|
|
review_context: GuardianReviewContext,
|
|
) -> Arc<McpToolApprovalContext> {
|
|
let key = (invocation.server.clone(), call_id.to_string());
|
|
let metadata = Arc::new(McpToolApprovalContext {
|
|
invocation: (invocation.server == CODEX_APPS_MCP_SERVER_NAME
|
|
|| is_node_repl_backed_server(&invocation.server))
|
|
.then(|| invocation.clone()),
|
|
metadata,
|
|
review_context,
|
|
});
|
|
let mut registry = self
|
|
.mcp_tool_approval_metadata
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
// Keep approval details available while a tool is running, even across turns.
|
|
// When a new call starts, clean up entries left by finished calls.
|
|
registry.retain(|_, entry| entry.strong_count() != 0);
|
|
registry.insert(key, Arc::downgrade(&metadata));
|
|
metadata
|
|
}
|
|
|
|
pub(crate) fn mcp_tool_approval_metadata(
|
|
&self,
|
|
server: &str,
|
|
call_id: &str,
|
|
) -> Option<McpToolApprovalContext> {
|
|
self.mcp_tool_approval_metadata
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.get(&(server.to_string(), call_id.to_string()))
|
|
.and_then(std::sync::Weak::upgrade)
|
|
.map(|metadata| (*metadata).clone())
|
|
}
|
|
}
|
|
|
|
const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate";
|
|
const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri";
|
|
const MCP_TOOL_LINK_ID_META_KEY: &str = "link_id";
|
|
const MCP_TOOL_LINK_IS_IMPLICIT_META_KEY: &str = "link_is_implicit";
|
|
const MCP_TOOL_PLUGIN_ID_META_KEY: &str = "plugin_id";
|
|
const MCP_TOOL_ITEM_ID_META_KEY: &str = "itemId";
|
|
const MCP_TOOL_SESSION_ID_META_KEY: &str = "sessionId";
|
|
const MCP_TOOL_WINDOW_ID_META_KEY: &str = "windowId";
|
|
const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId";
|
|
const MCP_TOOL_CONNECTED_ACCOUNT_EMAIL_META_KEY: &str = "connected_account_email";
|
|
const MCP_TOOL_RESOURCE_URI_META_KEY: &str = "resource_uri";
|
|
|
|
#[cfg(test)]
|
|
async fn custom_mcp_tool_approval_mode(
|
|
sess: &Session,
|
|
turn_context: &TurnContext,
|
|
server: &str,
|
|
tool_name: &str,
|
|
) -> AppToolApproval {
|
|
let user_configured_mode = turn_context
|
|
.config
|
|
.config_layer_stack
|
|
.effective_config()
|
|
.as_table()
|
|
.and_then(|table| table.get("mcp_servers"))
|
|
.cloned()
|
|
.and_then(|value| {
|
|
HashMap::<String, codex_config::types::McpServerConfig>::deserialize(value).ok()
|
|
})
|
|
.and_then(|servers| {
|
|
let server_config = servers.get(server)?;
|
|
Some(
|
|
server_config
|
|
.tools
|
|
.get(tool_name)
|
|
.and_then(|tool| tool.approval_mode)
|
|
.or(server_config.default_tools_approval_mode)
|
|
.unwrap_or_default(),
|
|
)
|
|
});
|
|
if let Some(user_configured_mode) = user_configured_mode {
|
|
return user_configured_mode;
|
|
}
|
|
|
|
sess.services
|
|
.plugins_manager
|
|
.plugins_for_config(&turn_context.config.plugins_config_input())
|
|
.await
|
|
.plugins()
|
|
.iter()
|
|
.filter(|plugin| plugin.is_active())
|
|
.find_map(|plugin| {
|
|
let server_config = plugin.mcp_servers.get(server)?;
|
|
server_config
|
|
.tools
|
|
.get(tool_name)
|
|
.and_then(|tool| tool.approval_mode)
|
|
.or(server_config.default_tools_approval_mode)
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn build_mcp_tool_call_request_meta(
|
|
step_context: &StepContext,
|
|
server: &str,
|
|
call_id: &str,
|
|
metadata: Option<&McpToolApprovalMetadata>,
|
|
is_host_owned_apps: bool,
|
|
) -> Option<serde_json::Value> {
|
|
let mut request_meta = serde_json::Map::new();
|
|
request_meta.insert(
|
|
"callId".to_string(),
|
|
serde_json::Value::String(call_id.to_string()),
|
|
);
|
|
|
|
if let Some(turn_metadata) = step_context
|
|
.turn
|
|
.turn_metadata_state
|
|
.current_meta_value_for_mcp_request(ExecutionMetadata::from_settings(
|
|
&step_context.settings,
|
|
))
|
|
{
|
|
request_meta.insert(
|
|
crate::X_CODEX_TURN_METADATA_HEADER.to_string(),
|
|
turn_metadata,
|
|
);
|
|
}
|
|
|
|
if server == CODEX_APPS_MCP_SERVER_NAME {
|
|
let mut codex_apps_meta = metadata
|
|
.and_then(|metadata| metadata.codex_apps_meta.clone())
|
|
.unwrap_or_default();
|
|
codex_apps_meta.insert(
|
|
"call_id".to_string(),
|
|
serde_json::Value::String(call_id.to_string()),
|
|
);
|
|
// Only the captured host-owned Apps registration receives Core lineage.
|
|
codex_apps_meta.remove("root_turn_id");
|
|
if is_host_owned_apps
|
|
&& let Some(root_turn_id) = step_context.turn.turn_metadata_state.root_turn_id()
|
|
{
|
|
codex_apps_meta.insert(
|
|
"root_turn_id".to_string(),
|
|
serde_json::Value::String(root_turn_id),
|
|
);
|
|
}
|
|
request_meta.insert(
|
|
MCP_TOOL_CODEX_APPS_META_KEY.to_string(),
|
|
serde_json::Value::Object(codex_apps_meta),
|
|
);
|
|
}
|
|
if let Some(plugin_id) = metadata.and_then(|metadata| metadata.plugin_id.as_ref()) {
|
|
request_meta.insert(
|
|
MCP_TOOL_PLUGIN_ID_META_KEY.to_string(),
|
|
serde_json::Value::String(plugin_id.clone()),
|
|
);
|
|
}
|
|
|
|
if let Some(policies) = build_confirmation_policies_request_meta(
|
|
step_context,
|
|
server,
|
|
metadata.and_then(|metadata| metadata.connector_id.as_deref()),
|
|
) {
|
|
request_meta.insert(CONFIRMATION_POLICIES_META_KEY.to_string(), policies);
|
|
}
|
|
|
|
(!request_meta.is_empty()).then_some(serde_json::Value::Object(request_meta))
|
|
}
|
|
|
|
/// Builds confirmation-policy metadata for eligible actor MCP calls.
|
|
///
|
|
/// Policies follow the issuing step's model snapshot, including across approval
|
|
/// waits. REPL-backed connectors receive them; Guardian sessions are excluded.
|
|
/// Eligible calls get an empty object when no policies are configured, clearing
|
|
/// startup defaults. Text stays verbatim so runtimes own blank-value fallback.
|
|
fn build_confirmation_policies_request_meta(
|
|
step_context: &StepContext,
|
|
server: &str,
|
|
connector_id: Option<&str>,
|
|
) -> Option<serde_json::Value> {
|
|
if !is_node_repl_backed_connector(server, connector_id)
|
|
|| crate::guardian::is_basic_session_source(&step_context.turn.session_source)
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let mut policies = serde_json::Map::new();
|
|
if let Some(confirmation_policies) =
|
|
ResolvedModelMessages::from_model(&step_context.settings.model_info).confirmation_policies()
|
|
{
|
|
for (name, policy) in [
|
|
("browser_use", confirmation_policies.browser_use.as_ref()),
|
|
("computer_use", confirmation_policies.computer_use.as_ref()),
|
|
] {
|
|
if let Some(policy) = policy {
|
|
policies.insert(name.to_string(), serde_json::Value::String(policy.clone()));
|
|
}
|
|
}
|
|
}
|
|
Some(serde_json::Value::Object(policies))
|
|
}
|
|
|
|
fn with_mcp_tool_call_ids_meta(
|
|
meta: Option<serde_json::Value>,
|
|
thread_id: &str,
|
|
session_id: &str,
|
|
originating_call: Option<&ToolCallOrigin>,
|
|
) -> Option<serde_json::Value> {
|
|
let mut map = match meta {
|
|
Some(serde_json::Value::Object(map)) => map,
|
|
None => serde_json::Map::new(),
|
|
other => return other,
|
|
};
|
|
map.insert(
|
|
MCP_TOOL_THREAD_ID_META_KEY.to_string(),
|
|
serde_json::Value::String(thread_id.to_string()),
|
|
);
|
|
map.insert(
|
|
MCP_TOOL_SESSION_ID_META_KEY.to_string(),
|
|
serde_json::Value::String(session_id.to_string()),
|
|
);
|
|
if let Some(origin) = originating_call {
|
|
map.insert(
|
|
MCP_TOOL_WINDOW_ID_META_KEY.to_string(),
|
|
serde_json::Value::String(origin.window_id.clone()),
|
|
);
|
|
}
|
|
if let Some(item_id) = originating_call.and_then(|origin| origin.item_id.as_ref()) {
|
|
map.insert(
|
|
MCP_TOOL_ITEM_ID_META_KEY.to_string(),
|
|
serde_json::Value::String(item_id.to_string()),
|
|
);
|
|
}
|
|
Some(serde_json::Value::Object(map))
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct McpToolApprovalPromptOptions {
|
|
allow_session_remember: bool,
|
|
allow_persistent_approval: bool,
|
|
}
|
|
|
|
struct McpToolApprovalElicitationRequest<'a> {
|
|
server: &'a str,
|
|
metadata: Option<&'a McpToolApprovalMetadata>,
|
|
tool_params: Option<&'a serde_json::Value>,
|
|
tool_params_display: Option<&'a [RenderedMcpToolApprovalParam]>,
|
|
question: RequestUserInputQuestion,
|
|
message_override: Option<&'a str>,
|
|
prompt_options: McpToolApprovalPromptOptions,
|
|
}
|
|
|
|
pub(crate) const MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX: &str = "mcp_tool_call_approval";
|
|
pub(crate) const MCP_TOOL_APPROVAL_ACCEPT: &str = "Allow";
|
|
pub(crate) const MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION: &str = "Allow for this session";
|
|
const MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER: &str = "Allow and don't ask me again";
|
|
const MCP_TOOL_APPROVAL_CANCEL: &str = "Cancel";
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
struct McpToolApprovalKey {
|
|
server: String,
|
|
plugin_id: Option<String>,
|
|
connector_id: Option<String>,
|
|
link_id: Option<String>,
|
|
tool_name: String,
|
|
}
|
|
|
|
fn mcp_tool_approval_prompt_options(
|
|
allow_session_remember: bool,
|
|
allow_persistent_approval: bool,
|
|
tool_call_mcp_elicitation_enabled: bool,
|
|
) -> McpToolApprovalPromptOptions {
|
|
McpToolApprovalPromptOptions {
|
|
allow_session_remember,
|
|
allow_persistent_approval: tool_call_mcp_elicitation_enabled && allow_persistent_approval,
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
struct McpToolApproval {
|
|
decision: ReviewDecision,
|
|
source: Option<ApprovalResolutionSource>,
|
|
cancellation_reason: Option<McpApprovalCancellationReason>,
|
|
}
|
|
|
|
#[expect(clippy::too_many_arguments)]
|
|
async fn maybe_request_mcp_tool_approval(
|
|
sess: &Arc<Session>,
|
|
step_context: &Arc<StepContext>,
|
|
cancellation_token: &CancellationToken,
|
|
call_id: &str,
|
|
invocation: &McpInvocation,
|
|
invocation_tool_name: &ToolName,
|
|
hook_tool_name: &HookToolName,
|
|
metadata: &McpToolApprovalMetadata,
|
|
config: &codex_mcp::McpConfig,
|
|
permission_profile: &PermissionProfile,
|
|
environment_id: &str,
|
|
policy: McpToolApprovalPolicy,
|
|
) -> Option<McpToolApproval> {
|
|
let turn_context = &step_context.turn;
|
|
let strict_auto_review = turn_context.strict_auto_review_enabled();
|
|
let approvals_reviewer = connectors::mcp_approvals_reviewer_from_layers(
|
|
&config.config_layer_stack,
|
|
step_context
|
|
.settings
|
|
.mcp_approvals_reviewer_override
|
|
.unwrap_or(config.approvals_reviewer),
|
|
Some(turn_context.model_info().slug.as_str()),
|
|
&invocation.server,
|
|
metadata.connector_id.as_deref(),
|
|
metadata.link_id.as_deref(),
|
|
);
|
|
if !strict_auto_review
|
|
&& mcp_permission_prompt_is_auto_approved(
|
|
config.approval_policy.value(),
|
|
permission_profile,
|
|
McpPermissionPromptAutoApproveContext {
|
|
tool_approval_mode: Some(policy.mode),
|
|
},
|
|
)
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let annotations = metadata.annotations.as_ref();
|
|
if !strict_auto_review && !requires_mcp_tool_approval_for_mode(annotations, policy.mode) {
|
|
return None;
|
|
}
|
|
|
|
let session_approval_key =
|
|
session_mcp_tool_approval_key(invocation, Some(metadata), policy.mode);
|
|
let persistent_approval_key = if policy.allow_persistent {
|
|
persistent_mcp_tool_approval_key(invocation, Some(metadata), policy.mode)
|
|
} else {
|
|
None
|
|
};
|
|
if !strict_auto_review
|
|
&& let Some(key) = session_approval_key.as_ref()
|
|
&& mcp_tool_approval_is_remembered(sess, key).await
|
|
{
|
|
return Some(McpToolApproval {
|
|
decision: ReviewDecision::Approved,
|
|
source: None,
|
|
cancellation_reason: None,
|
|
});
|
|
}
|
|
|
|
let action = ApprovalAction::McpToolCall {
|
|
id: call_id.to_string(),
|
|
environment_id: environment_id.to_owned(),
|
|
server: invocation.server.clone(),
|
|
tool_name: invocation.tool.clone(),
|
|
arguments: invocation.arguments.clone(),
|
|
connector_id: metadata.connector_id.clone(),
|
|
connector_name: metadata.connector_name.clone(),
|
|
connector_description: metadata.connector_description.clone(),
|
|
connected_account_email: (invocation.server == CODEX_APPS_MCP_SERVER_NAME)
|
|
.then(|| metadata.connected_account_email.clone())
|
|
.flatten(),
|
|
tool_title: metadata.tool_title.clone(),
|
|
tool_description: metadata.tool_description.clone(),
|
|
annotations: metadata
|
|
.annotations
|
|
.as_ref()
|
|
.map(|annotations| GuardianMcpAnnotations {
|
|
destructive_hint: annotations.destructive_hint,
|
|
open_world_hint: annotations.open_world_hint,
|
|
read_only_hint: annotations.read_only_hint,
|
|
}),
|
|
hook_tool_name: hook_tool_name.clone(),
|
|
approval_policy: config.approval_policy.value(),
|
|
reviewer: approvals_reviewer,
|
|
approval_mode: policy.mode,
|
|
allow_session_remember: session_approval_key.is_some(),
|
|
allow_persistent_approval: persistent_approval_key.is_some(),
|
|
};
|
|
let approval_context = ApprovalContext {
|
|
exec_requirement: None,
|
|
review_context: GuardianReviewContext::from(step_context),
|
|
cancellation_token: Some(cancellation_token.clone()),
|
|
call_id: call_id.to_string(),
|
|
tool_name: invocation_tool_name.clone(),
|
|
strict_auto_review,
|
|
approval_reason: None,
|
|
retry_reason: None,
|
|
network_approval_context: None,
|
|
};
|
|
let approval = sess.request_approval(action, approval_context).await;
|
|
Some(McpToolApproval {
|
|
decision: match approval.result {
|
|
Ok(decision) => decision,
|
|
Err(ToolError::Rejected(rejection)) => ReviewDecision::denied(rejection),
|
|
Err(ToolError::Codex(_)) => ReviewDecision::Abort,
|
|
},
|
|
source: approval.source,
|
|
cancellation_reason: approval.cancellation_reason,
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn request_mcp_tool_user_approval(
|
|
sess: &Session,
|
|
turn_context: &TurnContext,
|
|
call_id: &str,
|
|
action: &ApprovalAction,
|
|
) -> ApprovalResolution {
|
|
let ApprovalAction::McpToolCall {
|
|
id,
|
|
server,
|
|
tool_name,
|
|
arguments,
|
|
connector_id,
|
|
connector_name,
|
|
connector_description,
|
|
connected_account_email,
|
|
tool_title,
|
|
tool_description,
|
|
approval_policy,
|
|
approval_mode,
|
|
allow_session_remember,
|
|
allow_persistent_approval,
|
|
..
|
|
} = action
|
|
else {
|
|
unreachable!("only MCP actions can request MCP tool approval");
|
|
};
|
|
|
|
if *approval_policy == AskForApproval::Never {
|
|
return ApprovalResolution {
|
|
decision: ReviewDecision::denied(
|
|
"MCP tool call requires approval, but approval policy is never",
|
|
),
|
|
source: ApprovalResolutionSource::User,
|
|
cancellation_reason: None,
|
|
};
|
|
}
|
|
|
|
let tool_call_mcp_elicitation_enabled = turn_context
|
|
.config
|
|
.features
|
|
.enabled(Feature::ToolCallMcpElicitation);
|
|
let prompt_options = mcp_tool_approval_prompt_options(
|
|
*allow_session_remember,
|
|
*allow_persistent_approval,
|
|
tool_call_mcp_elicitation_enabled,
|
|
);
|
|
let question_id = format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{id}");
|
|
let rendered_template = render_mcp_tool_approval_template(
|
|
server,
|
|
connector_id.as_deref(),
|
|
connector_name.as_deref(),
|
|
tool_title.as_deref(),
|
|
arguments.as_ref(),
|
|
);
|
|
let tool_params_display = rendered_template
|
|
.as_ref()
|
|
.map(|rendered_template| rendered_template.tool_params_display.clone())
|
|
.or_else(|| build_mcp_tool_approval_display_params(arguments.as_ref()));
|
|
let question = build_mcp_tool_approval_question(
|
|
question_id.clone(),
|
|
server,
|
|
tool_name,
|
|
connector_name.as_deref(),
|
|
prompt_options,
|
|
rendered_template
|
|
.as_ref()
|
|
.map(|rendered_template| rendered_template.question.as_str()),
|
|
);
|
|
let (request_dispatched, decision, cancellation_reason) = if tool_call_mcp_elicitation_enabled {
|
|
let link_id = sess
|
|
.mcp_tool_approval_metadata(server, id)
|
|
.and_then(|context| context.metadata.link_id);
|
|
let metadata = McpToolApprovalMetadata {
|
|
annotations: None,
|
|
connector_id: connector_id.clone(),
|
|
link_id,
|
|
connector_name: connector_name.clone(),
|
|
connector_description: connector_description.clone(),
|
|
connected_account_email: connected_account_email.clone(),
|
|
plugin_id: None,
|
|
tool_title: tool_title.clone(),
|
|
tool_description: tool_description.clone(),
|
|
mcp_app_resource_uri: None,
|
|
mcp_app_ui: None,
|
|
codex_apps_meta: None,
|
|
openai_file_input_optional_fields: None,
|
|
};
|
|
let request_id = rmcp::model::RequestId::String(question_id.clone().into());
|
|
let request =
|
|
build_mcp_tool_approval_elicitation_request(McpToolApprovalElicitationRequest {
|
|
server,
|
|
metadata: Some(&metadata),
|
|
tool_params: rendered_template
|
|
.as_ref()
|
|
.and_then(|rendered_template| rendered_template.tool_params.as_ref())
|
|
.or(arguments.as_ref()),
|
|
tool_params_display: tool_params_display.as_deref(),
|
|
question,
|
|
message_override: rendered_template
|
|
.as_ref()
|
|
.map(|rendered_template| rendered_template.elicitation_message.as_str()),
|
|
prompt_options,
|
|
});
|
|
let outcome = sess
|
|
.request_mcp_server_elicitation(turn_context, server.clone(), request_id, request)
|
|
.await;
|
|
// Missing replies are interruptions. A received Cancel needs provenance
|
|
// from its source; RPC success alone does not establish a user action.
|
|
let cancellation_reason = match outcome.response.as_ref() {
|
|
None => Some(McpApprovalCancellationReason::Interrupted),
|
|
Some(response) if response.action == ElicitationAction::Cancel => response
|
|
.meta
|
|
.as_ref()
|
|
.and_then(|meta| meta.get(CANCELLATION_REASON_KEY))
|
|
.and_then(|reason| serde_json::from_value(reason.clone()).ok()),
|
|
Some(_) => None,
|
|
};
|
|
(
|
|
outcome.sent,
|
|
parse_mcp_tool_approval_elicitation_response(outcome.response, &question_id),
|
|
cancellation_reason,
|
|
)
|
|
} else {
|
|
if turn_context.session_source.is_non_root_agent() {
|
|
return ApprovalResolution {
|
|
decision: ReviewDecision::denied(concat!(
|
|
"MCP tool approval via request_user_input requires the root thread. ",
|
|
"Ask the parent agent to handle this request. ",
|
|
"Do not retry the blocked action until the parent confirms the blocker is resolved."
|
|
)),
|
|
source: ApprovalResolutionSource::User,
|
|
cancellation_reason: None,
|
|
};
|
|
}
|
|
let args = RequestUserInputArgs {
|
|
questions: vec![question],
|
|
is_blocking: true,
|
|
auto_resolution_ms: None,
|
|
};
|
|
let response = sess
|
|
.request_user_input(turn_context, call_id.to_string(), args)
|
|
.await;
|
|
let cancellation_reason = response
|
|
.as_ref()
|
|
.and_then(|accepted| accepted.response.answers.get(&question_id))
|
|
.filter(|answer| {
|
|
answer
|
|
.answers
|
|
.iter()
|
|
.any(|answer| answer == MCP_TOOL_APPROVAL_CANCEL)
|
|
})
|
|
.map(|_| McpApprovalCancellationReason::User)
|
|
.or_else(|| {
|
|
response
|
|
.is_none()
|
|
.then_some(McpApprovalCancellationReason::Interrupted)
|
|
});
|
|
(
|
|
true,
|
|
parse_mcp_tool_approval_response(
|
|
response.map(|accepted| accepted.response),
|
|
&question_id,
|
|
),
|
|
cancellation_reason,
|
|
)
|
|
};
|
|
let decision = normalize_approval_decision_for_mode(decision, *approval_mode);
|
|
if request_dispatched
|
|
&& matches!(
|
|
decision,
|
|
ReviewDecision::Denied { .. } | ReviewDecision::TimedOut | ReviewDecision::Abort
|
|
)
|
|
{
|
|
track_mcp_tool_call_elicitation(sess, turn_context, call_id, ElicitationType::Approval);
|
|
}
|
|
ApprovalResolution {
|
|
cancellation_reason: if decision == ReviewDecision::Abort {
|
|
cancellation_reason
|
|
} else {
|
|
None
|
|
},
|
|
decision,
|
|
source: ApprovalResolutionSource::User,
|
|
}
|
|
}
|
|
|
|
fn session_mcp_tool_approval_key(
|
|
invocation: &McpInvocation,
|
|
metadata: Option<&McpToolApprovalMetadata>,
|
|
approval_mode: AppToolApproval,
|
|
) -> Option<McpToolApprovalKey> {
|
|
if approval_mode != AppToolApproval::Auto {
|
|
return None;
|
|
}
|
|
|
|
let connector_id = metadata.and_then(|metadata| metadata.connector_id.clone());
|
|
if invocation.server == CODEX_APPS_MCP_SERVER_NAME && connector_id.is_none() {
|
|
return None;
|
|
}
|
|
|
|
Some(McpToolApprovalKey {
|
|
server: invocation.server.clone(),
|
|
plugin_id: metadata.and_then(|metadata| metadata.plugin_id.clone()),
|
|
connector_id,
|
|
link_id: metadata.and_then(|metadata| metadata.link_id.clone()),
|
|
tool_name: invocation.tool.clone(),
|
|
})
|
|
}
|
|
|
|
fn persistent_mcp_tool_approval_key(
|
|
invocation: &McpInvocation,
|
|
metadata: Option<&McpToolApprovalMetadata>,
|
|
approval_mode: AppToolApproval,
|
|
) -> Option<McpToolApprovalKey> {
|
|
session_mcp_tool_approval_key(invocation, metadata, approval_mode)
|
|
}
|
|
|
|
pub(crate) fn build_guardian_mcp_tool_review_request(
|
|
call_id: &str,
|
|
invocation: &McpInvocation,
|
|
metadata: Option<&McpToolApprovalMetadata>,
|
|
) -> GuardianApprovalRequest {
|
|
GuardianApprovalRequest::McpToolCall {
|
|
id: call_id.to_string(),
|
|
server: invocation.server.clone(),
|
|
tool_name: invocation.tool.clone(),
|
|
arguments: invocation.arguments.clone(),
|
|
connector_id: metadata.and_then(|metadata| metadata.connector_id.clone()),
|
|
connector_name: metadata.and_then(|metadata| metadata.connector_name.clone()),
|
|
connector_description: metadata.and_then(|metadata| metadata.connector_description.clone()),
|
|
connected_account_email: (invocation.server == CODEX_APPS_MCP_SERVER_NAME)
|
|
.then(|| metadata.and_then(|metadata| metadata.connected_account_email.clone()))
|
|
.flatten(),
|
|
tool_title: metadata.and_then(|metadata| metadata.tool_title.clone()),
|
|
tool_description: metadata.and_then(|metadata| metadata.tool_description.clone()),
|
|
annotations: metadata
|
|
.and_then(|metadata| metadata.annotations.as_ref())
|
|
.map(|annotations| GuardianMcpAnnotations {
|
|
destructive_hint: annotations.destructive_hint,
|
|
open_world_hint: annotations.open_world_hint,
|
|
read_only_hint: annotations.read_only_hint,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn app_tool_policy(
|
|
config: &codex_mcp::McpConfig,
|
|
metadata: &McpToolApprovalMetadata,
|
|
tool_name: &str,
|
|
) -> AppToolPolicy {
|
|
let annotations = metadata.annotations.as_ref();
|
|
AppToolPolicyEvaluator::new(&config.config_layer_stack).policy(AppToolPolicyInput {
|
|
connector_id: metadata.connector_id.as_deref(),
|
|
link_id: metadata.link_id.as_deref(),
|
|
tool_name,
|
|
tool_title: metadata.tool_title.as_deref(),
|
|
destructive_hint: annotations.and_then(|annotations| annotations.destructive_hint),
|
|
open_world_hint: annotations.and_then(|annotations| annotations.open_world_hint),
|
|
})
|
|
}
|
|
|
|
fn mcp_tool_metadata(
|
|
tool_info: &ToolInfo,
|
|
plugin_id: Option<&str>,
|
|
arguments: Option<&JsonValue>,
|
|
) -> Result<McpToolApprovalMetadata, McpToolAccountError> {
|
|
let server = tool_info.server_name.as_str();
|
|
let tool_info = tool_info.clone();
|
|
let connector_description = (server == CODEX_APPS_MCP_SERVER_NAME)
|
|
.then(|| tool_info.namespace_description.clone())
|
|
.flatten();
|
|
|
|
let codex_apps_meta = tool_info
|
|
.tool
|
|
.meta
|
|
.as_ref()
|
|
.and_then(|meta| meta.get(MCP_TOOL_CODEX_APPS_META_KEY))
|
|
.and_then(serde_json::Value::as_object)
|
|
.cloned();
|
|
let link_id = if server == CODEX_APPS_MCP_SERVER_NAME {
|
|
account::resolve_account(&tool_info, arguments)?
|
|
} else {
|
|
None
|
|
};
|
|
let connected_account_email = if server == CODEX_APPS_MCP_SERVER_NAME {
|
|
codex_apps_meta
|
|
.as_ref()
|
|
.and_then(|meta| meta.get(MCP_TOOL_CONNECTED_ACCOUNT_EMAIL_META_KEY))
|
|
.and_then(serde_json::Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|email| !email.is_empty())
|
|
.map(str::to_string)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let mcp_app_resource_uri = get_mcp_app_resource_uri(tool_info.tool.meta.as_deref());
|
|
let mcp_app_ui = mcp_app_resource_uri.as_ref().and_then(|resource_uri| {
|
|
let preferred_model_display_mode = match tool_info
|
|
.tool
|
|
.meta
|
|
.as_ref()
|
|
.and_then(|meta| meta.get("openai/ui"))
|
|
.and_then(|ui| ui.get("preferredModelDisplayMode"))
|
|
.and_then(serde_json::Value::as_str)
|
|
{
|
|
Some("inline") => McpAppDisplayMode::Inline,
|
|
Some("fullscreen") => McpAppDisplayMode::Fullscreen,
|
|
_ => return None,
|
|
};
|
|
Some(McpAppUi {
|
|
resource_uri: resource_uri.clone(),
|
|
preferred_model_display_mode,
|
|
})
|
|
});
|
|
|
|
Ok(McpToolApprovalMetadata {
|
|
annotations: tool_info.tool.annotations,
|
|
connector_id: tool_info.connector_id,
|
|
link_id,
|
|
connector_name: tool_info.connector_name,
|
|
connector_description,
|
|
connected_account_email,
|
|
plugin_id: plugin_id.map(str::to_string),
|
|
tool_title: tool_info.tool.title,
|
|
tool_description: tool_info.tool.description.map(std::borrow::Cow::into_owned),
|
|
mcp_app_resource_uri,
|
|
mcp_app_ui,
|
|
codex_apps_meta,
|
|
// Disallow custom MCPs from uploading files via fileParams.
|
|
openai_file_input_optional_fields: openai_file_input_optional_fields_for_server(
|
|
server,
|
|
&tool_info.openai_file_input_optional_fields,
|
|
),
|
|
})
|
|
}
|
|
|
|
fn openai_file_input_optional_fields_for_server(
|
|
server: &str,
|
|
openai_file_input_optional_fields: &HashMap<String, Vec<String>>,
|
|
) -> Option<HashMap<String, Vec<String>>> {
|
|
(server == CODEX_APPS_MCP_SERVER_NAME)
|
|
.then(|| openai_file_input_optional_fields.clone())
|
|
.filter(|params| !params.is_empty())
|
|
}
|
|
|
|
fn get_mcp_app_resource_uri(
|
|
meta: Option<&serde_json::Map<String, serde_json::Value>>,
|
|
) -> Option<String> {
|
|
meta.and_then(|meta| {
|
|
meta.get("ui")
|
|
.and_then(serde_json::Value::as_object)
|
|
.and_then(|ui| ui.get("resourceUri"))
|
|
.and_then(serde_json::Value::as_str)
|
|
.or_else(|| {
|
|
meta.get(MCP_TOOL_UI_RESOURCE_URI_META_KEY)
|
|
.and_then(serde_json::Value::as_str)
|
|
})
|
|
.or_else(|| {
|
|
meta.get(MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY)
|
|
.and_then(serde_json::Value::as_str)
|
|
})
|
|
.map(str::to_string)
|
|
})
|
|
}
|
|
|
|
fn build_mcp_tool_approval_question(
|
|
question_id: String,
|
|
server: &str,
|
|
tool_name: &str,
|
|
connector_name: Option<&str>,
|
|
prompt_options: McpToolApprovalPromptOptions,
|
|
question_override: Option<&str>,
|
|
) -> RequestUserInputQuestion {
|
|
let question = question_override
|
|
.map(ToString::to_string)
|
|
.unwrap_or_else(|| {
|
|
build_mcp_tool_approval_fallback_message(server, tool_name, connector_name)
|
|
});
|
|
let question = format!("{}?", question.trim_end_matches('?'));
|
|
|
|
let mut options = vec![RequestUserInputQuestionOption {
|
|
label: MCP_TOOL_APPROVAL_ACCEPT.to_string(),
|
|
description: "Run the tool and continue.".to_string(),
|
|
}];
|
|
if prompt_options.allow_session_remember {
|
|
options.push(RequestUserInputQuestionOption {
|
|
label: MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(),
|
|
description: "Run the tool and remember this choice for this session.".to_string(),
|
|
});
|
|
}
|
|
if prompt_options.allow_persistent_approval {
|
|
options.push(RequestUserInputQuestionOption {
|
|
label: MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string(),
|
|
description: "Run the tool and remember this choice for future tool calls.".to_string(),
|
|
});
|
|
}
|
|
options.push(RequestUserInputQuestionOption {
|
|
label: MCP_TOOL_APPROVAL_CANCEL.to_string(),
|
|
description: "Cancel this tool call.".to_string(),
|
|
});
|
|
|
|
RequestUserInputQuestion {
|
|
id: question_id,
|
|
header: "Approve app tool call?".to_string(),
|
|
question,
|
|
is_other: false,
|
|
is_secret: false,
|
|
options: Some(options),
|
|
}
|
|
}
|
|
|
|
fn build_mcp_tool_approval_fallback_message(
|
|
server: &str,
|
|
tool_name: &str,
|
|
connector_name: Option<&str>,
|
|
) -> String {
|
|
let actor = connector_name
|
|
.map(str::trim)
|
|
.filter(|name| !name.is_empty())
|
|
.map(ToString::to_string)
|
|
.unwrap_or_else(|| {
|
|
if server == CODEX_APPS_MCP_SERVER_NAME {
|
|
"this app".to_string()
|
|
} else {
|
|
format!("the {server} MCP server")
|
|
}
|
|
});
|
|
format!("Allow {actor} to run tool \"{tool_name}\"?")
|
|
}
|
|
|
|
fn build_mcp_tool_approval_elicitation_request(
|
|
request: McpToolApprovalElicitationRequest<'_>,
|
|
) -> ElicitationRequest {
|
|
let message = request
|
|
.message_override
|
|
.map(ToString::to_string)
|
|
.unwrap_or_else(|| request.question.question.clone());
|
|
|
|
ElicitationRequest::Form {
|
|
meta: build_mcp_tool_approval_elicitation_meta(
|
|
request.server,
|
|
request.metadata,
|
|
request.tool_params,
|
|
request.tool_params_display,
|
|
request.prompt_options,
|
|
),
|
|
message,
|
|
requested_schema: serde_json::json!({
|
|
"type": "object",
|
|
"properties": {},
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn build_mcp_tool_approval_elicitation_meta(
|
|
server: &str,
|
|
metadata: Option<&McpToolApprovalMetadata>,
|
|
tool_params: Option<&serde_json::Value>,
|
|
tool_params_display: Option<&[RenderedMcpToolApprovalParam]>,
|
|
prompt_options: McpToolApprovalPromptOptions,
|
|
) -> Option<serde_json::Value> {
|
|
let mut meta = serde_json::Map::new();
|
|
meta.insert(
|
|
MCP_TOOL_APPROVAL_KIND_KEY.to_string(),
|
|
serde_json::Value::String(MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL.to_string()),
|
|
);
|
|
match (
|
|
prompt_options.allow_session_remember,
|
|
prompt_options.allow_persistent_approval,
|
|
) {
|
|
(true, true) => {
|
|
meta.insert(
|
|
MCP_TOOL_APPROVAL_PERSIST_KEY.to_string(),
|
|
serde_json::json!([
|
|
MCP_TOOL_APPROVAL_PERSIST_SESSION,
|
|
MCP_TOOL_APPROVAL_PERSIST_ALWAYS,
|
|
]),
|
|
);
|
|
}
|
|
(true, false) => {
|
|
meta.insert(
|
|
MCP_TOOL_APPROVAL_PERSIST_KEY.to_string(),
|
|
serde_json::Value::String(MCP_TOOL_APPROVAL_PERSIST_SESSION.to_string()),
|
|
);
|
|
}
|
|
(false, true) => {
|
|
meta.insert(
|
|
MCP_TOOL_APPROVAL_PERSIST_KEY.to_string(),
|
|
serde_json::Value::String(MCP_TOOL_APPROVAL_PERSIST_ALWAYS.to_string()),
|
|
);
|
|
}
|
|
(false, false) => {}
|
|
}
|
|
if let Some(metadata) = metadata {
|
|
if let Some(tool_title) = metadata.tool_title.as_ref() {
|
|
meta.insert(
|
|
MCP_TOOL_APPROVAL_TOOL_TITLE_KEY.to_string(),
|
|
serde_json::Value::String(tool_title.clone()),
|
|
);
|
|
}
|
|
if let Some(tool_description) = metadata.tool_description.as_ref() {
|
|
meta.insert(
|
|
MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY.to_string(),
|
|
serde_json::Value::String(tool_description.clone()),
|
|
);
|
|
}
|
|
if server == CODEX_APPS_MCP_SERVER_NAME
|
|
&& (metadata.connector_id.is_some()
|
|
|| metadata.connector_name.is_some()
|
|
|| metadata.connector_description.is_some())
|
|
{
|
|
meta.insert(
|
|
MCP_TOOL_APPROVAL_SOURCE_KEY.to_string(),
|
|
serde_json::Value::String(MCP_TOOL_APPROVAL_SOURCE_CONNECTOR.to_string()),
|
|
);
|
|
if let Some(connector_id) = metadata.connector_id.as_deref() {
|
|
meta.insert(
|
|
MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY.to_string(),
|
|
serde_json::Value::String(connector_id.to_string()),
|
|
);
|
|
}
|
|
if let Some(link_id) = metadata.link_id.as_ref() {
|
|
meta.insert(
|
|
MCP_TOOL_LINK_ID_META_KEY.to_string(),
|
|
serde_json::Value::String(link_id.clone()),
|
|
);
|
|
// Match the Codex Apps service's reserved implicit-link ID prefix.
|
|
meta.insert(
|
|
MCP_TOOL_LINK_IS_IMPLICIT_META_KEY.to_string(),
|
|
serde_json::Value::Bool(link_id.starts_with("implicit_link::")),
|
|
);
|
|
}
|
|
if let Some(connector_name) = metadata.connector_name.as_ref() {
|
|
meta.insert(
|
|
MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY.to_string(),
|
|
serde_json::Value::String(connector_name.clone()),
|
|
);
|
|
}
|
|
if let Some(connector_description) = metadata.connector_description.as_ref() {
|
|
meta.insert(
|
|
MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY.to_string(),
|
|
serde_json::Value::String(connector_description.clone()),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if let Some(tool_params) = tool_params {
|
|
meta.insert(
|
|
MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY.to_string(),
|
|
tool_params.clone(),
|
|
);
|
|
}
|
|
if let Some(tool_params_display) = tool_params_display
|
|
&& let Ok(tool_params_display) = serde_json::to_value(tool_params_display)
|
|
{
|
|
meta.insert(
|
|
MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY.to_string(),
|
|
tool_params_display,
|
|
);
|
|
}
|
|
(!meta.is_empty()).then_some(serde_json::Value::Object(meta))
|
|
}
|
|
|
|
fn build_mcp_tool_approval_display_params(
|
|
tool_params: Option<&serde_json::Value>,
|
|
) -> Option<Vec<crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam>> {
|
|
let tool_params = tool_params?.as_object()?;
|
|
let mut display_params = tool_params
|
|
.iter()
|
|
.map(
|
|
|(name, value)| crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam {
|
|
name: name.clone(),
|
|
value: value.clone(),
|
|
display_name: name.clone(),
|
|
},
|
|
)
|
|
.collect::<Vec<_>>();
|
|
display_params.sort_by(|left, right| left.name.cmp(&right.name));
|
|
Some(display_params)
|
|
}
|
|
|
|
fn parse_mcp_tool_approval_elicitation_response(
|
|
response: Option<ElicitationResponse>,
|
|
question_id: &str,
|
|
) -> ReviewDecision {
|
|
let Some(response) = response else {
|
|
return ReviewDecision::Abort;
|
|
};
|
|
match response.action {
|
|
ElicitationAction::Accept => {
|
|
match response
|
|
.meta
|
|
.as_ref()
|
|
.and_then(serde_json::Value::as_object)
|
|
.and_then(|meta| meta.get(MCP_TOOL_APPROVAL_PERSIST_KEY))
|
|
.and_then(serde_json::Value::as_str)
|
|
{
|
|
Some(MCP_TOOL_APPROVAL_PERSIST_SESSION) => {
|
|
return ReviewDecision::ApprovedForSession;
|
|
}
|
|
Some(MCP_TOOL_APPROVAL_PERSIST_ALWAYS) => {
|
|
return ReviewDecision::ApprovedMcpPolicyAmendment;
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
match parse_mcp_tool_approval_response(
|
|
request_user_input_response_from_elicitation_content(response.content),
|
|
question_id,
|
|
) {
|
|
ReviewDecision::Abort => ReviewDecision::Approved,
|
|
decision => decision,
|
|
}
|
|
}
|
|
ElicitationAction::Decline => ReviewDecision::denied("user rejected MCP tool call"),
|
|
ElicitationAction::Cancel => ReviewDecision::Abort,
|
|
_ => ReviewDecision::Abort,
|
|
}
|
|
}
|
|
|
|
fn request_user_input_response_from_elicitation_content(
|
|
content: Option<serde_json::Value>,
|
|
) -> Option<RequestUserInputResponse> {
|
|
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<RequestUserInputResponse>,
|
|
question_id: &str,
|
|
) -> ReviewDecision {
|
|
let Some(response) = response else {
|
|
return ReviewDecision::Abort;
|
|
};
|
|
let answers = response
|
|
.answers
|
|
.get(question_id)
|
|
.map(|answer| answer.answers.as_slice());
|
|
let Some(answers) = answers else {
|
|
return ReviewDecision::Abort;
|
|
};
|
|
if answers
|
|
.iter()
|
|
.any(|answer| answer == MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION)
|
|
{
|
|
ReviewDecision::ApprovedForSession
|
|
} else if answers
|
|
.iter()
|
|
.any(|answer| answer == MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER)
|
|
{
|
|
ReviewDecision::ApprovedMcpPolicyAmendment
|
|
} else if answers
|
|
.iter()
|
|
.any(|answer| answer == MCP_TOOL_APPROVAL_ACCEPT)
|
|
{
|
|
ReviewDecision::Approved
|
|
} else {
|
|
ReviewDecision::Abort
|
|
}
|
|
}
|
|
|
|
fn normalize_approval_decision_for_mode(
|
|
decision: ReviewDecision,
|
|
approval_mode: AppToolApproval,
|
|
) -> ReviewDecision {
|
|
if matches!(
|
|
approval_mode,
|
|
AppToolApproval::Prompt | AppToolApproval::Writes
|
|
) && matches!(
|
|
decision,
|
|
ReviewDecision::ApprovedForSession | ReviewDecision::ApprovedMcpPolicyAmendment
|
|
) {
|
|
ReviewDecision::Approved
|
|
} else {
|
|
decision
|
|
}
|
|
}
|
|
|
|
async fn mcp_tool_approval_is_remembered(sess: &Session, key: &McpToolApprovalKey) -> bool {
|
|
let store = sess.services.tool_approvals.lock().await;
|
|
matches!(store.get(key), Some(ReviewDecision::ApprovedForSession))
|
|
}
|
|
|
|
async fn remember_mcp_tool_approval(sess: &Session, key: McpToolApprovalKey) {
|
|
let mut store = sess.services.tool_approvals.lock().await;
|
|
store.put(key, ReviewDecision::ApprovedForSession);
|
|
}
|
|
|
|
async fn apply_mcp_tool_approval_decision(
|
|
sess: &Session,
|
|
turn_context: &TurnContext,
|
|
decision: &ReviewDecision,
|
|
session_approval_key: Option<McpToolApprovalKey>,
|
|
persistent_approval_key: Option<McpToolApprovalKey>,
|
|
) {
|
|
match decision {
|
|
ReviewDecision::ApprovedForSession => {
|
|
if let Some(key) = session_approval_key {
|
|
remember_mcp_tool_approval(sess, key).await;
|
|
}
|
|
}
|
|
ReviewDecision::ApprovedMcpPolicyAmendment => {
|
|
if let Some(key) = persistent_approval_key {
|
|
maybe_persist_mcp_tool_approval(sess, turn_context, key).await;
|
|
} else if let Some(key) = session_approval_key {
|
|
remember_mcp_tool_approval(sess, key).await;
|
|
}
|
|
}
|
|
ReviewDecision::Approved
|
|
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
|
| ReviewDecision::NetworkPolicyAmendment { .. }
|
|
| ReviewDecision::Denied { .. }
|
|
| ReviewDecision::TimedOut
|
|
| ReviewDecision::Abort => {}
|
|
}
|
|
}
|
|
|
|
async fn maybe_persist_mcp_tool_approval(
|
|
sess: &Session,
|
|
turn_context: &TurnContext,
|
|
key: McpToolApprovalKey,
|
|
) {
|
|
let tool_name = key.tool_name.clone();
|
|
|
|
let persist_result = if key.server == CODEX_APPS_MCP_SERVER_NAME {
|
|
let Some(connector_id) = key.connector_id.clone() else {
|
|
remember_mcp_tool_approval(sess, key).await;
|
|
return;
|
|
};
|
|
persist_codex_app_tool_approval(&turn_context.config, &connector_id, &tool_name).await
|
|
} else {
|
|
persist_non_app_mcp_tool_approval(
|
|
&turn_context.config,
|
|
&key.server,
|
|
&tool_name,
|
|
key.plugin_id.as_deref(),
|
|
)
|
|
.await
|
|
};
|
|
|
|
if let Err(err) = persist_result {
|
|
error!(
|
|
error = %err,
|
|
server = key.server,
|
|
tool_name,
|
|
"failed to persist MCP tool approval"
|
|
);
|
|
remember_mcp_tool_approval(sess, key).await;
|
|
return;
|
|
}
|
|
|
|
sess.reload_user_config_layer().await;
|
|
remember_mcp_tool_approval(sess, key).await;
|
|
}
|
|
|
|
async fn persist_codex_app_tool_approval(
|
|
config: &Config,
|
|
connector_id: &str,
|
|
tool_name: &str,
|
|
) -> anyhow::Result<()> {
|
|
ConfigEditsBuilder::for_config(config)
|
|
.with_edits([ConfigEdit::SetPath {
|
|
segments: vec![
|
|
"apps".to_string(),
|
|
connector_id.to_string(),
|
|
"tools".to_string(),
|
|
tool_name.to_string(),
|
|
"approval_mode".to_string(),
|
|
],
|
|
value: value("approve"),
|
|
}])
|
|
.apply()
|
|
.await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn persist_custom_mcp_tool_approval(
|
|
config: &Config,
|
|
server: &str,
|
|
tool_name: &str,
|
|
) -> anyhow::Result<()> {
|
|
let Some(config_edits_builder) = custom_mcp_tool_approval_config_builder(config, server)?
|
|
else {
|
|
anyhow::bail!("MCP server `{server}` is not configured in config.toml");
|
|
};
|
|
|
|
persist_custom_mcp_tool_approval_with(config_edits_builder, server, tool_name).await
|
|
}
|
|
|
|
async fn persist_non_app_mcp_tool_approval(
|
|
config: &Config,
|
|
server: &str,
|
|
tool_name: &str,
|
|
plugin_id: Option<&str>,
|
|
) -> anyhow::Result<()> {
|
|
if let Some(config_edits_builder) = custom_mcp_tool_approval_config_builder(config, server)? {
|
|
return persist_custom_mcp_tool_approval_with(config_edits_builder, server, tool_name)
|
|
.await;
|
|
}
|
|
|
|
if let Some(plugin_id) = plugin_id {
|
|
return ConfigEditsBuilder::for_config(config)
|
|
.with_edits([ConfigEdit::SetPath {
|
|
segments: vec![
|
|
"plugins".to_string(),
|
|
plugin_id.to_string(),
|
|
"mcp_servers".to_string(),
|
|
server.to_string(),
|
|
"tools".to_string(),
|
|
tool_name.to_string(),
|
|
"approval_mode".to_string(),
|
|
],
|
|
value: value("approve"),
|
|
}])
|
|
.apply()
|
|
.await;
|
|
}
|
|
|
|
anyhow::bail!("MCP server `{server}` is not configured in config.toml or an enabled plugin")
|
|
}
|
|
|
|
fn custom_mcp_tool_approval_config_builder(
|
|
config: &Config,
|
|
server: &str,
|
|
) -> anyhow::Result<Option<ConfigEditsBuilder>> {
|
|
if let Some(project_config_folder) = project_mcp_tool_approval_config_folder(config, server) {
|
|
return Ok(Some(ConfigEditsBuilder::new(&project_config_folder)));
|
|
}
|
|
|
|
Ok(user_mcp_server_is_configured(config, server)?
|
|
.then(|| ConfigEditsBuilder::for_config(config)))
|
|
}
|
|
|
|
async fn persist_custom_mcp_tool_approval_with(
|
|
config_edits_builder: ConfigEditsBuilder,
|
|
server: &str,
|
|
tool_name: &str,
|
|
) -> anyhow::Result<()> {
|
|
config_edits_builder
|
|
.with_edits([ConfigEdit::SetPath {
|
|
segments: vec![
|
|
"mcp_servers".to_string(),
|
|
server.to_string(),
|
|
"tools".to_string(),
|
|
tool_name.to_string(),
|
|
"approval_mode".to_string(),
|
|
],
|
|
value: value("approve"),
|
|
}])
|
|
.apply()
|
|
.await
|
|
}
|
|
|
|
fn user_mcp_server_is_configured(config: &Config, server: &str) -> anyhow::Result<bool> {
|
|
let Some(mcp_servers_toml) = config
|
|
.config_layer_stack
|
|
.effective_user_config()
|
|
.as_ref()
|
|
.and_then(|user_config| user_config.get("mcp_servers"))
|
|
.cloned()
|
|
else {
|
|
return Ok(false);
|
|
};
|
|
let servers =
|
|
HashMap::<String, codex_config::types::McpServerConfig>::deserialize(mcp_servers_toml)?;
|
|
Ok(servers.contains_key(server))
|
|
}
|
|
|
|
fn project_mcp_tool_approval_config_folder(
|
|
config: &Config,
|
|
server: &str,
|
|
) -> Option<AbsolutePathBuf> {
|
|
config
|
|
.config_layer_stack
|
|
.layers_high_to_low()
|
|
.find_map(|layer| {
|
|
if !matches!(layer.name, ConfigLayerSource::Project { .. }) {
|
|
return None;
|
|
}
|
|
|
|
let servers = layer
|
|
.config
|
|
.as_table()
|
|
.and_then(|table| table.get("mcp_servers"))
|
|
.cloned()
|
|
.and_then(|value| {
|
|
HashMap::<String, codex_config::types::McpServerConfig>::deserialize(value).ok()
|
|
})?;
|
|
if servers.contains_key(server) {
|
|
layer.config_folder()
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
fn requires_mcp_tool_approval(annotations: Option<&ToolAnnotations>) -> bool {
|
|
let destructive_hint = annotations.and_then(|annotations| annotations.destructive_hint);
|
|
if destructive_hint == Some(true) {
|
|
return true;
|
|
}
|
|
|
|
let read_only_hint = annotations
|
|
.and_then(|annotations| annotations.read_only_hint)
|
|
.unwrap_or(false);
|
|
if read_only_hint {
|
|
return false;
|
|
}
|
|
|
|
destructive_hint.unwrap_or(true)
|
|
|| annotations
|
|
.and_then(|annotations| annotations.open_world_hint)
|
|
.unwrap_or(true)
|
|
}
|
|
|
|
fn requires_mcp_tool_approval_for_mode(
|
|
annotations: Option<&ToolAnnotations>,
|
|
approval_mode: AppToolApproval,
|
|
) -> bool {
|
|
match approval_mode {
|
|
AppToolApproval::Auto => requires_mcp_tool_approval(annotations),
|
|
AppToolApproval::Prompt => true,
|
|
AppToolApproval::Writes => !annotations
|
|
.and_then(|annotations| annotations.read_only_hint)
|
|
.unwrap_or(false),
|
|
AppToolApproval::Approve => false,
|
|
}
|
|
}
|
|
|
|
async fn notify_mcp_tool_call_skip(
|
|
sess: &Session,
|
|
turn_context: &TurnContext,
|
|
call_id: &str,
|
|
invocation: McpInvocation,
|
|
item_metadata: McpToolCallItemMetadata,
|
|
message: String,
|
|
already_started: bool,
|
|
) -> Result<CallToolResult, String> {
|
|
if !already_started {
|
|
notify_mcp_tool_call_started(
|
|
sess,
|
|
turn_context,
|
|
call_id,
|
|
invocation.clone(),
|
|
item_metadata.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
let result = Err(message);
|
|
// Skipped calls never reach the backend-result recorder.
|
|
sess.services.rollout_thread_trace.record_mcp_tool_result(
|
|
&turn_context.sub_id,
|
|
call_id,
|
|
&result,
|
|
);
|
|
notify_mcp_tool_call_completed(
|
|
sess,
|
|
turn_context,
|
|
call_id,
|
|
invocation,
|
|
item_metadata,
|
|
Duration::ZERO,
|
|
truncate_mcp_tool_result_for_event(&result),
|
|
)
|
|
.await;
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "mcp_tool_call_tests.rs"]
|
|
mod tests;
|