mirror of
https://github.com/openai/codex.git
synced 2026-06-01 19:02:59 +00:00
[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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user