Auto-approve MCP server elicitations in Full Access mode (#17164)

Currently, when a MCP server sends an elicitation to Codex running in
Full Access (`sandbox_policy: DangerFullAccess` + `approval_policy:
Never`), the elicitations are auto-cancelled.

This PR updates the automatic handling of MCP elicitations to be
consistent with other approvals in full-access, where they are
auto-approved. Because MCP elicitations may actually require user input,
this mechanism is limited to empty form elicitations.

## Changeset
- Add policy helper shared with existing MCP tool call approval
auto-approve
- Update `ElicitationRequestManager` to auto-approve elicitations in
full access when `can_auto_accept_elicitation` is true.
- Add tests

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Leo Shimonaka
2026-04-08 16:41:02 -07:00
committed by GitHub
parent dcbc91fd39
commit 01537f0bd2
9 changed files with 162 additions and 37 deletions

View File

@@ -22,6 +22,7 @@ pub use mcp::configured_mcp_servers;
pub use mcp::discover_supported_scopes;
pub use mcp::effective_mcp_servers;
pub use mcp::group_tools_by_server;
pub use mcp::mcp_permission_prompt_is_auto_approved;
pub use mcp::oauth_login_support;
pub use mcp::qualified_mcp_tool_name_prefix;
pub use mcp::resolve_oauth_scopes;

View File

@@ -82,6 +82,19 @@ pub fn qualified_mcp_tool_name_prefix(server_name: &str) -> String {
))
}
/// Returns true when MCP permission prompts should resolve as approved instead
/// of being shown to the user.
pub fn mcp_permission_prompt_is_auto_approved(
approval_policy: AskForApproval,
sandbox_policy: &SandboxPolicy,
) -> bool {
approval_policy == AskForApproval::Never
&& matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
)
}
/// MCP runtime settings derived from `codex_core::config::Config`.
///
/// This struct should contain only long-lived configuration values that the

View File

