mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
update
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user