Files
codex/codex-rs/core/src/mcp_tool_call.rs
T
jif 9cc14e7988 Add managed command checkpoints requiring fresh human approval (#58045)
## 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
2026-10-29 11:23:51 +00:00

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;