Client-side URL elicitation flow and schema updates

This commit is contained in:
canvrno-oai
2026-02-23 14:39:03 -08:00
parent 17c5230dd3
commit 219837b030
9 changed files with 251 additions and 17 deletions
@@ -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"
}
}
@@ -2840,6 +2840,12 @@
"string",
"null"
]
},
"elicitation_id": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -13939,4 +13945,4 @@
},
"title": "CodexAppServerProtocol",
"type": "object"
}
}
@@ -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, };
+56 -8
View File
@@ -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(
+135
View File
@@ -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<String>,
}
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<RequestUserInputResponse>,
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<ElicitationAction> {
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,
}
})
}
+1
View File
@@ -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;
+34 -6
View File
@@ -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<ElicitationCap
form: Some(FormElicitationCapability {
schema_validation: None,
}),
url: None,
url: Some(UrlElicitationCapability {}),
})
} else {
None
@@ -2117,7 +2145,7 @@ mod tests {
.boxed()
.shared();
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
let mut manager = McpConnectionManager::new_uninitialized(&approval_policy, false );
let mut manager = McpConnectionManager::new_uninitialized(&approval_policy, false);
let startup_complete = Arc::new(std::sync::atomic::AtomicBool::new(true));
manager.clients.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
@@ -2145,7 +2173,7 @@ mod tests {
form: Some(FormElicitationCapability {
schema_validation: None
}),
url: None,
url: Some(UrlElicitationCapability {}),
})
));
+2
View File
@@ -107,6 +107,8 @@ pub struct ElicitationRequestEvent {
pub requested_schema: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub elicitation_id: Option<String>,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
+2
View File
@@ -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 {