diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 5e5430826e..9645e1f4ee 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -94,6 +94,10 @@ use codex_protocol::ThreadId; use codex_protocol::items::CollabAgentTool as CoreCollabAgentTool; use codex_protocol::items::ModelInvocationContext; use codex_protocol::items::TurnItem as CoreTurnItem; +use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY; +use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL; +use codex_protocol::mcp_approval_meta::CANCELLATION_REASON_KEY; +use codex_protocol::mcp_approval_meta::McpApprovalCancellationReason; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::Event; @@ -874,6 +878,11 @@ pub(crate) async fn apply_bespoke_event_handling( }); } EventMsg::ElicitationRequest(request) => { + let mcp_tool_approval = matches!( + &request.request, + codex_protocol::approvals::ElicitationRequest::Form { meta: Some(meta), .. } + if meta.get(APPROVAL_KIND_KEY).and_then(serde_json::Value::as_str) == Some(APPROVAL_KIND_MCP_TOOL_CALL) + ); let user_verification = matches!( &request.request, codex_protocol::approvals::ElicitationRequest::UserVerification { .. } @@ -930,6 +939,7 @@ pub(crate) async fn apply_bespoke_event_handling( PendingMcpElicitationResponse { receiver: rx, user_verification, + mcp_tool_approval, }, conversation, thread_state, @@ -1723,6 +1733,7 @@ async fn on_request_user_input_response( struct PendingMcpElicitationResponse { receiver: oneshot::Receiver, user_verification: bool, + mcp_tool_approval: bool, } async fn on_mcp_server_elicitation_response( @@ -1737,12 +1748,30 @@ async fn on_mcp_server_elicitation_response( let response = pending.receiver.await; resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await; drop(permission_guard); - let response = if pending.user_verification { + let interrupted = !matches!(&response, Ok(Ok(_))); + let mut response = if pending.user_verification { crate::user_verification_response::from_client_result(response) } else { mcp_server_elicitation_response_from_client_result(response) }; + if pending.mcp_tool_approval + && interrupted + && response.action == McpServerElicitationAction::Cancel + { + // Only the synthesized response is ours to attribute. Successful client + // replies retain their provenance, including an absent reason. + let mut meta = match response.meta.take() { + Some(serde_json::Value::Object(meta)) => meta, + _ => serde_json::Map::new(), + }; + meta.insert( + CANCELLATION_REASON_KEY.to_string(), + serde_json::json!(McpApprovalCancellationReason::Interrupted), + ); + response.meta = Some(serde_json::Value::Object(meta)); + } + if let Err(err) = conversation .submit(Op::ResolveElicitation { server_name, diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index c161ede8da..62438b74fc 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -16,6 +16,7 @@ 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; @@ -60,9 +61,11 @@ 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; @@ -344,7 +347,20 @@ pub(crate) async fn handle_mcp_tool_call( .await } ReviewDecision::Abort => { - let message = "user cancelled MCP tool call".to_string(); + 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(), @@ -1484,6 +1500,7 @@ fn mcp_tool_approval_prompt_options( struct McpToolApproval { decision: ReviewDecision, source: Option, + cancellation_reason: Option, } #[expect(clippy::too_many_arguments)] @@ -1544,6 +1561,7 @@ async fn maybe_request_mcp_tool_approval( return Some(McpToolApproval { decision: ReviewDecision::Approved, source: None, + cancellation_reason: None, }); } @@ -1593,6 +1611,7 @@ async fn maybe_request_mcp_tool_approval( Err(ToolError::Codex(_)) => ReviewDecision::Abort, }, source: approval.source, + cancellation_reason: approval.cancellation_reason, }) } @@ -1601,7 +1620,7 @@ pub(crate) async fn request_mcp_tool_user_approval( turn_context: &TurnContext, call_id: &str, action: &ApprovalAction, -) -> ReviewDecision { +) -> ApprovalResolution { let ApprovalAction::McpToolCall { id, server, @@ -1624,9 +1643,13 @@ pub(crate) async fn request_mcp_tool_user_approval( }; if *approval_policy == AskForApproval::Never { - return ReviewDecision::denied( - "MCP tool call requires approval, but approval policy is 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 @@ -1660,7 +1683,7 @@ pub(crate) async fn request_mcp_tool_user_approval( .as_ref() .map(|rendered_template| rendered_template.question.as_str()), ); - let (request_dispatched, decision) = if tool_call_mcp_elicitation_enabled { + 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); @@ -1698,17 +1721,33 @@ pub(crate) async fn request_mcp_tool_user_approval( 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 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." - )); + 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], @@ -1718,12 +1757,28 @@ pub(crate) async fn request_mcp_tool_user_approval( 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); @@ -1735,7 +1790,15 @@ pub(crate) async fn request_mcp_tool_user_approval( { track_mcp_tool_call_elicitation(sess, turn_context, call_id, ElicitationType::Approval); } - decision + ApprovalResolution { + cancellation_reason: if decision == ReviewDecision::Abort { + cancellation_reason + } else { + None + }, + decision, + source: ApprovalResolutionSource::User, + } } fn session_mcp_tool_approval_key( diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index b4145fee25..8726cd6990 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -2330,7 +2330,11 @@ async fn dispatched_mcp_approval_with_closed_response_is_classified_as_approval( *session.active_turn.lock().await = None; assert_eq!( approval.await.expect("approval task should complete"), - ReviewDecision::Abort + ApprovalResolution { + decision: ReviewDecision::Abort, + source: ApprovalResolutionSource::User, + cancellation_reason: Some(McpApprovalCancellationReason::Interrupted), + } ); item["status"] = serde_json::json!("failed"); @@ -3002,6 +3006,7 @@ async fn permission_request_hook_allows_mcp_tool_call() { Some(McpToolApproval { decision: ReviewDecision::Approved, source: Some(ApprovalResolutionSource::Hook), + cancellation_reason: None, }) ); let log = std::fs::read_to_string(log_path).expect("read MCP permission hook log"); @@ -3079,6 +3084,7 @@ async fn permission_request_hook_uses_hook_tool_name_without_metadata() { Some(McpToolApproval { decision: ReviewDecision::Approved, source: Some(ApprovalResolutionSource::Hook), + cancellation_reason: None, }) ); let log = std::fs::read_to_string(log_path).expect("read MCP permission hook log"); @@ -3172,6 +3178,7 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() { Some(McpToolApproval { decision: ReviewDecision::Approved, source: None, + cancellation_reason: None, }) ); assert!( @@ -3276,6 +3283,7 @@ async fn strict_auto_review_forces_guardian_for_mcp_policy_skip() { let Some(McpToolApproval { decision: ReviewDecision::Denied { rejection: message }, source: Some(ApprovalResolutionSource::Guardian), + cancellation_reason: None, }) = decision else { panic!("guardian-denied MCP approval should carry a rejection message"); @@ -3391,6 +3399,7 @@ async fn assert_mcp_user_approval_persistence( Some(McpToolApproval { decision: expected_decision, source: Some(ApprovalResolutionSource::User), + cancellation_reason: None, }), ); } diff --git a/codex-rs/core/src/tools/approvals.rs b/codex-rs/core/src/tools/approvals.rs index 7840013fe3..cdd468cc16 100644 --- a/codex-rs/core/src/tools/approvals.rs +++ b/codex-rs/core/src/tools/approvals.rs @@ -30,6 +30,7 @@ use codex_protocol::approvals::NetworkApprovalContext; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::error::CodexErr; +use codex_protocol::mcp_approval_meta::McpApprovalCancellationReason; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::AskForApproval; @@ -404,12 +405,14 @@ pub(crate) enum ApprovalResolutionSource { pub(crate) struct ApprovalResult { pub(crate) result: Result, pub(crate) source: Option, + pub(crate) cancellation_reason: Option, } #[derive(Clone, Debug, PartialEq, Eq)] -struct ApprovalResolution { - decision: ReviewDecision, - source: ApprovalResolutionSource, +pub(crate) struct ApprovalResolution { + pub(crate) decision: ReviewDecision, + pub(crate) source: ApprovalResolutionSource, + pub(crate) cancellation_reason: Option, } impl ApprovalResolution { @@ -466,6 +469,7 @@ impl Session { return ApprovalResult { result: Err(ToolError::Rejected(reason.to_string())), source: None, + cancellation_reason: None, }; } let is_mcp_tool_call = matches!(&action, ApprovalAction::McpToolCall { .. }); @@ -490,10 +494,12 @@ impl Session { Some(PermissionRequestDecision::Allow) => ApprovalResolution { decision: ReviewDecision::Approved, source: ApprovalResolutionSource::Hook, + cancellation_reason: None, }, Some(PermissionRequestDecision::Deny { message }) => ApprovalResolution { decision: ReviewDecision::denied(message), source: ApprovalResolutionSource::Hook, + cancellation_reason: None, }, None => self.request_reviewer_approval(action, &ctx).await, }; @@ -502,10 +508,12 @@ impl Session { record_resolution(&ctx, &resolution); } let source = Some(resolution.source); + let cancellation_reason = resolution.cancellation_reason; if is_mcp_tool_call && resolution.decision == ReviewDecision::ApprovedMcpPolicyAmendment { return ApprovalResult { result: Ok(resolution.decision), source, + cancellation_reason, }; } if is_network_approval { @@ -519,6 +527,7 @@ impl Session { return ApprovalResult { result: Ok(resolution.decision), source, + cancellation_reason, }; } (ReviewDecision::Abort, ApprovalResolutionSource::Guardian) => { @@ -527,6 +536,7 @@ impl Session { "automatic approval review was cancelled".to_string(), )), source, + cancellation_reason, }; } _ => {} @@ -535,6 +545,7 @@ impl Session { ApprovalResult { result: resolution.into_tool_result(ctx.review_context.turn().model_info()), source, + cancellation_reason, } } @@ -545,14 +556,13 @@ impl Session { ) -> ApprovalResolution { if let Some(decision) = self.request_guardian_approval(action.clone(), ctx).await { ApprovalResolution { + cancellation_reason: (decision == ReviewDecision::Abort) + .then_some(McpApprovalCancellationReason::Interrupted), decision, source: ApprovalResolutionSource::Guardian, } } else { - ApprovalResolution { - decision: self.request_user_approval(&action, ctx).await, - source: ApprovalResolutionSource::User, - } + self.request_user_approval(&action, ctx).await } } @@ -667,8 +677,8 @@ impl Session { &self, action: &ApprovalAction, ctx: &ApprovalContext, - ) -> ReviewDecision { - match action { + ) -> ApprovalResolution { + let decision = match action { ApprovalAction::ExecCommand { environment_id, command, @@ -760,44 +770,43 @@ impl Session { .clone() .or_else(|| ctx.approval_reason.clone()); if *permissions_preapproved && reason.is_none() { - return ReviewDecision::Approved; + ReviewDecision::Approved + } else if reason.is_some() { + self.request_patch_approval( + ctx.review_context.turn(), + ctx.call_id.clone(), + changes.as_ref().clone(), + reason, + /*grant_root*/ None, + ) + .await + } else { + with_cached_approval( + &self.services, + "apply_patch", + action.cache_keys(), + || async { + self.request_patch_approval( + ctx.review_context.turn(), + ctx.call_id.clone(), + changes.as_ref().clone(), + /*reason*/ None, + /*grant_root*/ None, + ) + .await + }, + ) + .await } - if reason.is_some() { - return self - .request_patch_approval( - ctx.review_context.turn(), - ctx.call_id.clone(), - changes.as_ref().clone(), - reason, - /*grant_root*/ None, - ) - .await; - } - with_cached_approval( - &self.services, - "apply_patch", - action.cache_keys(), - || async { - self.request_patch_approval( - ctx.review_context.turn(), - ctx.call_id.clone(), - changes.as_ref().clone(), - /*reason*/ None, - /*grant_root*/ None, - ) - .await - }, - ) - .await } ApprovalAction::McpToolCall { .. } => { - request_mcp_tool_user_approval( + return request_mcp_tool_user_approval( self, ctx.review_context.turn(), &ctx.call_id, action, ) - .await + .await; } ApprovalAction::NetworkAccess { environment_id, @@ -826,6 +835,11 @@ impl Session { ApprovalAction::RequestPermissions { .. } => { unreachable!("permission requests are routed directly to Guardian") } + }; + ApprovalResolution { + decision, + source: ApprovalResolutionSource::User, + cancellation_reason: None, } } } diff --git a/codex-rs/core/src/tools/approvals_tests.rs b/codex-rs/core/src/tools/approvals_tests.rs index 4ecd7460bf..09baa934c8 100644 --- a/codex-rs/core/src/tools/approvals_tests.rs +++ b/codex-rs/core/src/tools/approvals_tests.rs @@ -14,6 +14,7 @@ fn approval_resolution_rejects_denied_network_policy_amendment() { }, }, source: ApprovalResolutionSource::User, + cancellation_reason: None, }; assert!(matches!( @@ -27,6 +28,7 @@ fn approval_resolution_rejects_mcp_policy_amendment() { let resolution = ApprovalResolution { decision: ReviewDecision::ApprovedMcpPolicyAmendment, source: ApprovalResolutionSource::User, + cancellation_reason: None, }; assert!(matches!( @@ -40,6 +42,7 @@ fn approval_resolution_aborts_turn_when_approval_is_aborted() { let resolution = ApprovalResolution { decision: ReviewDecision::Abort, source: ApprovalResolutionSource::User, + cancellation_reason: None, }; assert!(matches!( @@ -67,6 +70,7 @@ fn approval_resolution_uses_acting_model_timeout_instructions() { let resolution = ApprovalResolution { decision: ReviewDecision::TimedOut, source: ApprovalResolutionSource::Guardian, + cancellation_reason: None, }; assert!(matches!( @@ -167,11 +171,13 @@ async fn non_utf8_cwd_preserves_approval_routing( ApprovalResolution { decision: ReviewDecision::Approved, source: ApprovalResolutionSource::User, + cancellation_reason: None, } } else { ApprovalResolution { decision: ReviewDecision::Approved, source: ApprovalResolutionSource::Guardian, + cancellation_reason: None, } }; assert_eq!(approval.await, expected); diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 7523d8b6ec..8f4041420c 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -29,6 +29,7 @@ use std::borrow::Cow; use crate::session::turn_context::TurnContext; pub(crate) use approvals::ApprovalContext; +pub(crate) use approvals::ApprovalResolution; pub(crate) use approvals::ApprovalResolutionSource; use codex_features::Feature; use codex_protocol::exec_output::ExecToolCallOutput; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 8d3fcd8861..0fde82a326 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -27,6 +27,7 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpServerElicitationAction; +use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; @@ -96,6 +97,10 @@ use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::SandboxMode; +use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY; +use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL; +use codex_protocol::mcp_approval_meta::CANCELLATION_REASON_KEY; +use codex_protocol::mcp_approval_meta::McpApprovalCancellationReason; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -2002,11 +2007,19 @@ fn resume_lookup_model_providers( } } -fn canceled_mcp_server_elicitation_response() -> Result { +fn canceled_mcp_server_elicitation_response( + request: &McpServerElicitationRequest, +) -> Result { + let tool_approval = matches!(request, McpServerElicitationRequest::Form { meta: Some(meta), .. } + if meta.get(APPROVAL_KIND_KEY).and_then(Value::as_str) == Some(APPROVAL_KIND_MCP_TOOL_CALL)); serde_json::to_value(McpServerElicitationRequestResponse { action: McpServerElicitationAction::Cancel, content: None, - meta: None, + meta: tool_approval.then(|| { + serde_json::json!({ + CANCELLATION_REASON_KEY: McpApprovalCancellationReason::NonInteractive, + }) + }), }) .map_err(|err| format!("failed to encode mcp elicitation response: {err}")) } @@ -2077,11 +2090,11 @@ async fn handle_server_request( ) { let method = server_request_method_name(&request); let handle_result = match request { - ServerRequest::McpServerElicitationRequest { request_id, .. } => { + ServerRequest::McpServerElicitationRequest { request_id, params } => { // Exec auto-cancels elicitation instead of surfacing it // interactively. Preserve that behavior for attached subagent // threads too so we do not turn a cancel into a decline/error. - match canceled_mcp_server_elicitation_response() { + match canceled_mcp_server_elicitation_response(¶ms.request) { Ok(value) => { resolve_server_request( client, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 895cd7eef7..d2e20e5f39 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -462,7 +462,14 @@ fn should_backfill_turn_completed_items_backfills_persisted_summaries_only() { #[test] fn canceled_mcp_server_elicitation_response_uses_cancel_action() { - let value = canceled_mcp_server_elicitation_response() + let request = serde_json::from_value(serde_json::json!({ + "mode": "form", + "message": "Allow the tool call?", + "requestedSchema": { "type": "object", "properties": {} }, + "_meta": { APPROVAL_KIND_KEY: APPROVAL_KIND_MCP_TOOL_CALL }, + })) + .expect("valid MCP tool approval request"); + let value = canceled_mcp_server_elicitation_response(&request) .expect("mcp elicitation cancel response should serialize"); let response: McpServerElicitationRequestResponse = serde_json::from_value(value).expect("cancel response should deserialize"); @@ -472,7 +479,9 @@ fn canceled_mcp_server_elicitation_response_uses_cancel_action() { McpServerElicitationRequestResponse { action: McpServerElicitationAction::Cancel, content: None, - meta: None, + meta: Some(serde_json::json!({ + CANCELLATION_REASON_KEY: McpApprovalCancellationReason::NonInteractive, + })), } ); } diff --git a/codex-rs/protocol/src/mcp_approval_meta.rs b/codex-rs/protocol/src/mcp_approval_meta.rs index 1ed0f2f3fd..674be8244c 100644 --- a/codex-rs/protocol/src/mcp_approval_meta.rs +++ b/codex-rs/protocol/src/mcp_approval_meta.rs @@ -17,6 +17,18 @@ pub const APPROVALS_REVIEWER_KEY: &str = "approvals_reviewer"; pub const PERSIST_KEY: &str = "persist"; pub const PERSIST_SESSION: &str = "session"; pub const PERSIST_ALWAYS: &str = "always"; +/// Set where cancellation originates, before user actions and runtime failures +/// are flattened into the same elicitation action. Absence means unknown. +pub const CANCELLATION_REASON_KEY: &str = "codex_approval_cancellation_reason"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum McpApprovalCancellationReason { + User, + Interrupted, + NonInteractive, +} + pub const SOURCE_KEY: &str = "source"; pub const SOURCE_CONNECTOR: &str = "connector"; pub const CONNECTOR_ID_KEY: &str = "connector_id"; diff --git a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs index 78f7dcf0fa..7ec9a65642 100644 --- a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -15,6 +15,8 @@ use codex_protocol::ThreadId; use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as APPROVAL_META_KIND_KEY; use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as APPROVAL_META_KIND_MCP_TOOL_CALL; use codex_protocol::mcp_approval_meta::APPROVAL_KIND_TOOL_SUGGESTION as APPROVAL_META_KIND_TOOL_SUGGESTION; +use codex_protocol::mcp_approval_meta::CANCELLATION_REASON_KEY; +use codex_protocol::mcp_approval_meta::McpApprovalCancellationReason; use codex_protocol::mcp_approval_meta::PERSIST_ALWAYS as APPROVAL_PERSIST_ALWAYS_VALUE; use codex_protocol::mcp_approval_meta::PERSIST_KEY as APPROVAL_PERSIST_KEY; use codex_protocol::mcp_approval_meta::PERSIST_SESSION as APPROVAL_PERSIST_SESSION_VALUE; @@ -175,6 +177,7 @@ pub(crate) struct McpServerElicitationFormRequest { message: String, approval_display_params: Vec, response_mode: McpServerElicitationResponseMode, + is_tool_approval: bool, fields: Vec, tool_suggestion: Option, } @@ -330,6 +333,7 @@ impl McpServerElicitationFormRequest { message, approval_display_params, response_mode, + is_tool_approval, fields, tool_suggestion, }) @@ -1151,7 +1155,11 @@ impl McpServerElicitationOverlay { self.request.request_id.clone(), McpServerElicitationAction::Cancel, /*content*/ None, - /*meta*/ None, + self.request.is_tool_approval.then(|| { + serde_json::json!({ + CANCELLATION_REASON_KEY: McpApprovalCancellationReason::User, + }) + }), ); } @@ -1190,7 +1198,14 @@ impl McpServerElicitationOverlay { })), ), Some(APPROVAL_DECLINE_VALUE) => (McpServerElicitationAction::Decline, None), - Some(APPROVAL_CANCEL_VALUE) => (McpServerElicitationAction::Cancel, None), + Some(APPROVAL_CANCEL_VALUE) => ( + McpServerElicitationAction::Cancel, + self.request.is_tool_approval.then(|| { + serde_json::json!({ + CANCELLATION_REASON_KEY: McpApprovalCancellationReason::User, + }) + }), + ), _ => (McpServerElicitationAction::Cancel, None), }; self.app_event_tx.resolve_elicitation( @@ -1875,6 +1890,7 @@ mod tests { message: "Allow this request?".to_string(), approval_display_params: Vec::new(), response_mode: McpServerElicitationResponseMode::FormContent, + is_tool_approval: false, fields: vec![McpServerElicitationField { id: "confirmed".to_string(), label: "Confirm".to_string(), @@ -1945,6 +1961,7 @@ mod tests { message: "Allow this request?".to_string(), approval_display_params: Vec::new(), response_mode: McpServerElicitationResponseMode::ApprovalAction, + is_tool_approval: false, fields: vec![McpServerElicitationField { id: APPROVAL_FIELD_ID.to_string(), label: String::new(), @@ -2002,6 +2019,7 @@ mod tests { message: "Allow this request?".to_string(), approval_display_params: Vec::new(), response_mode: McpServerElicitationResponseMode::ApprovalAction, + is_tool_approval: true, fields: vec![McpServerElicitationField { id: APPROVAL_FIELD_ID.to_string(), label: String::new(),