[elicitations] Switch to use MCP style elicitation payload for mcp tool approvals. (#13621)

- [x] Switch to use MCP style elicitation payload for mcp tool
approvals.
- [ ] TODO: Update the UI to support the full spec.
This commit is contained in:
Matthew Zeng
2026-03-06 01:50:26 -08:00
committed by GitHub
parent ee1a20258a
commit 98dca99db7
59 changed files with 5165 additions and 100 deletions

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;
@@ -68,6 +70,7 @@ use codex_otel::current_span_trace_id;
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;
@@ -2815,6 +2818,85 @@ 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 server_name = params.server_name.clone();
let request = match params.request {
McpServerElicitationRequest::Form {
meta,
message,
requested_schema,
} => {
let requested_schema = match serde_json::to_value(requested_schema) {
Ok(requested_schema) => requested_schema,
Err(err) => {
warn!(
"failed to serialize MCP elicitation schema for server_name: {server_name}, request_id: {request_id}: {err:#}"
);
return None;
}
};
codex_protocol::approvals::ElicitationRequest::Form {
meta,
message,
requested_schema,
}
}
McpServerElicitationRequest::Url {
meta,
message,
url,
elicitation_id,
} => codex_protocol::approvals::ElicitationRequest::Url {
meta,
message,
url,
elicitation_id,
},
};
let (tx_response, rx_response) = oneshot::channel();
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 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 {
turn_id: params.turn_id,
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,
@@ -2888,6 +2970,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()
@@ -3883,6 +3982,7 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
request_id,
decision,
content,
meta,
} => {
handlers::resolve_elicitation(
&sess,
@@ -3890,6 +3990,7 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
request_id,
decision,
content,
meta,
)
.await;
false
@@ -4117,6 +4218,7 @@ mod handlers {
request_id: ProtocolRequestId,
decision: codex_protocol::approvals::ElicitationAction,
content: Option<Value>,
meta: Option<Value>,
) {
let action = match decision {
codex_protocol::approvals::ElicitationAction::Accept => ElicitationAction::Accept,
@@ -4128,7 +4230,11 @@ mod handlers {
ElicitationAction::Accept => Some(content.unwrap_or_else(|| serde_json::json!({}))),
ElicitationAction::Decline | ElicitationAction::Cancel => None,
};
let response = ElicitationResponse { action, content };
let response = ElicitationResponse {
action,
content,
meta,
};
let request_id = match request_id {
ProtocolRequestId::String(value) => {
rmcp::model::NumberOrString::String(std::sync::Arc::from(value))

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

@@ -295,25 +295,34 @@ impl ElicitationRequestManager {
return Ok(ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
meta: None,
});
}
let request = match elicitation {
CreateElicitationRequestParams::FormElicitationParams {
meta,
message,
requested_schema,
..
} => ElicitationRequest::Form {
meta: meta
.map(serde_json::to_value)
.transpose()
.context("failed to serialize MCP elicitation metadata")?,
message,
requested_schema: serde_json::to_value(requested_schema)
.context("failed to serialize MCP elicitation schema")?,
},
CreateElicitationRequestParams::UrlElicitationParams {
meta,
message,
url,
elicitation_id,
..
} => ElicitationRequest::Url {
meta: meta
.map(serde_json::to_value)
.transpose()
.context("failed to serialize MCP elicitation metadata")?,
message,
url,
elicitation_id,
@@ -328,6 +337,7 @@ impl ElicitationRequestManager {
.send(Event {
id: "mcp_elicitation_request".to_string(),
msg: EventMsg::ElicitationRequest(ElicitationRequestEvent {
turn_id: None,
server_name,
id: match id.clone() {
rmcp::model::NumberOrString::String(value) => {

View File

@@ -1,6 +1,11 @@
use std::collections::BTreeMap;
use std::time::Duration;
use std::time::Instant;
use codex_app_server_protocol::McpElicitationObjectType;
use codex_app_server_protocol::McpElicitationSchema;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use tracing::error;
use crate::analytics_client::AppInvocation;
@@ -10,6 +15,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,10 +30,13 @@ 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 std::sync::Arc;
@@ -68,7 +77,7 @@ pub(crate) async fn handle_mcp_tool_call(
arguments: arguments_value.clone(),
};
let metadata = lookup_mcp_tool_metadata(sess.as_ref(), &server, &tool_name).await;
let metadata = lookup_mcp_tool_metadata(sess.as_ref(), turn_context, &server, &tool_name).await;
let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME {
connectors::app_tool_policy(
&turn_context.config,
@@ -107,8 +116,7 @@ pub(crate) async fn handle_mcp_tool_call(
sess.as_ref(),
turn_context,
&call_id,
&server,
&tool_name,
&invocation,
metadata.as_ref(),
app_tool_policy.approval,
)
@@ -334,7 +342,9 @@ struct McpToolApprovalMetadata {
annotations: Option<ToolAnnotations>,
connector_id: Option<String>,
connector_name: Option<String>,
connector_description: Option<String>,
tool_title: Option<String>,
tool_description: Option<String>,
}
const MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX: &str = "mcp_tool_call_approval";
@@ -342,6 +352,18 @@ const MCP_TOOL_APPROVAL_ACCEPT: &str = "Approve Once";
const MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER: &str = "Approve this Session";
const MCP_TOOL_APPROVAL_DECLINE: &str = "Deny";
const MCP_TOOL_APPROVAL_CANCEL: &str = "Cancel";
const MCP_TOOL_APPROVAL_KIND_KEY: &str = "codex_approval_kind";
const MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL: &str = "mcp_tool_call";
const MCP_TOOL_APPROVAL_PERSIST_KEY: &str = "persist";
const MCP_TOOL_APPROVAL_PERSIST_SESSION: &str = "session";
const MCP_TOOL_APPROVAL_SOURCE_KEY: &str = "source";
const MCP_TOOL_APPROVAL_SOURCE_CONNECTOR: &str = "connector";
const MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY: &str = "connector_id";
const MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY: &str = "connector_name";
const MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY: &str = "connector_description";
const MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: &str = "tool_title";
const MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: &str = "tool_description";
const MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: &str = "tool_params";
#[derive(Debug, Serialize)]
struct McpToolApprovalKey {
@@ -354,8 +376,7 @@ async fn maybe_request_mcp_tool_approval(
sess: &Session,
turn_context: &TurnContext,
call_id: &str,
server: &str,
tool_name: &str,
invocation: &McpInvocation,
metadata: Option<&McpToolApprovalMetadata>,
approval_mode: AppToolApproval,
) -> Option<McpToolApprovalDecision> {
@@ -374,13 +395,13 @@ async fn maybe_request_mcp_tool_approval(
let approval_key = if approval_mode == AppToolApproval::Auto {
let connector_id = metadata.and_then(|metadata| metadata.connector_id.clone());
if server == CODEX_APPS_MCP_SERVER_NAME && connector_id.is_none() {
if invocation.server == CODEX_APPS_MCP_SERVER_NAME && connector_id.is_none() {
None
} else {
Some(McpToolApprovalKey {
server: server.to_string(),
server: invocation.server.clone(),
connector_id,
tool_name: tool_name.to_string(),
tool_name: invocation.tool.clone(),
})
}
} else {
@@ -395,13 +416,44 @@ async fn maybe_request_mcp_tool_approval(
let question_id = format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}");
let question = build_mcp_tool_approval_question(
question_id.clone(),
server,
tool_name,
&invocation.server,
&invocation.tool,
metadata.and_then(|metadata| metadata.tool_title.as_deref()),
metadata.and_then(|metadata| metadata.connector_name.as_deref()),
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,
&invocation.server,
metadata,
invocation.arguments.as_ref(),
question.clone(),
approval_key.is_some(),
);
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],
};
@@ -430,6 +482,7 @@ fn is_full_access_mode(turn_context: &TurnContext) -> bool {
async fn lookup_mcp_tool_metadata(
sess: &Session,
turn_context: &TurnContext,
server: &str,
tool_name: &str,
) -> Option<McpToolApprovalMetadata> {
@@ -441,17 +494,40 @@ async fn lookup_mcp_tool_metadata(
.list_all_tools()
.await;
tools.into_values().find_map(|tool_info| {
if tool_info.server_name == server && tool_info.tool_name == tool_name {
Some(McpToolApprovalMetadata {
annotations: tool_info.tool.annotations,
connector_id: tool_info.connector_id,
connector_name: tool_info.connector_name,
tool_title: tool_info.tool.title,
})
} else {
None
}
let tool_info = tools
.into_values()
.find(|tool_info| tool_info.server_name == server && tool_info.tool_name == tool_name)?;
let connector_description = if server == CODEX_APPS_MCP_SERVER_NAME {
let connectors = match connectors::list_cached_accessible_connectors_from_mcp_tools(
turn_context.config.as_ref(),
)
.await
{
Some(connectors) => Some(connectors),
None => {
connectors::list_accessible_connectors_from_mcp_tools(turn_context.config.as_ref())
.await
.ok()
}
};
connectors.and_then(|connectors| {
let connector_id = tool_info.connector_id.as_deref()?;
connectors
.into_iter()
.find(|connector| connector.id == connector_id)
.and_then(|connector| connector.description)
})
} else {
None
};
Some(McpToolApprovalMetadata {
annotations: tool_info.tool.annotations,
connector_id: tool_info.connector_id,
connector_name: tool_info.connector_name,
connector_description,
tool_title: tool_info.tool.title,
tool_description: tool_info.tool.description.map(std::borrow::Cow::into_owned),
})
}
@@ -544,6 +620,173 @@ fn build_mcp_tool_approval_question(
}
}
fn build_mcp_tool_approval_elicitation_request(
sess: &Session,
turn_context: &TurnContext,
server: &str,
metadata: Option<&McpToolApprovalMetadata>,
tool_params: Option<&serde_json::Value>,
question: RequestUserInputQuestion,
allow_session_persist: bool,
) -> McpServerElicitationRequestParams {
let message = if question.header.trim().is_empty() {
question.question
} else {
let header = question.header;
let prompt = question.question;
format!("{header}\n\n{prompt}")
};
McpServerElicitationRequestParams {
thread_id: sess.conversation_id.to_string(),
turn_id: Some(turn_context.sub_id.clone()),
server_name: server.to_string(),
request: McpServerElicitationRequest::Form {
meta: build_mcp_tool_approval_elicitation_meta(
server,
metadata,
tool_params,
allow_session_persist,
),
message,
requested_schema: McpElicitationSchema {
schema_uri: None,
type_: McpElicitationObjectType::Object,
properties: BTreeMap::new(),
required: None,
},
},
}
}
fn build_mcp_tool_approval_elicitation_meta(
server: &str,
metadata: Option<&McpToolApprovalMetadata>,
tool_params: Option<&serde_json::Value>,
allow_session_persist: bool,
) -> Option<serde_json::Value> {
let mut meta = serde_json::Map::new();
meta.insert(
MCP_TOOL_APPROVAL_KIND_KEY.to_string(),
serde_json::Value::String(MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL.to_string()),
);
if allow_session_persist {
meta.insert(
MCP_TOOL_APPROVAL_PERSIST_KEY.to_string(),
serde_json::Value::String(MCP_TOOL_APPROVAL_PERSIST_SESSION.to_string()),
);
}
if let Some(metadata) = metadata {
if let Some(tool_title) = metadata.tool_title.as_ref() {
meta.insert(
MCP_TOOL_APPROVAL_TOOL_TITLE_KEY.to_string(),
serde_json::Value::String(tool_title.clone()),
);
}
if let Some(tool_description) = metadata.tool_description.as_ref() {
meta.insert(
MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY.to_string(),
serde_json::Value::String(tool_description.clone()),
);
}
if server == CODEX_APPS_MCP_SERVER_NAME
&& (metadata.connector_id.is_some()
|| metadata.connector_name.is_some()
|| metadata.connector_description.is_some())
{
meta.insert(
MCP_TOOL_APPROVAL_SOURCE_KEY.to_string(),
serde_json::Value::String(MCP_TOOL_APPROVAL_SOURCE_CONNECTOR.to_string()),
);
if let Some(connector_id) = metadata.connector_id.as_deref() {
meta.insert(
MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY.to_string(),
serde_json::Value::String(connector_id.to_string()),
);
}
if let Some(connector_name) = metadata.connector_name.as_ref() {
meta.insert(
MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY.to_string(),
serde_json::Value::String(connector_name.clone()),
);
}
if let Some(connector_description) = metadata.connector_description.as_ref() {
meta.insert(
MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY.to_string(),
serde_json::Value::String(connector_description.clone()),
);
}
}
}
if let Some(tool_params) = tool_params {
meta.insert(
MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY.to_string(),
tool_params.clone(),
);
}
(!meta.is_empty()).then_some(serde_json::Value::Object(meta))
}
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 => {
if response
.meta
.as_ref()
.and_then(serde_json::Value::as_object)
.and_then(|meta| meta.get(MCP_TOOL_APPROVAL_PERSIST_KEY))
.and_then(serde_json::Value::as_str)
== Some(MCP_TOOL_APPROVAL_PERSIST_SESSION)
{
return McpToolApprovalDecision::AcceptAndRemember;
}
match parse_mcp_tool_approval_response(
request_user_input_response_from_elicitation_content(response.content),
question_id,
) {
McpToolApprovalDecision::Cancel => McpToolApprovalDecision::Accept,
decision => decision,
}
}
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,
@@ -651,6 +894,23 @@ mod tests {
}
}
fn approval_metadata(
connector_id: Option<&str>,
connector_name: Option<&str>,
connector_description: Option<&str>,
tool_title: Option<&str>,
tool_description: Option<&str>,
) -> McpToolApprovalMetadata {
McpToolApprovalMetadata {
annotations: None,
connector_id: connector_id.map(str::to_string),
connector_name: connector_name.map(str::to_string),
connector_description: connector_description.map(str::to_string),
tool_title: tool_title.map(str::to_string),
tool_description: tool_description.map(str::to_string),
}
}
#[test]
fn approval_required_when_read_only_false_and_destructive() {
let annotations = annotations(Some(false), Some(true), None);
@@ -780,4 +1040,174 @@ mod tests {
assert_eq!(got, original);
}
#[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 approval_elicitation_meta_marks_tool_approvals() {
assert_eq!(
build_mcp_tool_approval_elicitation_meta("custom_server", None, None, false),
Some(serde_json::json!({
MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL,
}))
);
}
#[test]
fn approval_elicitation_meta_keeps_session_persist_behavior() {
assert_eq!(
build_mcp_tool_approval_elicitation_meta(
"custom_server",
Some(&approval_metadata(
None,
None,
None,
Some("Run Action"),
Some("Runs the selected action."),
)),
Some(&serde_json::json!({"id": 1})),
true,
),
Some(serde_json::json!({
MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL,
MCP_TOOL_APPROVAL_PERSIST_KEY: MCP_TOOL_APPROVAL_PERSIST_SESSION,
MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Run Action",
MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Runs the selected action.",
MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: {
"id": 1,
},
}))
);
}
#[test]
fn approval_elicitation_meta_includes_connector_source_for_codex_apps() {
assert_eq!(
build_mcp_tool_approval_elicitation_meta(
CODEX_APPS_MCP_SERVER_NAME,
Some(&approval_metadata(
Some("calendar"),
Some("Calendar"),
Some("Manage events and schedules."),
Some("Run Action"),
Some("Runs the selected action."),
)),
Some(&serde_json::json!({
"calendar_id": "primary",
})),
false,
),
Some(serde_json::json!({
MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL,
MCP_TOOL_APPROVAL_SOURCE_KEY: MCP_TOOL_APPROVAL_SOURCE_CONNECTOR,
MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY: "calendar",
MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY: "Calendar",
MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY: "Manage events and schedules.",
MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Run Action",
MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Runs the selected action.",
MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: {
"calendar_id": "primary",
},
}))
);
}
#[test]
fn approval_elicitation_meta_merges_session_persist_with_connector_source() {
assert_eq!(
build_mcp_tool_approval_elicitation_meta(
CODEX_APPS_MCP_SERVER_NAME,
Some(&approval_metadata(
Some("calendar"),
Some("Calendar"),
Some("Manage events and schedules."),
Some("Run Action"),
Some("Runs the selected action."),
)),
Some(&serde_json::json!({
"calendar_id": "primary",
})),
true,
),
Some(serde_json::json!({
MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL,
MCP_TOOL_APPROVAL_PERSIST_KEY: MCP_TOOL_APPROVAL_PERSIST_SESSION,
MCP_TOOL_APPROVAL_SOURCE_KEY: MCP_TOOL_APPROVAL_SOURCE_CONNECTOR,
MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY: "calendar",
MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY: "Calendar",
MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY: "Manage events and schedules.",
MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Run Action",
MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Runs the selected action.",
MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: {
"calendar_id": "primary",
},
}))
);
}
#[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,
})),
meta: None,
}),
"approval",
);
assert_eq!(response, McpToolApprovalDecision::Decline);
}
#[test]
fn accepted_elicitation_response_uses_session_persist_meta() {
let response = parse_mcp_tool_approval_elicitation_response(
Some(ElicitationResponse {
action: ElicitationAction::Accept,
content: None,
meta: Some(serde_json::json!({
MCP_TOOL_APPROVAL_PERSIST_KEY: MCP_TOOL_APPROVAL_PERSIST_SESSION,
})),
}),
"approval",
);
assert_eq!(response, McpToolApprovalDecision::AcceptAndRemember);
}
#[test]
fn accepted_elicitation_without_content_defaults_to_accept() {
let response = parse_mcp_tool_approval_elicitation_response(
Some(ElicitationResponse {
action: ElicitationAction::Accept,
content: None,
meta: None,
}),
"approval",
);
assert_eq!(response, McpToolApprovalDecision::Accept);
}
}

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,