This commit is contained in:
Matthew Zeng
2026-03-05 11:20:38 -08:00
parent 926b2f19e8
commit db979dc5de
5 changed files with 383 additions and 0 deletions

View File

@@ -443,6 +443,9 @@
"steer": {
"type": "boolean"
},
"tool_call_mcp_elicitation": {
"type": "boolean"
},
"undo": {
"type": "boolean"
},
@@ -1834,6 +1837,9 @@
"steer": {
"type": "boolean"
},
"tool_call_mcp_elicitation": {
"type": "boolean"
},
"undo": {
"type": "boolean"
},

View File

@@ -55,6 +55,8 @@ use async_channel::Receiver;
use async_channel::Sender;
use chrono::Local;
use chrono::Utc;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_hooks::HookEvent;
use codex_hooks::HookEventAfterAgent;
use codex_hooks::HookPayload;
@@ -67,6 +69,7 @@ use codex_network_proxy::normalize_host;
use codex_otel::current_span_w3c_trace_context;
use codex_otel::set_parent_from_w3c_trace_context;
use codex_protocol::ThreadId;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyRuleAction;
@@ -2807,6 +2810,69 @@ impl Session {
rx_response.await.ok()
}
pub async fn request_mcp_server_elicitation(
&self,
turn_context: &TurnContext,
request_id: RequestId,
params: McpServerElicitationRequestParams,
) -> Option<ElicitationResponse> {
let (tx_response, rx_response) = oneshot::channel();
let server_name = params.server_name.clone();
let prev_entry = {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.insert_pending_elicitation(
server_name.clone(),
request_id.clone(),
tx_response,
)
}
None => None,
}
};
if prev_entry.is_some() {
warn!(
"Overwriting existing pending elicitation for server_name: {server_name}, request_id: {request_id}"
);
}
let request = match params.request {
McpServerElicitationRequest::Form {
message,
requested_schema,
} => codex_protocol::approvals::ElicitationRequest::Form {
message,
requested_schema,
},
McpServerElicitationRequest::Url {
message,
url,
elicitation_id,
} => codex_protocol::approvals::ElicitationRequest::Url {
message,
url,
elicitation_id,
},
};
let id = match request_id {
rmcp::model::NumberOrString::String(value) => {
codex_protocol::mcp::RequestId::String(value.to_string())
}
rmcp::model::NumberOrString::Number(value) => {
codex_protocol::mcp::RequestId::Integer(value)
}
};
let event = EventMsg::ElicitationRequest(ElicitationRequestEvent {
server_name,
id,
request,
});
self.send_event(turn_context, event).await;
rx_response.await.ok()
}
pub async fn notify_user_input_response(
&self,
sub_id: &str,
@@ -2880,6 +2946,23 @@ impl Session {
id: RequestId,
response: ElicitationResponse,
) -> anyhow::Result<()> {
let entry = {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.remove_pending_elicitation(&server_name, &id)
}
None => None,
}
};
if let Some(tx_response) = entry {
tx_response
.send(response)
.map_err(|e| anyhow::anyhow!("failed to send elicitation response: {e:?}"))?;
return Ok(());
}
self.services
.mcp_connection_manager
.read()

View File