@@ -25,6 +25,7 @@ use crate::mcp::McpConfig;
use crate::mcp::ToolPluginProvenance;
use crate::mcp::configured_mcp_servers;
use crate::mcp::effective_mcp_servers;
use crate::mcp::mcp_permission_prompt_is_auto_approved;
use crate::mcp::sanitize_responses_api_tool_name;
use crate::mcp::tool_plugin_provenance;
use anyhow::Context;
@@ -243,17 +244,31 @@ fn elicitation_is_rejected_by_policy(approval_policy: AskForApproval) -> bool {
}
}
fn can_auto_accept_elicitation(elicitation: &CreateElicitationRequestParams) -> bool {
match elicitation {
CreateElicitationRequestParams::FormElicitationParams {
requested_schema, ..
} => {
// Auto-accept confirm/approval elicitations without schema requirements.
requested_schema.properties.is_empty()
}
CreateElicitationRequestParams::UrlElicitationParams { .. } => false,
}
}
#[derive(Clone)]
struct ElicitationRequestManager {
requests: Arc<Mutex<ResponderMap>>,
approval_policy: Arc<StdMutex<AskForApproval>>,
sandbox_policy: Arc<StdMutex<SandboxPolicy>>,
}
impl ElicitationRequestManager {
fn new(approval_policy: AskForApproval) -> Self {
fn new(approval_policy: AskForApproval, sandbox_policy: SandboxPolicy) -> Self {
Self {
requests: Arc::new(Mutex::new(HashMap::new())),
approval_policy: Arc::new(StdMutex::new(approval_policy)),
sandbox_policy: Arc::new(StdMutex::new(sandbox_policy)),
}
}
@@ -275,16 +290,33 @@ impl ElicitationRequestManager {
fn make_sender(&self, server_name: String, tx_event: Sender<Event>) -> SendElicitation {
let elicitation_requests = self.requests.clone();
let approval_policy = self.approval_policy.clone();
let sandbox_policy = self.sandbox_policy.clone();
Box::new(move |id, elicitation| {
let elicitation_requests = elicitation_requests.clone();
let tx_event = tx_event.clone();
let server_name = server_name.clone();
let approval_policy = approval_policy.clone();
let sandbox_policy = sandbox_policy.clone();
async move {
if approval_policy
let approval_policy = approval_policy
.lock()
.is_ok_and(|policy| elicitation_is_rejected_by_policy(*policy))
.map(|policy| *policy)
.unwrap_or(AskForApproval::Never);
let sandbox_policy = sandbox_policy
.lock()
.map(|policy| policy.clone())
.unwrap_or_else(|_| SandboxPolicy::new_read_only_policy());
if mcp_permission_prompt_is_auto_approved(approval_policy, &sandbox_policy)
&& can_auto_accept_elicitation(&elicitation)
{
return Ok(ElicitationResponse {
action: ElicitationAction::Accept,
content: Some(serde_json::json!({})),
meta: None,
});
}
if elicitation_is_rejected_by_policy(approval_policy) {
return Ok(ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
@@ -604,11 +636,17 @@ impl McpConnectionManager {
tool_plugin_provenance(config)
}
pub fn new_uninitialized(approval_policy: &Constrained<AskForApproval>) -> Self {
pub fn new_uninitialized(
approval_policy: &Constrained<AskForApproval>,
sandbox_policy: &Constrained<SandboxPolicy>,
) -> Self {
Self {
clients: HashMap::new(),
server_origins: HashMap::new(),
elicitation_requests: ElicitationRequestManager::new(approval_policy.value()),
elicitation_requests: ElicitationRequestManager::new(
approval_policy.value(),
sandbox_policy.get().clone(),
),
}
}
@@ -626,6 +664,12 @@ impl McpConnectionManager {
}
}
pub fn set_sandbox_policy(&self, sandbox_policy: &SandboxPolicy) {
if let Ok(mut policy) = self.elicitation_requests.sandbox_policy.lock() {
*policy = sandbox_policy.clone();
}
}
#[allow(clippy::new_ret_no_self, clippy::too_many_arguments)]
pub async fn new(
mcp_servers: &HashMap<String, McpServerConfig>,
@@ -643,7 +687,10 @@ impl McpConnectionManager {
let mut clients = HashMap::new();
let mut server_origins = HashMap::new();
let mut join_set = JoinSet::new();
let elicitation_requests = ElicitationRequestManager::new(approval_policy.value());
let elicitation_requests = ElicitationRequestManager::new(
approval_policy.value(),
initial_sandbox_state.sandbox_policy.clone(),
);
let tool_plugin_provenance = Arc::new(tool_plugin_provenance);
let startup_submit_id = submit_id.clone();
let mcp_servers = mcp_servers.clone();

View File

@@ -1,7 +1,9 @@
use super::*;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_protocol::protocol::McpAuthStatus;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
use rmcp::model::NumberOrString;
use std::collections::HashSet;
use std::sync::Arc;
use tempfile::tempdir;
@@ -97,6 +99,70 @@ fn elicitation_granular_policy_respects_never_and_config() {
)));
}
#[tokio::test]
async fn full_access_auto_accepts_elicitation_with_empty_form_schema() {
let manager =
ElicitationRequestManager::new(AskForApproval::Never, SandboxPolicy::DangerFullAccess);
let (tx_event, _rx_event) = async_channel::bounded(1);
let sender = manager.make_sender("server".to_string(), tx_event);
let response = sender(
NumberOrString::Number(1),
CreateElicitationRequestParams::FormElicitationParams {
meta: None,
message: "Confirm?".to_string(),
requested_schema: rmcp::model::ElicitationSchema::builder()
.build()
.expect("schema should build"),
},
)
.await
.expect("elicitation should auto accept");
assert_eq!(
response,
ElicitationResponse {
action: ElicitationAction::Accept,
content: Some(serde_json::json!({})),
meta: None,
}
);
}
#[tokio::test]
async fn full_access_does_not_auto_accept_elicitation_with_requested_fields() {
let manager =
ElicitationRequestManager::new(AskForApproval::Never, SandboxPolicy::DangerFullAccess);
let (tx_event, _rx_event) = async_channel::bounded(1);
let sender = manager.make_sender("server".to_string(), tx_event);
let response = sender(
NumberOrString::Number(1),
CreateElicitationRequestParams::FormElicitationParams {
meta: None,
message: "What should I say?".to_string(),
requested_schema: rmcp::model::ElicitationSchema::builder()
.required_property(
"message",
rmcp::model::PrimitiveSchema::String(rmcp::model::StringSchema::new()),
)
.build()
.expect("schema should build"),
},
)
.await
.expect("elicitation should auto decline");
assert_eq!(
response,
ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
meta: None,
}
);
}
#[test]
fn test_qualify_tools_short_non_duplicated_names() {
let tools = vec![
@@ -409,7 +475,8 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() {
.boxed()
.shared();
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
let mut manager = McpConnectionManager::new_uninitialized(&approval_policy);
let sandbox_policy = Constrained::allow_any(SandboxPolicy::new_read_only_policy());
let mut manager = McpConnectionManager::new_uninitialized(&approval_policy, &sandbox_policy);
manager.clients.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
AsyncManagedClient {
@@ -434,7 +501,8 @@ async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot(
.boxed()
.shared();
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
let mut manager = McpConnectionManager::new_uninitialized(&approval_policy);
let sandbox_policy = Constrained::allow_any(SandboxPolicy::new_read_only_policy());
let mut manager = McpConnectionManager::new_uninitialized(&approval_policy, &sandbox_policy);
manager.clients.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
AsyncManagedClient {
@@ -456,7 +524,8 @@ async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty(
.boxed()
.shared();
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
let mut manager = McpConnectionManager::new_uninitialized(&approval_policy);
let sandbox_policy = Constrained::allow_any(SandboxPolicy::new_read_only_policy());
let mut manager = McpConnectionManager::new_uninitialized(&approval_policy, &sandbox_policy);
manager.clients.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
AsyncManagedClient {
@@ -487,7 +556,8 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() {
.boxed()
.shared();
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
let mut manager = McpConnectionManager::new_uninitialized(&approval_policy);
let sandbox_policy = Constrained::allow_any(SandboxPolicy::new_read_only_policy());
let mut manager = McpConnectionManager::new_uninitialized(&approval_policy, &sandbox_policy);
let startup_complete = Arc::new(std::sync::atomic::AtomicBool::new(true));
manager.clients.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),