From 219837b03082b4fb6f26280de3481d7dfbea7c5b Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Sat, 21 Feb 2026 12:46:12 -0800 Subject: [PATCH] Client-side URL elicitation flow and schema updates --- .../schema/json/EventMsg.json | 14 +- .../codex_app_server_protocol.schemas.json | 8 +- .../typescript/ElicitationRequestEvent.ts | 2 +- codex-rs/core/src/codex.rs | 64 +++++++-- codex-rs/core/src/mcp/elicitation_url.rs | 135 ++++++++++++++++++ codex-rs/core/src/mcp/mod.rs | 1 + codex-rs/core/src/mcp_connection_manager.rs | 40 +++++- codex-rs/protocol/src/approvals.rs | 2 + codex-rs/tui/src/chatwidget/tests.rs | 2 + 9 files changed, 251 insertions(+), 17 deletions(-) create mode 100644 codex-rs/core/src/mcp/elicitation_url.rs diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index f75d893be3..d8946bf976 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -1779,6 +1779,12 @@ "string", "null" ] + }, + "elicitation_id": { + "type": [ + "string", + "null" + ] } }, "required": [ @@ -7031,6 +7037,12 @@ "string", "null" ] + }, + "elicitation_id": { + "type": [ + "string", + "null" + ] } }, "required": [ @@ -8433,4 +8445,4 @@ } ], "title": "EventMsg" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index d2737af921..665c14e42f 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -2840,6 +2840,12 @@ "string", "null" ] + }, + "elicitation_id": { + "type": [ + "string", + "null" + ] } }, "required": [ @@ -13939,4 +13945,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/typescript/ElicitationRequestEvent.ts b/codex-rs/app-server-protocol/schema/typescript/ElicitationRequestEvent.ts index 6fcb5d8057..5c7eaf7d75 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ElicitationRequestEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ElicitationRequestEvent.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { JsonValue } from "./serde_json/JsonValue"; -export type ElicitationRequestEvent = { server_name: string, id: string | number, message: string, requested_schema: JsonValue | null, url: string | null, }; +export type ElicitationRequestEvent = { server_name: string, id: string | number, message: string, requested_schema: JsonValue | null, url: string | null, elicitation_id: string | null, }; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d21e5b9913..385e66e0c3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -155,6 +155,9 @@ use crate::instructions::UserInstructions; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::effective_mcp_servers; +use crate::mcp::elicitation_url::build_url_elicitation_outcome_from_user_input; +use crate::mcp::elicitation_url::build_url_elicitation_request_user_input_args; +use crate::mcp::elicitation_url::is_url_elicitation_request; use crate::mcp::is_apps_mcp_gateway_elicitation_flow_active; use crate::mcp::maybe_prompt_and_install_mcp_dependencies; use crate::mcp::with_codex_apps_mcp; @@ -2330,10 +2333,18 @@ impl Session { &self, elicitation: codex_protocol::approvals::ElicitationRequestEvent, ) { - let response = if let Some((turn_context, cancellation_token)) = + let (response, completion_elicitation_id) = if let Some(( + turn_context, + cancellation_token, + )) = self.active_turn_context_and_cancellation_token().await { - let args = build_mcp_elicitation_request_user_input_args(&elicitation); + let is_url_elicitation = is_url_elicitation_request(&elicitation); + let args = if is_url_elicitation { + build_url_elicitation_request_user_input_args(&elicitation) + } else { + build_mcp_elicitation_request_user_input_args(&elicitation) + }; let call_id = format!("mcp_elicitation_request_{}", turn_context.sub_id); let turn_sub_id = turn_context.sub_id.clone(); let user_input = tokio::select! { @@ -2347,23 +2358,47 @@ impl Session { } response = self.request_user_input(turn_context.as_ref(), call_id, args) => response, }; - build_mcp_elicitation_response_from_user_input(user_input, &elicitation) - } else { - ElicitationResponse { - action: ElicitationAction::Cancel, - content: None, + if is_url_elicitation { + let outcome = + build_url_elicitation_outcome_from_user_input(user_input, &elicitation); + (outcome.response, outcome.completion_elicitation_id) + } else { + ( + build_mcp_elicitation_response_from_user_input(user_input, &elicitation), + None, + ) } + } else { + ( + ElicitationResponse { + action: ElicitationAction::Cancel, + content: None, + }, + None, + ) }; + let server_name = elicitation.server_name.clone(); let request_id = protocol_request_id_to_rmcp(&elicitation.id); if let Err(err) = self - .resolve_elicitation(elicitation.server_name, request_id, response) + .resolve_elicitation(server_name.clone(), request_id, response) .await { warn!( error = %err, "failed to resolve codex_apps MCP elicitation request" ); + return; + } + if let Some(elicitation_id) = completion_elicitation_id + && let Err(err) = self + .complete_url_elicitation(server_name, elicitation_id) + .await + { + warn!( + error = %err, + "failed to send codex_apps url elicitation completion notification" + ); } } @@ -2667,6 +2702,19 @@ impl Session { .await } + pub async fn complete_url_elicitation( + &self, + server_name: String, + elicitation_id: String, + ) -> anyhow::Result<()> { + self.services + .mcp_connection_manager + .read() + .await + .complete_url_elicitation(server_name, elicitation_id) + .await + } + /// Records input items: always append to conversation history and /// persist these response items to rollout. pub(crate) async fn record_conversation_items( diff --git a/codex-rs/core/src/mcp/elicitation_url.rs b/codex-rs/core/src/mcp/elicitation_url.rs new file mode 100644 index 0000000000..67260d0ca6 --- /dev/null +++ b/codex-rs/core/src/mcp/elicitation_url.rs @@ -0,0 +1,135 @@ +use codex_protocol::approvals::ElicitationRequestEvent; +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; + +const REQUEST_USER_INPUT_NOTE_PREFIX: &str = "user_note: "; +const MCP_URL_ELICITATION_DECISION_QUESTION_ID: &str = "mcp_url_elicitation_decision"; +const MCP_URL_ELICITATION_COMPLETED: &str = "Completed"; +const MCP_URL_ELICITATION_DECLINE: &str = "Decline"; +const MCP_URL_ELICITATION_CANCEL: &str = "Cancel"; + +pub(crate) fn is_url_elicitation_request(elicitation: &ElicitationRequestEvent) -> bool { + elicitation.url.is_some() +} + +pub(crate) struct UrlElicitationPromptOutcome { + pub(crate) response: ElicitationResponse, + pub(crate) completion_elicitation_id: Option, +} + +pub(crate) fn build_url_elicitation_request_user_input_args( + elicitation: &ElicitationRequestEvent, +) -> RequestUserInputArgs { + let mut question_lines = vec![elicitation.message.clone()]; + if let Some(url) = &elicitation.url { + question_lines.push(String::new()); + question_lines.push("Open this URL in your browser to continue:".to_string()); + question_lines.push(url.clone()); + question_lines.push(String::new()); + question_lines.push( + "After completing the flow in your browser, return here and choose Completed." + .to_string(), + ); + } + if elicitation.elicitation_id.is_none() { + question_lines.push(String::new()); + question_lines.push( + "This request is missing required URL elicitation metadata, so it cannot be completed safely." + .to_string(), + ); + } + + RequestUserInputArgs { + questions: vec![RequestUserInputQuestion { + id: MCP_URL_ELICITATION_DECISION_QUESTION_ID.to_string(), + header: "MCP URL elicitation".to_string(), + question: question_lines.join("\n"), + is_other: false, + is_secret: false, + options: Some(vec![ + RequestUserInputQuestionOption { + label: MCP_URL_ELICITATION_COMPLETED.to_string(), + description: "You completed the browser flow and want to continue.".to_string(), + }, + RequestUserInputQuestionOption { + label: MCP_URL_ELICITATION_DECLINE.to_string(), + description: "Decline this URL elicitation request.".to_string(), + }, + RequestUserInputQuestionOption { + label: MCP_URL_ELICITATION_CANCEL.to_string(), + description: "Cancel this URL elicitation request.".to_string(), + }, + ]), + }], + } +} + +pub(crate) fn build_url_elicitation_outcome_from_user_input( + response: Option, + elicitation: &ElicitationRequestEvent, +) -> UrlElicitationPromptOutcome { + let Some(response) = response else { + return UrlElicitationPromptOutcome { + response: ElicitationResponse { + action: ElicitationAction::Cancel, + content: None, + }, + completion_elicitation_id: None, + }; + }; + + let action = response + .answers + .get(MCP_URL_ELICITATION_DECISION_QUESTION_ID) + .and_then(request_user_input_answer_to_url_elicitation_action) + .unwrap_or(ElicitationAction::Cancel); + + match action { + ElicitationAction::Accept => { + let Some(elicitation_id) = elicitation.elicitation_id.clone() else { + return UrlElicitationPromptOutcome { + response: ElicitationResponse { + action: ElicitationAction::Cancel, + content: None, + }, + completion_elicitation_id: None, + }; + }; + UrlElicitationPromptOutcome { + response: ElicitationResponse { + action: ElicitationAction::Accept, + content: None, + }, + completion_elicitation_id: Some(elicitation_id), + } + } + ElicitationAction::Decline | ElicitationAction::Cancel => UrlElicitationPromptOutcome { + response: ElicitationResponse { + action, + content: None, + }, + completion_elicitation_id: None, + }, + } +} + +fn request_user_input_answer_to_url_elicitation_action( + answer: &RequestUserInputAnswer, +) -> Option { + answer.answers.iter().find_map(|entry| { + if entry.starts_with(REQUEST_USER_INPUT_NOTE_PREFIX) { + return None; + } + match entry.as_str() { + MCP_URL_ELICITATION_COMPLETED => Some(ElicitationAction::Accept), + MCP_URL_ELICITATION_DECLINE => Some(ElicitationAction::Decline), + MCP_URL_ELICITATION_CANCEL => Some(ElicitationAction::Cancel), + _ => None, + } + }) +} diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index bf347c48b6..d97f23827b 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub(crate) mod elicitation_form; +pub(crate) mod elicitation_url; mod skill_dependencies; pub(crate) use skill_dependencies::maybe_prompt_and_install_mcp_dependencies; diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 06b4ce15b5..b4fc879afc 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -309,7 +309,7 @@ impl ElicitationRequestManager { } rmcp::model::NumberOrString::Number(value) => ProtocolRequestId::Integer(value), }; - let (message, requested_schema, url) = match elicitation { + let (message, requested_schema, url, elicitation_id) = match elicitation { CreateElicitationRequestParams::FormElicitationParams { message, requested_schema, @@ -322,10 +322,19 @@ impl ElicitationRequestManager { None }, None, + None, ), CreateElicitationRequestParams::UrlElicitationParams { - message, url, .. - } => (message, None, mcp_elicitations_enabled.then_some(url)), + message, + url, + elicitation_id, + .. + } => ( + message, + None, + mcp_elicitations_enabled.then_some(url), + mcp_elicitations_enabled.then_some(elicitation_id), + ), }; let _ = tx_event .send(Event { @@ -336,6 +345,7 @@ impl ElicitationRequestManager { message, requested_schema, url, + elicitation_id, }), }) .await; @@ -705,6 +715,24 @@ impl McpConnectionManager { .await } + pub async fn complete_url_elicitation( + &self, + server_name: String, + elicitation_id: String, + ) -> Result<()> { + let managed_client = self.client_by_name(&server_name).await?; + managed_client + .client + .send_custom_notification( + "notifications/elicitation/complete", + Some(serde_json::json!({ "elicitationId": elicitation_id })), + ) + .await + .with_context(|| { + format!("failed sending url elicitation completion for `{server_name}`") + }) + } + pub(crate) async fn wait_for_server_ready(&self, server_name: &str, timeout: Duration) -> bool { let Some(async_managed_client) = self.clients.get(server_name) else { return false; @@ -1237,7 +1265,7 @@ fn elicitation_capability_for_server(server_name: &str) -> Option, #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub elicitation_id: Option, } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index dd31a2df4e..ac300e3fe0 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3277,6 +3277,7 @@ async fn mcp_tool_call_begin_elicitation_end_deferred_during_active_stream() { "properties": {}, })), url: Some("https://example.com/elicitation".to_string()), + elicitation_id: Some("elicitation-url-1".to_string()), }), }); chat.handle_codex_event(Event { @@ -3349,6 +3350,7 @@ async fn mcp_tool_call_elicitation_can_be_resolved_after_stream_queue_flush() { "properties": {}, })), url: None, + elicitation_id: None, }), }); chat.handle_codex_event(Event {