@@ -147,6 +147,8 @@ pub enum Feature {
/// Enable collaboration modes (Plan, Default).
/// Kept for config backward compatibility; behavior is always collaboration-modes-enabled.
CollaborationModes,
/// Route MCP tool approval prompts through the MCP elicitation request path.
ToolCallMcpElicitation,
/// Enable personality selection in the TUI.
Personality,
/// Enable native artifact tools.
@@ -693,6 +695,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Removed,
default_enabled: true,
},
FeatureSpec {
id: Feature::ToolCallMcpElicitation,
key: "tool_call_mcp_elicitation",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::Personality,
key: "personality",

View File

@@ -1,6 +1,8 @@
use std::time::Duration;
use std::time::Instant;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use tracing::error;
use crate::analytics_client::AppInvocation;
@@ -10,6 +12,7 @@ use crate::codex::Session;
use crate::codex::TurnContext;
use crate::config::types::AppToolApproval;
use crate::connectors;
use crate::features::Feature;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::protocol::EventMsg;
use crate::protocol::McpInvocation;
@@ -24,12 +27,16 @@ use codex_protocol::openai_models::InputModality;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
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 rmcp::model::ToolAnnotations;
use serde::Serialize;
use serde_json::json;
use std::sync::Arc;
/// Handles the specified tool call dispatches the appropriate
@@ -402,6 +409,34 @@ async fn maybe_request_mcp_tool_approval(
annotations,
approval_key.is_some(),
);
if turn_context
.config
.features
.enabled(Feature::ToolCallMcpElicitation)
{
let request_id = rmcp::model::RequestId::String(
format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}").into(),
);
let params = build_mcp_tool_approval_elicitation_request(
sess,
turn_context,
server,
question.clone(),
);
let decision = parse_mcp_tool_approval_elicitation_response(
sess.request_mcp_server_elicitation(turn_context, request_id, params)
.await,
&question_id,
);
let decision = normalize_approval_decision_for_mode(decision, approval_mode);
if matches!(decision, McpToolApprovalDecision::AcceptAndRemember)
&& let Some(key) = approval_key
{
remember_mcp_tool_approval(sess, key).await;
}
return Some(decision);
}
let args = RequestUserInputArgs {
questions: vec![question],
};
@@ -544,6 +579,126 @@ fn build_mcp_tool_approval_question(
}
}
fn build_mcp_tool_approval_elicitation_request(
sess: &Session,
turn_context: &TurnContext,
server: &str,
question: RequestUserInputQuestion,
) -> McpServerElicitationRequestParams {
let requested_schema =
request_user_input_questions_to_elicitation_schema(std::slice::from_ref(&question));
let message = if question.header.trim().is_empty() {
question.question
} else {
format!(
"{header}\n\n{prompt}",
header = question.header,
prompt = question.question
)
};
McpServerElicitationRequestParams {
thread_id: sess.conversation_id.to_string(),
turn_id: Some(turn_context.sub_id.clone()),
server_name: server.to_string(),
request: McpServerElicitationRequest::Form {
message,
requested_schema,
},
}
}
fn request_user_input_questions_to_elicitation_schema(
questions: &[RequestUserInputQuestion],
) -> serde_json::Value {
let questions_metadata = match serde_json::to_value(questions) {
Ok(value) => value,
Err(_) => serde_json::Value::Null,
};
let properties = questions
.iter()
.map(|question| {
let mut property = json!({
"title": question.header,
"description": question.question,
});
if let Some(options) = question.options.as_ref() {
property["type"] = json!("string");
property["enum"] = json!(
options
.iter()
.map(|option| option.label.clone())
.collect::<Vec<_>>()
);
property["x-codex-options"] = json!(options);
} else {
property["type"] = json!("string");
}
if question.is_secret {
property["writeOnly"] = json!(true);
}
(question.id.clone(), property)
})
.collect::<serde_json::Map<_, _>>();
json!({
"type": "object",
"properties": properties,
"required": questions
.iter()
.map(|question| question.id.clone())
.collect::<Vec<_>>(),
"additionalProperties": false,
"x-codex-request-user-input": {
"questions": questions_metadata,
},
})
}
fn parse_mcp_tool_approval_elicitation_response(
response: Option<ElicitationResponse>,
question_id: &str,
) -> McpToolApprovalDecision {
let Some(response) = response else {
return McpToolApprovalDecision::Cancel;
};
match response.action {
ElicitationAction::Accept => parse_mcp_tool_approval_response(
request_user_input_response_from_elicitation_content(response.content),
question_id,
),
ElicitationAction::Decline => McpToolApprovalDecision::Decline,
ElicitationAction::Cancel => McpToolApprovalDecision::Cancel,
}
}
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,
@@ -780,4 +935,112 @@ mod tests {
assert_eq!(got, original);
}
#[test]
fn elicitation_schema_reuses_request_user_input_question_shape() {
let schema =
request_user_input_questions_to_elicitation_schema(&[RequestUserInputQuestion {
id: "approval".to_string(),
header: "Approve app tool call?".to_string(),
question: "Allow this action?".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![
RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_ACCEPT.to_string(),
description: "Run the tool and continue.".to_string(),
},
RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_DECLINE.to_string(),
description: "Decline this tool call and continue.".to_string(),
},
]),
}]);
assert_eq!(
schema,
serde_json::json!({
"type": "object",
"properties": {
"approval": {
"title": "Approve app tool call?",
"description": "Allow this action?",
"type": "string",
"enum": [
MCP_TOOL_APPROVAL_ACCEPT,
MCP_TOOL_APPROVAL_DECLINE,
],
"x-codex-options": [
{
"label": MCP_TOOL_APPROVAL_ACCEPT,
"description": "Run the tool and continue.",
},
{
"label": MCP_TOOL_APPROVAL_DECLINE,
"description": "Decline this tool call and continue.",
},
],
},
},
"required": ["approval"],
"additionalProperties": false,
"x-codex-request-user-input": {
"questions": [{
"id": "approval",
"header": "Approve app tool call?",
"question": "Allow this action?",
"isOther": false,
"isSecret": false,
"options": [
{
"label": MCP_TOOL_APPROVAL_ACCEPT,
"description": "Run the tool and continue.",
},
{
"label": MCP_TOOL_APPROVAL_DECLINE,
"description": "Decline this tool call and continue.",
},
],
}],
},
})
);
}
#[test]
fn accepted_elicitation_content_converts_to_request_user_input_response() {
let response =
request_user_input_response_from_elicitation_content(Some(serde_json::json!(
{
"approval": MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER,
}
)));
assert_eq!(
response,
Some(RequestUserInputResponse {
answers: std::collections::HashMap::from([(
"approval".to_string(),
RequestUserInputAnswer {
answers: vec![MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string()],
},
)]),
})
);
}
#[test]
fn declined_elicitation_response_stays_decline() {
let response = parse_mcp_tool_approval_elicitation_response(
Some(ElicitationResponse {
action: ElicitationAction::Decline,
content: Some(serde_json::json!({
"approval": MCP_TOOL_APPROVAL_ACCEPT,
})),
}),
"approval",
);
assert_eq!(response, McpToolApprovalDecision::Decline);
}
}

