Skip to main content

codex_mcp/
elicitation.rs

1//! MCP elicitation request tracking and policy handling.
2//!
3//! RMCP clients call into this module when a server asks Codex to elicit data
4//! from the user. It decides whether the request can be automatically accepted,
5//! must be declined by policy, or should be surfaced as a Codex protocol event
6//! and later resolved through the stored responder.
7
8use std::collections::HashMap;
9use std::sync::Arc;
10use std::sync::Mutex as StdMutex;
11use std::sync::atomic::AtomicU64;
12use std::sync::atomic::Ordering;
13
14use crate::mcp::McpPermissionPromptAutoApproveContext;
15use crate::mcp::mcp_permission_prompt_is_auto_approved;
16use anyhow::Context;
17use anyhow::Result;
18use anyhow::anyhow;
19use async_channel::Sender;
20use codex_protocol::approvals::ElicitationRequest;
21use codex_protocol::approvals::ElicitationRequestEvent;
22use codex_protocol::mcp::RequestId as ProtocolRequestId;
23use codex_protocol::models::PermissionProfile;
24use codex_protocol::protocol::AskForApproval;
25use codex_protocol::protocol::Event;
26use codex_protocol::protocol::EventMsg;
27use codex_rmcp_client::Elicitation;
28use codex_rmcp_client::ElicitationResponse;
29use codex_rmcp_client::SendElicitation;
30use futures::future::BoxFuture;
31use futures::future::FutureExt;
32use rmcp::model::ElicitationAction;
33use rmcp::model::RequestId;
34use tokio::sync::Mutex;
35use tokio::sync::oneshot;
36
37static NEXT_ELICITATION_REQUEST_ID: AtomicU64 = AtomicU64::new(0);
38
39#[derive(Debug, Clone)]
40pub struct ElicitationReviewRequest {
41    pub server_name: String,
42    pub request_id: RequestId,
43    pub elicitation: Elicitation,
44}
45
46pub trait ElicitationReviewer: Send + Sync {
47    fn review(
48        &self,
49        request: ElicitationReviewRequest,
50    ) -> BoxFuture<'static, Result<Option<ElicitationResponse>>>;
51}
52
53pub type ElicitationReviewerHandle = Arc<dyn ElicitationReviewer>;
54
55/// Holds an owner-provided registration while an MCP elicitation is waiting for a response.
56#[derive(Clone)]
57pub struct ElicitationLifecycle {
58    register: Arc<dyn Fn() -> Box<dyn Send + Sync> + Send + Sync>,
59}
60
61impl ElicitationLifecycle {
62    pub fn new<T>(register: impl Fn() -> T + Send + Sync + 'static) -> Self
63    where
64        T: Send + Sync + 'static,
65    {
66        Self {
67            register: Arc::new(move || Box::new(register())),
68        }
69    }
70
71    fn start(&self) -> ActiveElicitation {
72        ActiveElicitation {
73            _registration: (self.register)(),
74        }
75    }
76}
77
78struct ActiveElicitation {
79    _registration: Box<dyn Send + Sync>,
80}
81
82/// Routes model-visible elicitation response tokens to their exact pending responders.
83///
84/// One router is shared by every MCP runtime created for a thread. The public response token is
85/// generated by Codex rather than copied from the MCP connection, so separate runtimes may reuse
86/// the same server request ID without colliding.
87#[derive(Clone, Default)]
88pub struct ElicitationRequestRouter {
89    requests: Arc<Mutex<ResponderMap>>,
90}
91
92impl ElicitationRequestRouter {
93    async fn resolve(
94        &self,
95        server_name: String,
96        id: RequestId,
97        response: ElicitationResponse,
98    ) -> Result<()> {
99        self.requests
100            .lock()
101            .await
102            .remove(&(server_name, id))
103            .ok_or_else(|| anyhow!("elicitation request not found"))?
104            .send(response)
105            .map_err(|e| anyhow!("failed to send elicitation response: {e:?}"))
106    }
107}
108
109#[derive(Clone)]
110pub(crate) struct ElicitationRequestManager {
111    router: ElicitationRequestRouter,
112    pub(crate) approval_policy: Arc<StdMutex<AskForApproval>>,
113    pub(crate) permission_profile: Arc<StdMutex<PermissionProfile>>,
114    auto_deny: Arc<StdMutex<bool>>,
115    reviewer: Option<ElicitationReviewerHandle>,
116    lifecycle: Option<ElicitationLifecycle>,
117}
118
119impl ElicitationRequestManager {
120    pub(crate) fn new(
121        approval_policy: AskForApproval,
122        permission_profile: PermissionProfile,
123        reviewer: Option<ElicitationReviewerHandle>,
124        lifecycle: Option<ElicitationLifecycle>,
125        router: ElicitationRequestRouter,
126    ) -> Self {
127        Self {
128            router,
129            approval_policy: Arc::new(StdMutex::new(approval_policy)),
130            permission_profile: Arc::new(StdMutex::new(permission_profile)),
131            auto_deny: Arc::new(StdMutex::new(false)),
132            reviewer,
133            lifecycle,
134        }
135    }
136
137    pub(crate) fn auto_deny(&self) -> bool {
138        self.auto_deny
139            .lock()
140            .map(|auto_deny| *auto_deny)
141            .unwrap_or(false)
142    }
143
144    pub(crate) fn set_auto_deny(&self, auto_deny: bool) {
145        if let Ok(mut current) = self.auto_deny.lock() {
146            *current = auto_deny;
147        }
148    }
149
150    pub(crate) async fn resolve(
151        &self,
152        server_name: String,
153        id: RequestId,
154        response: ElicitationResponse,
155    ) -> Result<()> {
156        self.router.resolve(server_name, id, response).await
157    }
158
159    pub(crate) fn router(&self) -> ElicitationRequestRouter {
160        self.router.clone()
161    }
162
163    pub(crate) fn make_sender(
164        &self,
165        server_name: String,
166        tx_event: Option<Sender<Event>>,
167    ) -> SendElicitation {
168        let router = self.router.clone();
169        let approval_policy = self.approval_policy.clone();
170        let permission_profile = self.permission_profile.clone();
171        let auto_deny = self.auto_deny.clone();
172        let reviewer = self.reviewer.clone();
173        let lifecycle = self.lifecycle.clone();
174        Box::new(move |id, elicitation| {
175            let router = router.clone();
176            let tx_event = tx_event.clone();
177            let server_name = server_name.clone();
178            let approval_policy = approval_policy.clone();
179            let permission_profile = permission_profile.clone();
180            let auto_deny = auto_deny.clone();
181            let reviewer = reviewer.clone();
182            let lifecycle = lifecycle.clone();
183            async move {
184                let auto_deny = auto_deny
185                    .lock()
186                    .map(|auto_deny| *auto_deny)
187                    .unwrap_or(false);
188                if auto_deny {
189                    return Ok(ElicitationResponse {
190                        action: ElicitationAction::Decline,
191                        content: None,
192                        meta: None,
193                    });
194                }
195
196                let approval_policy = approval_policy
197                    .lock()
198                    .map(|policy| *policy)
199                    .unwrap_or(AskForApproval::Never);
200                let permission_profile = permission_profile
201                    .lock()
202                    .map(|profile| profile.clone())
203                    .unwrap_or_default();
204                if mcp_permission_prompt_is_auto_approved(
205                    approval_policy,
206                    &permission_profile,
207                    McpPermissionPromptAutoApproveContext::default(),
208                ) && can_auto_accept_elicitation(&elicitation)
209                {
210                    return Ok(ElicitationResponse {
211                        action: ElicitationAction::Accept,
212                        content: Some(serde_json::json!({})),
213                        meta: None,
214                    });
215                }
216
217                if elicitation_is_rejected_by_policy(approval_policy) {
218                    return Ok(ElicitationResponse {
219                        action: ElicitationAction::Decline,
220                        content: None,
221                        meta: None,
222                    });
223                }
224
225                if let Some(reviewer) = reviewer.as_ref() {
226                    let request = ElicitationReviewRequest {
227                        server_name: server_name.clone(),
228                        request_id: id.clone(),
229                        elicitation: elicitation.clone(),
230                    };
231                    if let Some(response) = reviewer.review(request).await? {
232                        return Ok(response);
233                    }
234                }
235
236                let Some(tx_event) = tx_event else {
237                    return Ok(ElicitationResponse {
238                        action: ElicitationAction::Decline,
239                        content: None,
240                        meta: None,
241                    });
242                };
243
244                let public_request_id = format!(
245                    "codex-mcp-elicitation-{}",
246                    NEXT_ELICITATION_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
247                );
248                let routed_request_id = RequestId::String(public_request_id.clone().into());
249                let request = match elicitation {
250                    Elicitation::Mcp(
251                        rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
252                            meta,
253                            message,
254                            requested_schema,
255                        },
256                    ) => ElicitationRequest::Form {
257                        meta: meta
258                            .map(serde_json::to_value)
259                            .transpose()
260                            .context("failed to serialize MCP elicitation metadata")?,
261                        message,
262                        requested_schema: serde_json::to_value(requested_schema)
263                            .context("failed to serialize MCP elicitation schema")?,
264                    },
265                    Elicitation::Mcp(
266                        rmcp::model::CreateElicitationRequestParams::UrlElicitationParams {
267                            meta,
268                            message,
269                            url,
270                            elicitation_id,
271                        },
272                    ) => ElicitationRequest::Url {
273                        meta: meta
274                            .map(serde_json::to_value)
275                            .transpose()
276                            .context("failed to serialize MCP elicitation metadata")?,
277                        message,
278                        url,
279                        elicitation_id,
280                    },
281                    Elicitation::OpenAiForm {
282                        meta,
283                        message,
284                        requested_schema,
285                    } => ElicitationRequest::OpenAiForm {
286                        meta,
287                        message,
288                        requested_schema,
289                    },
290                };
291                let (tx, rx) = oneshot::channel();
292                let _active_elicitation = lifecycle.as_ref().map(ElicitationLifecycle::start);
293                {
294                    let mut lock = router.requests.lock().await;
295                    lock.insert((server_name.clone(), routed_request_id), tx);
296                }
297                let _ = tx_event
298                    .send(Event {
299                        id: "mcp_elicitation_request".to_string(),
300                        msg: EventMsg::ElicitationRequest(ElicitationRequestEvent {
301                            turn_id: None,
302                            server_name,
303                            id: ProtocolRequestId::String(public_request_id),
304                            request,
305                        }),
306                    })
307                    .await;
308                rx.await
309                    .context("elicitation request channel closed unexpectedly")
310            }
311            .boxed()
312        })
313    }
314}
315
316pub(crate) fn elicitation_is_rejected_by_policy(approval_policy: AskForApproval) -> bool {
317    match approval_policy {
318        AskForApproval::Never => true,
319        AskForApproval::OnRequest => false,
320        AskForApproval::UnlessTrusted => false,
321        AskForApproval::Granular(granular_config) => !granular_config.allows_mcp_elicitations(),
322    }
323}
324
325type ResponderMap = HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>;
326
327fn can_auto_accept_elicitation(elicitation: &Elicitation) -> bool {
328    match elicitation {
329        Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
330            requested_schema,
331            ..
332        }) => {
333            // Auto-accept confirm/approval elicitations without schema requirements.
334            requested_schema.properties.is_empty()
335        }
336        Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::UrlElicitationParams {
337            ..
338        })
339        | Elicitation::OpenAiForm { .. } => false,
340    }
341}