[elicitations] Support always allow option for mcp tool calls. (#13807)

- [x] Support always allow option for mcp tool calls, writes to
config.toml.
- [x] Fix config hot-reload after starting a new thread for TUI.
This commit is contained in:
Matthew Zeng
2026-03-07 17:46:40 -08:00
committed by GitHub
parent 590cfa6176
commit a4a9536fd7
5 changed files with 824 additions and 154 deletions

View File

@@ -13,6 +13,8 @@ use crate::analytics_client::InvocationType;
use crate::analytics_client::build_track_events_context;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::config::types::AppToolApproval;
use crate::connectors;
use crate::features::Feature;
@@ -42,7 +44,9 @@ use codex_rmcp_client::ElicitationAction;
use codex_rmcp_client::ElicitationResponse;
use rmcp::model::ToolAnnotations;
use serde::Serialize;
use std::path::Path;
use std::sync::Arc;
use toml_edit::value;
/// Handles the specified tool call dispatches the appropriate
/// `McpToolCallBegin` and `McpToolCallEnd` events to the `Session`.
@@ -127,7 +131,9 @@ pub(crate) async fn handle_mcp_tool_call(
.await
{
let result = match decision {
McpToolApprovalDecision::Accept | McpToolApprovalDecision::AcceptAndRemember => {
McpToolApprovalDecision::Accept
| McpToolApprovalDecision::AcceptForSession
| McpToolApprovalDecision::AcceptAndRemember => {
let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
call_id: call_id.clone(),
invocation: invocation.clone(),
@@ -352,6 +358,7 @@ async fn maybe_track_codex_app_used(
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum McpToolApprovalDecision {
Accept,
AcceptForSession,
AcceptAndRemember,
Decline,
Cancel,
@@ -366,15 +373,22 @@ struct McpToolApprovalMetadata {
tool_description: Option<String>,
}
#[derive(Clone, Copy)]
struct McpToolApprovalPromptOptions {
allow_session_remember: bool,
allow_persistent_approval: bool,
}
const MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX: &str = "mcp_tool_call_approval";
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_ACCEPT_FOR_SESSION: &str = "Approve this session";
const MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER: &str = "Always allow";
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_PERSIST_ALWAYS: &str = "always";
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";
@@ -384,13 +398,25 @@ 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)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct McpToolApprovalKey {
server: String,
connector_id: Option<String>,
tool_name: String,
}
fn mcp_tool_approval_prompt_options(
session_approval_key: Option<&McpToolApprovalKey>,
persistent_approval_key: Option<&McpToolApprovalKey>,
tool_call_mcp_elicitation_enabled: bool,
) -> McpToolApprovalPromptOptions {
McpToolApprovalPromptOptions {
allow_session_remember: session_approval_key.is_some(),
allow_persistent_approval: tool_call_mcp_elicitation_enabled
&& persistent_approval_key.is_some(),
}
}
async fn maybe_request_mcp_tool_approval(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
@@ -412,25 +438,18 @@ 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 invocation.server == CODEX_APPS_MCP_SERVER_NAME && connector_id.is_none() {
None
} else {
Some(McpToolApprovalKey {
server: invocation.server.clone(),
connector_id,
tool_name: invocation.tool.clone(),
})
}
} else {
None
};
if let Some(key) = approval_key.as_ref()
let session_approval_key = session_mcp_tool_approval_key(invocation, metadata, approval_mode);
let persistent_approval_key =
persistent_mcp_tool_approval_key(invocation, metadata, approval_mode);
if let Some(key) = session_approval_key.as_ref()
&& mcp_tool_approval_is_remembered(sess, key).await
{
return Some(McpToolApprovalDecision::Accept);
}
let tool_call_mcp_elicitation_enabled = turn_context
.config
.features
.enabled(Feature::ToolCallMcpElicitation);
if routes_approval_to_guardian(turn_context) {
let decision = review_approval_request(
@@ -440,9 +459,23 @@ async fn maybe_request_mcp_tool_approval(
None,
)
.await;
return Some(mcp_tool_approval_decision_from_guardian(decision));
let decision = mcp_tool_approval_decision_from_guardian(decision);
apply_mcp_tool_approval_decision(
sess,
turn_context,
decision,
session_approval_key,
persistent_approval_key,
)
.await;
return Some(decision);
}
let prompt_options = mcp_tool_approval_prompt_options(
session_approval_key.as_ref(),
persistent_approval_key.as_ref(),
tool_call_mcp_elicitation_enabled,
);
let question_id = format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}");
let question = build_mcp_tool_approval_question(
question_id.clone(),
@@ -451,13 +484,9 @@ async fn maybe_request_mcp_tool_approval(
metadata.and_then(|metadata| metadata.tool_title.as_deref()),
metadata.and_then(|metadata| metadata.connector_name.as_deref()),
annotations,
approval_key.is_some(),
prompt_options,
);
if turn_context
.config
.features
.enabled(Feature::ToolCallMcpElicitation)
{
if tool_call_mcp_elicitation_enabled {
let request_id = rmcp::model::RequestId::String(
format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}").into(),
);
@@ -468,7 +497,7 @@ async fn maybe_request_mcp_tool_approval(
metadata,
invocation.arguments.as_ref(),
question.clone(),
approval_key.is_some(),
prompt_options,
);
let decision = parse_mcp_tool_approval_elicitation_response(
sess.request_mcp_server_elicitation(turn_context.as_ref(), request_id, params)
@@ -476,11 +505,14 @@ async fn maybe_request_mcp_tool_approval(
&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;
}
apply_mcp_tool_approval_decision(
sess,
turn_context,
decision,
session_approval_key,
persistent_approval_key,
)
.await;
return Some(decision);
}
@@ -494,14 +526,51 @@ async fn maybe_request_mcp_tool_approval(
parse_mcp_tool_approval_response(response, &question_id),
approval_mode,
);
if matches!(decision, McpToolApprovalDecision::AcceptAndRemember)
&& let Some(key) = approval_key
{
remember_mcp_tool_approval(sess, key).await;
}
apply_mcp_tool_approval_decision(
sess,
turn_context,
decision,
session_approval_key,
persistent_approval_key,
)
.await;
Some(decision)
}
fn session_mcp_tool_approval_key(
invocation: &McpInvocation,
metadata: Option<&McpToolApprovalMetadata>,
approval_mode: AppToolApproval,
) -> Option<McpToolApprovalKey> {
if approval_mode != AppToolApproval::Auto {
return None;
}
let connector_id = metadata.and_then(|metadata| metadata.connector_id.clone());
if invocation.server == CODEX_APPS_MCP_SERVER_NAME && connector_id.is_none() {
return None;
}
Some(McpToolApprovalKey {
server: invocation.server.clone(),
connector_id,
tool_name: invocation.tool.clone(),
})
}
fn persistent_mcp_tool_approval_key(
invocation: &McpInvocation,
metadata: Option<&McpToolApprovalMetadata>,
approval_mode: AppToolApproval,
) -> Option<McpToolApprovalKey> {
if invocation.server != CODEX_APPS_MCP_SERVER_NAME {
return None;
}
session_mcp_tool_approval_key(invocation, metadata, approval_mode)
.filter(|key| key.connector_id.is_some())
}
fn build_guardian_mcp_tool_review_request(
invocation: &McpInvocation,
metadata: Option<&McpToolApprovalMetadata>,
@@ -594,8 +663,8 @@ fn mcp_tool_approval_decision_from_guardian(decision: ReviewDecision) -> McpTool
match decision {
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession
| ReviewDecision::NetworkPolicyAmendment { .. } => McpToolApprovalDecision::Accept,
ReviewDecision::ApprovedForSession => McpToolApprovalDecision::AcceptForSession,
ReviewDecision::Denied | ReviewDecision::Abort => McpToolApprovalDecision::Decline,
}
}
@@ -691,7 +760,7 @@ fn build_mcp_tool_approval_question(
tool_title: Option<&str>,
connector_name: Option<&str>,
annotations: Option<&ToolAnnotations>,
allow_remember_option: bool,
prompt_options: McpToolApprovalPromptOptions,
) -> RequestUserInputQuestion {
let destructive =
annotations.and_then(|annotations| annotations.destructive_hint) == Some(true);
@@ -721,22 +790,22 @@ fn build_mcp_tool_approval_question(
label: MCP_TOOL_APPROVAL_ACCEPT.to_string(),
description: "Run the tool and continue.".to_string(),
}];
if allow_remember_option {
if prompt_options.allow_session_remember {
options.push(RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string(),
label: MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(),
description: "Run the tool and remember this choice for this session.".to_string(),
});
}
options.extend([
RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_DECLINE.to_string(),
description: "Decline this tool call and continue.".to_string(),
},
RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_CANCEL.to_string(),
description: "Cancel this tool call".to_string(),
},
]);
if prompt_options.allow_persistent_approval {
options.push(RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string(),
description: "Run the tool and remember this choice for future tool calls.".to_string(),
});
}
options.push(RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_CANCEL.to_string(),
description: "Cancel this tool call".to_string(),
});
RequestUserInputQuestion {
id: question_id,
@@ -755,7 +824,7 @@ fn build_mcp_tool_approval_elicitation_request(
metadata: Option<&McpToolApprovalMetadata>,
tool_params: Option<&serde_json::Value>,
question: RequestUserInputQuestion,
allow_session_persist: bool,
prompt_options: McpToolApprovalPromptOptions,
) -> McpServerElicitationRequestParams {
let message = if question.header.trim().is_empty() {
question.question
@@ -774,7 +843,7 @@ fn build_mcp_tool_approval_elicitation_request(
server,
metadata,
tool_params,
allow_session_persist,
prompt_options,
),
message,
requested_schema: McpElicitationSchema {
@@ -791,18 +860,39 @@ fn build_mcp_tool_approval_elicitation_meta(
server: &str,
metadata: Option<&McpToolApprovalMetadata>,
tool_params: Option<&serde_json::Value>,
allow_session_persist: bool,
prompt_options: McpToolApprovalPromptOptions,
) -> 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()),
);
match (
prompt_options.allow_session_remember,
prompt_options.allow_persistent_approval,
) {
(true, true) => {
meta.insert(
MCP_TOOL_APPROVAL_PERSIST_KEY.to_string(),
serde_json::json!([
MCP_TOOL_APPROVAL_PERSIST_SESSION,
MCP_TOOL_APPROVAL_PERSIST_ALWAYS,
]),
);
}
(true, false) => {
meta.insert(
MCP_TOOL_APPROVAL_PERSIST_KEY.to_string(),
serde_json::Value::String(MCP_TOOL_APPROVAL_PERSIST_SESSION.to_string()),
);
}
(false, true) => {
meta.insert(
MCP_TOOL_APPROVAL_PERSIST_KEY.to_string(),
serde_json::Value::String(MCP_TOOL_APPROVAL_PERSIST_ALWAYS.to_string()),
);
}
(false, false) => {}
}
if let Some(metadata) = metadata {
if let Some(tool_title) = metadata.tool_title.as_ref() {
@@ -864,15 +954,20 @@ fn parse_mcp_tool_approval_elicitation_response(
};
match response.action {
ElicitationAction::Accept => {
if response
match 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;
Some(MCP_TOOL_APPROVAL_PERSIST_SESSION) => {
return McpToolApprovalDecision::AcceptForSession;
}
Some(MCP_TOOL_APPROVAL_PERSIST_ALWAYS) => {
return McpToolApprovalDecision::AcceptAndRemember;
}
_ => {}
}
match parse_mcp_tool_approval_response(
@@ -930,6 +1025,11 @@ fn parse_mcp_tool_approval_response(
return McpToolApprovalDecision::Cancel;
};
if answers
.iter()
.any(|answer| answer == MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION)
{
McpToolApprovalDecision::AcceptForSession
} else if answers
.iter()
.any(|answer| answer == MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER)
{
@@ -939,13 +1039,8 @@ fn parse_mcp_tool_approval_response(
.any(|answer| answer == MCP_TOOL_APPROVAL_ACCEPT)
{
McpToolApprovalDecision::Accept
} else if answers
.iter()
.any(|answer| answer == MCP_TOOL_APPROVAL_CANCEL)
{
McpToolApprovalDecision::Cancel
} else {
McpToolApprovalDecision::Decline
McpToolApprovalDecision::Cancel
}
}
@@ -954,7 +1049,10 @@ fn normalize_approval_decision_for_mode(
approval_mode: AppToolApproval,
) -> McpToolApprovalDecision {
if approval_mode == AppToolApproval::Prompt
&& decision == McpToolApprovalDecision::AcceptAndRemember
&& matches!(
decision,
McpToolApprovalDecision::AcceptForSession | McpToolApprovalDecision::AcceptAndRemember
)
{
McpToolApprovalDecision::Accept
} else {
@@ -972,6 +1070,81 @@ async fn remember_mcp_tool_approval(sess: &Session, key: McpToolApprovalKey) {
store.put(key, ReviewDecision::ApprovedForSession);
}
async fn apply_mcp_tool_approval_decision(
sess: &Session,
turn_context: &TurnContext,
decision: McpToolApprovalDecision,
session_approval_key: Option<McpToolApprovalKey>,
persistent_approval_key: Option<McpToolApprovalKey>,
) {
match decision {
McpToolApprovalDecision::AcceptForSession => {
if let Some(key) = session_approval_key {
remember_mcp_tool_approval(sess, key).await;
}
}
McpToolApprovalDecision::AcceptAndRemember => {
if let Some(key) = persistent_approval_key {
maybe_persist_mcp_tool_approval(sess, turn_context, key).await;
} else if let Some(key) = session_approval_key {
remember_mcp_tool_approval(sess, key).await;
}
}
McpToolApprovalDecision::Accept
| McpToolApprovalDecision::Decline
| McpToolApprovalDecision::Cancel => {}
}
}
async fn maybe_persist_mcp_tool_approval(
sess: &Session,
turn_context: &TurnContext,
key: McpToolApprovalKey,
) {
let Some(connector_id) = key.connector_id.clone() else {
remember_mcp_tool_approval(sess, key).await;
return;
};
let tool_name = key.tool_name.clone();
if let Err(err) =
persist_codex_app_tool_approval(&turn_context.config.codex_home, &connector_id, &tool_name)
.await
{
error!(
error = %err,
connector_id,
tool_name,
"failed to persist codex app tool approval"
);
remember_mcp_tool_approval(sess, key).await;
return;
}
sess.reload_user_config_layer().await;
remember_mcp_tool_approval(sess, key).await;
}
async fn persist_codex_app_tool_approval(
codex_home: &Path,
connector_id: &str,
tool_name: &str,
) -> anyhow::Result<()> {
ConfigEditsBuilder::new(codex_home)
.with_edits([ConfigEdit::SetPath {
segments: vec![
"apps".to_string(),
connector_id.to_string(),
"tools".to_string(),
tool_name.to_string(),
"approval_mode".to_string(),
],
value: value("approve"),
}])
.apply()
.await
}
fn requires_mcp_tool_approval(annotations: &ToolAnnotations) -> bool {
if annotations.destructive_hint == Some(true) {
return true;
@@ -1006,7 +1179,17 @@ async fn notify_mcp_tool_call_skip(
#[cfg(test)]
mod tests {
use super::*;
use crate::codex::make_session_and_context;
use crate::config::ConfigToml;
use crate::config::types::AppConfig;
use crate::config::types::AppToolConfig;
use crate::config::types::AppToolsConfig;
use crate::config::types::AppsConfigToml;
use codex_config::CONFIG_TOML_FILE;
use pretty_assertions::assert_eq;
use serde::Deserialize;
use std::collections::HashMap;
use tempfile::tempdir;
fn annotations(
read_only: Option<bool>,
@@ -1039,6 +1222,16 @@ mod tests {
}
}
fn prompt_options(
allow_session_remember: bool,
allow_persistent_approval: bool,
) -> McpToolApprovalPromptOptions {
McpToolApprovalPromptOptions {
allow_session_remember,
allow_persistent_approval,
}
}
#[test]
fn approval_required_when_read_only_false_and_destructive() {
let annotations = annotations(Some(false), Some(true), None);
@@ -1058,7 +1251,14 @@ mod tests {
}
#[test]
fn prompt_mode_does_not_allow_session_remember() {
fn prompt_mode_does_not_allow_persistent_remember() {
assert_eq!(
normalize_approval_decision_for_mode(
McpToolApprovalDecision::AcceptForSession,
AppToolApproval::Prompt,
),
McpToolApprovalDecision::Accept
);
assert_eq!(
normalize_approval_decision_for_mode(
McpToolApprovalDecision::AcceptAndRemember,
@@ -1077,7 +1277,7 @@ mod tests {
Some("Run Action"),
None,
Some(&annotations(Some(false), Some(true), None)),
true,
prompt_options(false, false),
);
assert_eq!(question.header, "Approve app tool call?");
@@ -1086,7 +1286,7 @@ mod tests {
"The custom_server MCP server wants to run the tool \"Run Action\", which may modify or delete data. Allow this action?"
);
assert!(
question
!question
.options
.expect("options")
.into_iter()
@@ -1104,7 +1304,7 @@ mod tests {
Some("Run Action"),
None,
Some(&annotations(Some(false), Some(true), None)),
true,
prompt_options(true, true),
);
assert!(
@@ -1114,6 +1314,149 @@ mod tests {
);
}
#[test]
fn trusted_codex_apps_tool_question_offers_always_allow() {
let question = build_mcp_tool_approval_question(
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
Some("Run Action"),
Some("Calendar"),
Some(&annotations(Some(false), Some(true), None)),
prompt_options(true, true),
);
let options = question.options.expect("options");
assert!(options.iter().any(|option| {
option.label == MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION
&& option.description == "Run the tool and remember this choice for this session."
}));
assert!(options.iter().any(|option| {
option.label == MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER
&& option.description
== "Run the tool and remember this choice for future tool calls."
}));
assert_eq!(
options
.into_iter()
.map(|option| option.label)
.collect::<Vec<_>>(),
vec![
MCP_TOOL_APPROVAL_ACCEPT.to_string(),
MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(),
MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string(),
MCP_TOOL_APPROVAL_CANCEL.to_string(),
]
);
}
#[test]
fn codex_apps_tool_question_without_elicitation_omits_always_allow() {
let session_key = McpToolApprovalKey {
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
connector_id: Some("calendar".to_string()),
tool_name: "run_action".to_string(),
};
let persistent_key = session_key.clone();
let question = build_mcp_tool_approval_question(
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
Some("Run Action"),
Some("Calendar"),
Some(&annotations(Some(false), Some(true), None)),
mcp_tool_approval_prompt_options(Some(&session_key), Some(&persistent_key), false),
);
assert_eq!(
question
.options
.expect("options")
.into_iter()
.map(|option| option.label)
.collect::<Vec<_>>(),
vec![
MCP_TOOL_APPROVAL_ACCEPT.to_string(),
MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(),
MCP_TOOL_APPROVAL_CANCEL.to_string(),
]
);
}
#[test]
fn custom_mcp_tool_question_offers_session_remember_without_always_allow() {
let question = build_mcp_tool_approval_question(
"q".to_string(),
"custom_server",
"run_action",
Some("Run Action"),
None,
Some(&annotations(Some(false), Some(true), None)),
prompt_options(true, false),
);
assert_eq!(
question
.options
.expect("options")
.into_iter()
.map(|option| option.label)
.collect::<Vec<_>>(),
vec![
MCP_TOOL_APPROVAL_ACCEPT.to_string(),
MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(),
MCP_TOOL_APPROVAL_CANCEL.to_string(),
]
);
}
#[test]
fn custom_servers_keep_session_remember_without_persistent_approval() {
let invocation = McpInvocation {
server: "custom_server".to_string(),
tool: "run_action".to_string(),
arguments: None,
};
let expected = McpToolApprovalKey {
server: "custom_server".to_string(),
connector_id: None,
tool_name: "run_action".to_string(),
};
assert_eq!(
session_mcp_tool_approval_key(&invocation, None, AppToolApproval::Auto),
Some(expected)
);
assert_eq!(
persistent_mcp_tool_approval_key(&invocation, None, AppToolApproval::Auto),
None
);
}
#[test]
fn codex_apps_connectors_support_persistent_approval() {
let invocation = McpInvocation {
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool: "calendar/list_events".to_string(),
arguments: None,
};
let metadata = approval_metadata(Some("calendar"), Some("Calendar"), None, None, None);
let expected = McpToolApprovalKey {
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
connector_id: Some("calendar".to_string()),
tool_name: "calendar/list_events".to_string(),
};
assert_eq!(
session_mcp_tool_approval_key(&invocation, Some(&metadata), AppToolApproval::Auto),
Some(expected.clone())
);
assert_eq!(
persistent_mcp_tool_approval_key(&invocation, Some(&metadata), AppToolApproval::Auto),
Some(expected)
);
}
#[test]
fn sanitize_mcp_tool_result_for_model_rewrites_image_content() {
let result = Ok(CallToolResult {
@@ -1194,7 +1537,12 @@ mod tests {
#[test]
fn approval_elicitation_meta_marks_tool_approvals() {
assert_eq!(
build_mcp_tool_approval_elicitation_meta("custom_server", None, None, false),
build_mcp_tool_approval_elicitation_meta(
"custom_server",
None,
None,
prompt_options(false, false),
),
Some(serde_json::json!({
MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL,
}))
@@ -1202,7 +1550,7 @@ mod tests {
}
#[test]
fn approval_elicitation_meta_keeps_session_persist_behavior() {
fn approval_elicitation_meta_keeps_session_persist_behavior_for_custom_servers() {
assert_eq!(
build_mcp_tool_approval_elicitation_meta(
"custom_server",
@@ -1214,7 +1562,7 @@ mod tests {
Some("Runs the selected action."),
)),
Some(&serde_json::json!({"id": 1})),
true,
prompt_options(true, false),
),
Some(serde_json::json!({
MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL,
@@ -1335,7 +1683,7 @@ mod tests {
Some(&serde_json::json!({
"calendar_id": "primary",
})),
false,
prompt_options(false, false),
),
Some(serde_json::json!({
MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL,
@@ -1353,7 +1701,7 @@ mod tests {
}
#[test]
fn approval_elicitation_meta_merges_session_persist_with_connector_source() {
fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_source() {
assert_eq!(
build_mcp_tool_approval_elicitation_meta(
CODEX_APPS_MCP_SERVER_NAME,
@@ -1367,11 +1715,14 @@ mod tests {
Some(&serde_json::json!({
"calendar_id": "primary",
})),
true,
prompt_options(true, 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_PERSIST_KEY: [
MCP_TOOL_APPROVAL_PERSIST_SESSION,
MCP_TOOL_APPROVAL_PERSIST_ALWAYS,
],
MCP_TOOL_APPROVAL_SOURCE_KEY: MCP_TOOL_APPROVAL_SOURCE_CONNECTOR,
MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY: "calendar",
MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY: "Calendar",
@@ -1401,6 +1752,22 @@ mod tests {
assert_eq!(response, McpToolApprovalDecision::Decline);
}
#[test]
fn accepted_elicitation_response_uses_always_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_ALWAYS,
})),
}),
"approval",
);
assert_eq!(response, McpToolApprovalDecision::AcceptAndRemember);
}
#[test]
fn accepted_elicitation_response_uses_session_persist_meta() {
let response = parse_mcp_tool_approval_elicitation_response(
@@ -1414,7 +1781,7 @@ mod tests {
"approval",
);
assert_eq!(response, McpToolApprovalDecision::AcceptAndRemember);
assert_eq!(response, McpToolApprovalDecision::AcceptForSession);
}
#[test]
@@ -1430,4 +1797,83 @@ mod tests {
assert_eq!(response, McpToolApprovalDecision::Accept);
}
#[tokio::test]
async fn persist_codex_app_tool_approval_writes_tool_override() {
let tmp = tempdir().expect("tempdir");
persist_codex_app_tool_approval(tmp.path(), "calendar", "calendar/list_events")
.await
.expect("persist approval");
let contents =
std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config");
let parsed: ConfigToml = toml::from_str(&contents).expect("parse config");
assert_eq!(
parsed.apps,
Some(AppsConfigToml {
default: None,
apps: HashMap::from([(
"calendar".to_string(),
AppConfig {
enabled: true,
destructive_enabled: None,
open_world_enabled: None,
default_tools_approval_mode: None,
default_tools_enabled: None,
tools: Some(AppToolsConfig {
tools: HashMap::from([(
"calendar/list_events".to_string(),
AppToolConfig {
enabled: None,
approval_mode: Some(AppToolApproval::Approve),
},
)]),
}),
},
)]),
})
);
assert!(contents.contains("[apps.calendar.tools.\"calendar/list_events\"]"));
}
#[tokio::test]
async fn maybe_persist_mcp_tool_approval_reloads_session_config() {
let (session, turn_context) = make_session_and_context().await;
let codex_home = session.codex_home().await;
std::fs::create_dir_all(&codex_home).expect("create codex home");
let key = McpToolApprovalKey {
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
connector_id: Some("calendar".to_string()),
tool_name: "calendar/list_events".to_string(),
};
maybe_persist_mcp_tool_approval(&session, &turn_context, key.clone()).await;
let config = session.get_config().await;
let apps_toml = config
.config_layer_stack
.effective_config()
.as_table()
.and_then(|table| table.get("apps"))
.cloned()
.expect("apps table");
let apps = AppsConfigToml::deserialize(apps_toml).expect("deserialize apps config");
let tool = apps
.apps
.get("calendar")
.and_then(|app| app.tools.as_ref())
.and_then(|tools| tools.tools.get("calendar/list_events"))
.expect("calendar/list_events tool config exists");
assert_eq!(
tool,
&AppToolConfig {
enabled: None,
approval_mode: Some(AppToolApproval::Approve),
}
);
assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true);
}
}