View File

@@ -11,6 +11,8 @@ use tokio_util::task::AbortOnDropHandle;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_rmcp_client::ElicitationResponse;
use rmcp::model::RequestId;
use tokio::sync::oneshot;
use crate::codex::TurnContext;
@@ -72,6 +74,7 @@ impl ActiveTurn {
pub(crate) struct TurnState {
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
pending_user_input: HashMap<String, oneshot::Sender<RequestUserInputResponse>>,
pending_elicitations: HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>,
pending_dynamic_tools: HashMap<String, oneshot::Sender<DynamicToolResponse>>,
pending_input: Vec<ResponseInputItem>,
pub(crate) tool_calls: u64,
@@ -97,6 +100,7 @@ impl TurnState {
pub(crate) fn clear_pending(&mut self) {
self.pending_approvals.clear();
self.pending_user_input.clear();
self.pending_elicitations.clear();
self.pending_dynamic_tools.clear();
self.pending_input.clear();
}
@@ -116,6 +120,25 @@ impl TurnState {
self.pending_user_input.remove(key)
}
pub(crate) fn insert_pending_elicitation(
&mut self,
server_name: String,
request_id: RequestId,
tx: oneshot::Sender<ElicitationResponse>,
) -> Option<oneshot::Sender<ElicitationResponse>> {
self.pending_elicitations
.insert((server_name, request_id), tx)
}
pub(crate) fn remove_pending_elicitation(
&mut self,
server_name: &str,
request_id: &RequestId,
) -> Option<oneshot::Sender<ElicitationResponse>> {
self.pending_elicitations
.remove(&(server_name.to_string(), request_id.clone()))
}
pub(crate) fn insert_pending_dynamic_tool(
&mut self,
key: String,