Route MCP tool calls through shared approval handling (#38108)

## What changed

- Represent MCP tool calls as approval actions and route them through the
  session-level approval flow for permission hooks, reviewer selection,
  rejection handling, and resolution telemetry.
- Preserve MCP-specific user prompts, session and persistent approval choices,
  and the approval policy and reviewer captured for the tool call.

## Testing

- Add coverage for permission hooks resolving MCP calls before user or
  automatic review, persistence choices, captured policy routing, and MCP turn
  metadata with strict automatic review enabled.

GitOrigin-RevId: 8e134b3d17a19dc62b3b56076d2cd05a54c9f638
This commit is contained in:
Dylan Hurd authored and copyberry committed 2026-08-12 03:00:47 +00:00
1 parent eb9dceba1a
commit 2230d64464
7 files changed
+618 -130

No files matched your search

+123 -86
View File
@@ -8,17 +8,17 @@ use crate::config::edit::ConfigEditsBuilder;
use crate::connectors;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::GuardianMcpAnnotations;
use crate::guardian::new_guardian_review_id;
use crate::guardian::review_approval_request;
use crate::hook_runtime::run_permission_request_hooks;
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::hook_names::HookToolName;
use crate::tools::sandboxing::PermissionRequestPayload;
use crate::tools::sandboxing::ApprovalAction;
use crate::tools::sandboxing::ToolError;
use crate::turn_metadata::McpTurnMetadataContext;
use codex_analytics::AppInvocation;
use codex_analytics::InvocationType;
@@ -31,12 +31,12 @@ use codex_connectors::AppToolPolicy;
use codex_connectors::AppToolPolicyEvaluator;
use codex_connectors::AppToolPolicyInput;
use codex_features::Feature;
use codex_hooks::PermissionRequestDecision;
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::mcp_permission_prompt_is_auto_approved;
@@ -72,6 +72,7 @@ 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_text;
@@ -112,12 +113,14 @@ pub(crate) async fn handle_mcp_tool_call(
sess: Arc<Session>,
step_context: &Arc<StepContext>,
call_id: String,
server: String,
tool_name: String,
tool_info: &ToolInfo,
hook_tool_name: HookToolName,
invocation_tool_name: ToolName,
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() {
@@ -233,6 +236,7 @@ pub(crate) async fn handle_mcp_tool_call(
step_context,
&call_id,
&invocation,
&invocation_tool_name,
&hook_tool_name,
&metadata,
prepared_call.config(),
@@ -1282,14 +1286,13 @@ struct McpToolApprovalKey {
}
fn mcp_tool_approval_prompt_options(
session_approval_key: Option<&McpToolApprovalKey>,
persistent_approval_key: Option<&McpToolApprovalKey>,
allow_session_remember: bool,
allow_persistent_approval: bool,
tool_call_mcp_elicitation_enabled: bool,
) -> McpToolApprovalPromptOptions {
McpToolApprovalPromptOptions {
allow_session_remember: session_approval_key.is_some(),
allow_persistent_approval: tool_call_mcp_elicitation_enabled
&& persistent_approval_key.is_some(),
allow_session_remember,
allow_persistent_approval: tool_call_mcp_elicitation_enabled && allow_persistent_approval,
}
}
@@ -1299,6 +1302,7 @@ async fn maybe_request_mcp_tool_approval(
step_context: &Arc<StepContext>,
call_id: &str,
invocation: &McpInvocation,
invocation_tool_name: &ToolName,
hook_tool_name: &HookToolName,
metadata: &McpToolApprovalMetadata,
config: &codex_mcp::McpConfig,
@@ -1340,93 +1344,133 @@ async fn maybe_request_mcp_tool_approval(
return Some(ReviewDecision::Approved);
}
match run_permission_request_hooks(
sess,
turn_context,
call_id,
PermissionRequestPayload {
tool_name: hook_tool_name.clone(),
tool_input: invocation
.arguments
.clone()
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())),
let action = ApprovalAction::McpToolCall {
id: call_id.to_string(),
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 {
review_context: GuardianReviewContext::from(step_context),
call_id: call_id.to_string(),
tool_name: invocation_tool_name.clone(),
strict_auto_review: false,
approval_reason: None,
retry_reason: None,
network_approval_context: None,
};
Some(
match sess.request_approval(action, approval_context).await {
Ok(decision) => decision,
Err(ToolError::Rejected(rejection)) => ReviewDecision::denied(rejection),
Err(ToolError::Codex(_)) => ReviewDecision::Abort,
},
)
.await
{
Some(PermissionRequestDecision::Allow) => {
return Some(ReviewDecision::Approved);
}
Some(PermissionRequestDecision::Deny { message }) => {
return Some(ReviewDecision::denied(message));
}
None => {}
}
}
pub(crate) async fn request_mcp_tool_user_approval(
sess: &Session,
turn_context: &TurnContext,
call_id: &str,
action: &ApprovalAction,
) -> ReviewDecision {
let ApprovalAction::McpToolCall {
id,
server,
tool_name,
arguments,
connector_id,
connector_name,
connector_description,
connected_account_email,
tool_title,
tool_description,
approval_mode,
allow_session_remember,
allow_persistent_approval,
..
} = action
else {
unreachable!("only MCP actions can request MCP tool approval");
};
let tool_call_mcp_elicitation_enabled = turn_context
.config
.features
.enabled(Feature::ToolCallMcpElicitation);
if matches!(
config.approval_policy.value(),
AskForApproval::OnRequest | AskForApproval::Granular(_)
) && approvals_reviewer == ApprovalsReviewer::AutoReview
{
let review_id = new_guardian_review_id();
let decision = review_approval_request(
sess,
step_context,
review_id.clone(),
build_guardian_mcp_tool_review_request(call_id, invocation, Some(metadata)),
Default::default(),
)
.await;
return Some(match decision {
ReviewDecision::Abort => ReviewDecision::denied("user rejected MCP tool call"),
decision => decision,
});
}
let prompt_options = mcp_tool_approval_prompt_options(
session_approval_key.as_ref(),
persistent_approval_key.as_ref(),
*allow_session_remember,
*allow_persistent_approval,
tool_call_mcp_elicitation_enabled,
);
let question_id = format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}");
let question_id = format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{id}");
let rendered_template = render_mcp_tool_approval_template(
&invocation.server,
metadata.connector_id.as_deref(),
metadata.connector_name.as_deref(),
metadata.tool_title.as_deref(),
invocation.arguments.as_ref(),
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(invocation.arguments.as_ref()));
.or_else(|| build_mcp_tool_approval_display_params(arguments.as_ref()));
let question = build_mcp_tool_approval_question(
question_id.clone(),
&invocation.server,
&invocation.tool,
metadata.connector_name.as_deref(),
server,
tool_name,
connector_name.as_deref(),
prompt_options,
rendered_template
.as_ref()
.map(|rendered_template| rendered_template.question.as_str()),
);
if tool_call_mcp_elicitation_enabled {
let request_id = rmcp::model::RequestId::String(
format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}").into(),
);
let metadata = McpToolApprovalMetadata {
annotations: None,
connector_id: connector_id.clone(),
link_id: None,
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,
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: &invocation.server,
metadata: Some(metadata),
server,
metadata: Some(&metadata),
tool_params: rendered_template
.as_ref()
.and_then(|rendered_template| rendered_template.tool_params.as_ref())
.or(invocation.arguments.as_ref()),
.or(arguments.as_ref()),
tool_params_display: tool_params_display.as_deref(),
question,
message_override: rendered_template
@@ -1435,18 +1479,12 @@ async fn maybe_request_mcp_tool_approval(
prompt_options,
});
let decision = parse_mcp_tool_approval_elicitation_response(
sess.request_mcp_server_elicitation(
turn_context.as_ref(),
invocation.server.clone(),
request_id,
request,
)
.await
.response,
sess.request_mcp_server_elicitation(turn_context, server.clone(), request_id, request)
.await
.response,
&question_id,
);
let decision = normalize_approval_decision_for_mode(decision, policy.mode);
return Some(decision);
return normalize_approval_decision_for_mode(decision, *approval_mode);
}
let args = RequestUserInputArgs {
@@ -1455,13 +1493,12 @@ async fn maybe_request_mcp_tool_approval(
auto_resolution_ms: None,
};
let response = sess
.request_user_input(turn_context.as_ref(), call_id.to_string(), args)
.request_user_input(turn_context, call_id.to_string(), args)
.await;
let decision = normalize_approval_decision_for_mode(
normalize_approval_decision_for_mode(
parse_mcp_tool_approval_response(response, &question_id),
policy.mode,
);
Some(decision)
*approval_mode,
)
}
pub(crate) fn mcp_approvals_reviewer(