mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
## What changed - Make the MCP connection manager's event sender optional. - Skip startup notifications when a caller has no session event stream. - Decline interactive MCP elicitations in that mode while allowing the underlying operation to continue. - Update threadless resource reads, status snapshots, and connector discovery to use the channel-free path. ## Testing - Extend the app-server MCP resource test to verify that a threadless resource read declines an elicitation and still returns the resource contents. GitOrigin-RevId: 65fea152adbc02a78e09a40cae4134b6dccffd34
342 lines
12 KiB
Rust
342 lines
12 KiB
Rust
//! MCP elicitation request tracking and policy handling.
|
|
//!
|
|
//! RMCP clients call into this module when a server asks Codex to elicit data
|
|
//! from the user. It decides whether the request can be automatically accepted,
|
|
//! must be declined by policy, or should be surfaced as a Codex protocol event
|
|
//! and later resolved through the stored responder.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex as StdMutex;
|
|
use std::sync::atomic::AtomicU64;
|
|
use std::sync::atomic::Ordering;
|
|
|
|
use crate::mcp::McpPermissionPromptAutoApproveContext;
|
|
use crate::mcp::mcp_permission_prompt_is_auto_approved;
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use anyhow::anyhow;
|
|
use async_channel::Sender;
|
|
use codex_protocol::approvals::ElicitationRequest;
|
|
use codex_protocol::approvals::ElicitationRequestEvent;
|
|
use codex_protocol::mcp::RequestId as ProtocolRequestId;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::Event;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_rmcp_client::Elicitation;
|
|
use codex_rmcp_client::ElicitationResponse;
|
|
use codex_rmcp_client::SendElicitation;
|
|
use futures::future::BoxFuture;
|
|
use futures::future::FutureExt;
|
|
use rmcp::model::ElicitationAction;
|
|
use rmcp::model::RequestId;
|
|
use tokio::sync::Mutex;
|
|
use tokio::sync::oneshot;
|
|
|
|
static NEXT_ELICITATION_REQUEST_ID: AtomicU64 = AtomicU64::new(0);
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ElicitationReviewRequest {
|
|
pub server_name: String,
|
|
pub request_id: RequestId,
|
|
pub elicitation: Elicitation,
|
|
}
|
|
|
|
pub trait ElicitationReviewer: Send + Sync {
|
|
fn review(
|
|
&self,
|
|
request: ElicitationReviewRequest,
|
|
) -> BoxFuture<'static, Result<Option<ElicitationResponse>>>;
|
|
}
|
|
|
|
pub type ElicitationReviewerHandle = Arc<dyn ElicitationReviewer>;
|
|
|
|
/// Holds an owner-provided registration while an MCP elicitation is waiting for a response.
|
|
#[derive(Clone)]
|
|
pub struct ElicitationLifecycle {
|
|
register: Arc<dyn Fn() -> Box<dyn Send + Sync> + Send + Sync>,
|
|
}
|
|
|
|
impl ElicitationLifecycle {
|
|
pub fn new<T>(register: impl Fn() -> T + Send + Sync + 'static) -> Self
|
|
where
|
|
T: Send + Sync + 'static,
|
|
{
|
|
Self {
|
|
register: Arc::new(move || Box::new(register())),
|
|
}
|
|
}
|
|
|
|
fn start(&self) -> ActiveElicitation {
|
|
ActiveElicitation {
|
|
_registration: (self.register)(),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ActiveElicitation {
|
|
_registration: Box<dyn Send + Sync>,
|
|
}
|
|
|
|
/// Routes model-visible elicitation response tokens to their exact pending responders.
|
|
///
|
|
/// One router is shared by every MCP runtime created for a thread. The public response token is
|
|
/// generated by Codex rather than copied from the MCP connection, so separate runtimes may reuse
|
|
/// the same server request ID without colliding.
|
|
#[derive(Clone, Default)]
|
|
pub struct ElicitationRequestRouter {
|
|
requests: Arc<Mutex<ResponderMap>>,
|
|
}
|
|
|
|
impl ElicitationRequestRouter {
|
|
async fn resolve(
|
|
&self,
|
|
server_name: String,
|
|
id: RequestId,
|
|
response: ElicitationResponse,
|
|
) -> Result<()> {
|
|
self.requests
|
|
.lock()
|
|
.await
|
|
.remove(&(server_name, id))
|
|
.ok_or_else(|| anyhow!("elicitation request not found"))?
|
|
.send(response)
|
|
.map_err(|e| anyhow!("failed to send elicitation response: {e:?}"))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct ElicitationRequestManager {
|
|
router: ElicitationRequestRouter,
|
|
pub(crate) approval_policy: Arc<StdMutex<AskForApproval>>,
|
|
pub(crate) permission_profile: Arc<StdMutex<PermissionProfile>>,
|
|
auto_deny: Arc<StdMutex<bool>>,
|
|
reviewer: Option<ElicitationReviewerHandle>,
|
|
lifecycle: Option<ElicitationLifecycle>,
|
|
}
|
|
|
|
impl ElicitationRequestManager {
|
|
pub(crate) fn new(
|
|
approval_policy: AskForApproval,
|
|
permission_profile: PermissionProfile,
|
|
reviewer: Option<ElicitationReviewerHandle>,
|
|
lifecycle: Option<ElicitationLifecycle>,
|
|
router: ElicitationRequestRouter,
|
|
) -> Self {
|
|
Self {
|
|
router,
|
|
approval_policy: Arc::new(StdMutex::new(approval_policy)),
|
|
permission_profile: Arc::new(StdMutex::new(permission_profile)),
|
|
auto_deny: Arc::new(StdMutex::new(false)),
|
|
reviewer,
|
|
lifecycle,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn auto_deny(&self) -> bool {
|
|
self.auto_deny
|
|
.lock()
|
|
.map(|auto_deny| *auto_deny)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
pub(crate) fn set_auto_deny(&self, auto_deny: bool) {
|
|
if let Ok(mut current) = self.auto_deny.lock() {
|
|
*current = auto_deny;
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn resolve(
|
|
&self,
|
|
server_name: String,
|
|
id: RequestId,
|
|
response: ElicitationResponse,
|
|
) -> Result<()> {
|
|
self.router.resolve(server_name, id, response).await
|
|
}
|
|
|
|
pub(crate) fn router(&self) -> ElicitationRequestRouter {
|
|
self.router.clone()
|
|
}
|
|
|
|
pub(crate) fn make_sender(
|
|
&self,
|
|
server_name: String,
|
|
tx_event: Option<Sender<Event>>,
|
|
) -> SendElicitation {
|
|
let router = self.router.clone();
|
|
let approval_policy = self.approval_policy.clone();
|
|
let permission_profile = self.permission_profile.clone();
|
|
let auto_deny = self.auto_deny.clone();
|
|
let reviewer = self.reviewer.clone();
|
|
let lifecycle = self.lifecycle.clone();
|
|
Box::new(move |id, elicitation| {
|
|
let router = router.clone();
|
|
let tx_event = tx_event.clone();
|
|
let server_name = server_name.clone();
|
|
let approval_policy = approval_policy.clone();
|
|
let permission_profile = permission_profile.clone();
|
|
let auto_deny = auto_deny.clone();
|
|
let reviewer = reviewer.clone();
|
|
let lifecycle = lifecycle.clone();
|
|
async move {
|
|
let auto_deny = auto_deny
|
|
.lock()
|
|
.map(|auto_deny| *auto_deny)
|
|
.unwrap_or(false);
|
|
if auto_deny {
|
|
return Ok(ElicitationResponse {
|
|
action: ElicitationAction::Decline,
|
|
content: None,
|
|
meta: None,
|
|
});
|
|
}
|
|
|
|
let approval_policy = approval_policy
|
|
.lock()
|
|
.map(|policy| *policy)
|
|
.unwrap_or(AskForApproval::Never);
|
|
let permission_profile = permission_profile
|
|
.lock()
|
|
.map(|profile| profile.clone())
|
|
.unwrap_or_default();
|
|
if mcp_permission_prompt_is_auto_approved(
|
|
approval_policy,
|
|
&permission_profile,
|
|
McpPermissionPromptAutoApproveContext::default(),
|
|
) && 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,
|
|
meta: None,
|
|
});
|
|
}
|
|
|
|
if let Some(reviewer) = reviewer.as_ref() {
|
|
let request = ElicitationReviewRequest {
|
|
server_name: server_name.clone(),
|
|
request_id: id.clone(),
|
|
elicitation: elicitation.clone(),
|
|
};
|
|
if let Some(response) = reviewer.review(request).await? {
|
|
return Ok(response);
|
|
}
|
|
}
|
|
|
|
let Some(tx_event) = tx_event else {
|
|
return Ok(ElicitationResponse {
|
|
action: ElicitationAction::Decline,
|
|
content: None,
|
|
meta: None,
|
|
});
|
|
};
|
|
|
|
let public_request_id = format!(
|
|
"codex-mcp-elicitation-{}",
|
|
NEXT_ELICITATION_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
|
|
);
|
|
let routed_request_id = RequestId::String(public_request_id.clone().into());
|
|
let request = match elicitation {
|
|
Elicitation::Mcp(
|
|
rmcp::model::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")?,
|
|
},
|
|
Elicitation::Mcp(
|
|
rmcp::model::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,
|
|
},
|
|
Elicitation::OpenAiForm {
|
|
meta,
|
|
message,
|
|
requested_schema,
|
|
} => ElicitationRequest::OpenAiForm {
|
|
meta,
|
|
message,
|
|
requested_schema,
|
|
},
|
|
};
|
|
let (tx, rx) = oneshot::channel();
|
|
let _active_elicitation = lifecycle.as_ref().map(ElicitationLifecycle::start);
|
|
{
|
|
let mut lock = router.requests.lock().await;
|
|
lock.insert((server_name.clone(), routed_request_id), tx);
|
|
}
|
|
let _ = tx_event
|
|
.send(Event {
|
|
id: "mcp_elicitation_request".to_string(),
|
|
msg: EventMsg::ElicitationRequest(ElicitationRequestEvent {
|
|
turn_id: None,
|
|
server_name,
|
|
id: ProtocolRequestId::String(public_request_id),
|
|
request,
|
|
}),
|
|
})
|
|
.await;
|
|
rx.await
|
|
.context("elicitation request channel closed unexpectedly")
|
|
}
|
|
.boxed()
|
|
})
|
|
}
|
|
}
|
|
|
|
pub(crate) fn elicitation_is_rejected_by_policy(approval_policy: AskForApproval) -> bool {
|
|
match approval_policy {
|
|
AskForApproval::Never => true,
|
|
AskForApproval::OnRequest => false,
|
|
AskForApproval::UnlessTrusted => false,
|
|
AskForApproval::Granular(granular_config) => !granular_config.allows_mcp_elicitations(),
|
|
}
|
|
}
|
|
|
|
type ResponderMap = HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>;
|
|
|
|
fn can_auto_accept_elicitation(elicitation: &Elicitation) -> bool {
|
|
match elicitation {
|
|
Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
|
|
requested_schema,
|
|
..
|
|
}) => {
|
|
// Auto-accept confirm/approval elicitations without schema requirements.
|
|
requested_schema.properties.is_empty()
|
|
}
|
|
Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::UrlElicitationParams {
|
|
..
|
|
})
|
|
| Elicitation::OpenAiForm { .. } => false,
|
|
}
|
|
}
|