mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
41b4fabbb4
## Summary We originally addressed startup prewarming holding the read side of `RwLock<McpConnectionManager>` by snapshotting tool-list state. Review feedback identified the broader ownership problem: the outer synchronization should only publish or retrieve the current manager, while MCP operations rely on the manager's internal synchronization. A follow-up preserved operation retirement with a separate gate, but further review questioned whether that synchronization was actually required and whether we could support latest-wins replacement instead. This PR now stores the current MCP manager in `ArcSwap`. Each operation uses `load_full()` to obtain an owned `Arc<McpConnectionManager>`, then performs MCP I/O without retaining the publication mechanism. Refresh cancels obsolete startup work, constructs a replacement, and atomically publishes it. New operations see the latest manager, while operations that already loaded the previous manager retain a valid handle. Refresh happens at a turn boundary, so there should be no active user tool calls to drain. Git history supports dropping the outer `RwLock`. It was introduced in `03ffe4d595` on November 17, 2025 for non-blocking MCP startup: the session published an empty manager, startup initialized that same object while holding the write lock, and readers waited for initialization. `7cd2e84026` on February 19, 2026 removed that two-phase initialization in favor of constructing a fresh manager and swapping it in, explicitly noting that `Option` or `OnceCell` could replace the placeholder design. Hot reload later reused the existing lock to publish a replacement, but I found no indication that the lock was introduced to guarantee in-flight tool calls finish before refresh or shutdown. Terminal shutdown remains separate from refresh: it aborts startup prewarming and active tasks before shutting down the current manager, so tool calls may be interrupted and no model WebSocket work continues after shutdown. Focused regression coverage exercises pending tool-list cancellation, deferred refresh, and startup-prewarm shutdown.
680 lines
24 KiB
Rust
680 lines
24 KiB
Rust
use super::*;
|
|
use codex_mcp::ElicitationReviewRequest;
|
|
use codex_mcp::ElicitationReviewer;
|
|
use codex_mcp::ElicitationReviewerHandle;
|
|
use codex_protocol::config_types::ApprovalsReviewer;
|
|
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as MCP_ELICITATION_APPROVAL_KIND_KEY;
|
|
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as MCP_ELICITATION_APPROVAL_KIND_MCP_TOOL_CALL;
|
|
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_TOOL_SUGGESTION as MCP_ELICITATION_APPROVAL_KIND_TOOL_SUGGESTION;
|
|
use codex_protocol::mcp_approval_meta::APPROVALS_REVIEWER_KEY as MCP_ELICITATION_APPROVALS_REVIEWER_KEY;
|
|
use codex_protocol::mcp_approval_meta::CONNECTOR_DESCRIPTION_KEY as MCP_ELICITATION_CONNECTOR_DESCRIPTION_KEY;
|
|
use codex_protocol::mcp_approval_meta::CONNECTOR_ID_KEY as MCP_ELICITATION_CONNECTOR_ID_KEY;
|
|
use codex_protocol::mcp_approval_meta::CONNECTOR_NAME_KEY as MCP_ELICITATION_CONNECTOR_NAME_KEY;
|
|
use codex_protocol::mcp_approval_meta::REQUEST_TYPE_APPROVAL_REQUEST as MCP_ELICITATION_REQUEST_TYPE_APPROVAL_REQUEST;
|
|
use codex_protocol::mcp_approval_meta::REQUEST_TYPE_KEY as MCP_ELICITATION_REQUEST_TYPE_KEY;
|
|
use codex_protocol::mcp_approval_meta::TOOL_DESCRIPTION_KEY as MCP_ELICITATION_TOOL_DESCRIPTION_KEY;
|
|
use codex_protocol::mcp_approval_meta::TOOL_NAME_KEY as MCP_ELICITATION_TOOL_NAME_KEY;
|
|
use codex_protocol::mcp_approval_meta::TOOL_PARAMS_KEY as MCP_ELICITATION_TOOL_PARAMS_KEY;
|
|
use codex_protocol::mcp_approval_meta::TOOL_TITLE_KEY as MCP_ELICITATION_TOOL_TITLE_KEY;
|
|
use rmcp::model::CreateElicitationRequestParams;
|
|
use rmcp::model::ElicitationAction;
|
|
use rmcp::model::Meta;
|
|
use serde_json::Map;
|
|
|
|
const MCP_ELICITATION_DECLINE_MESSAGE_KEY: &str = "message";
|
|
const TOOL_SUGGESTION_ACTION_INSTALL: &str = "install";
|
|
const TOOL_SUGGESTION_ACTION_KEY: &str = "suggest_type";
|
|
const TOOL_SUGGESTION_TOOL_ID_KEY: &str = "tool_id";
|
|
const TOOL_SUGGESTION_TOOL_TYPE_KEY: &str = "tool_type";
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
enum GuardianElicitationReview {
|
|
NotRequested,
|
|
Decline(&'static str),
|
|
ApprovalRequest(Box<crate::guardian::GuardianApprovalRequest>),
|
|
}
|
|
|
|
struct GuardianMcpElicitationReviewer {
|
|
session: std::sync::Weak<Session>,
|
|
}
|
|
|
|
pub(crate) struct McpServerElicitationOutcome {
|
|
pub(crate) response: Option<ElicitationResponse>,
|
|
pub(crate) sent: bool,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
struct PluginInstallElicitationTelemetryMetadata {
|
|
tool_type: String,
|
|
tool_id: String,
|
|
tool_name: String,
|
|
}
|
|
|
|
impl GuardianMcpElicitationReviewer {
|
|
fn new(session: &Arc<Session>) -> Self {
|
|
Self {
|
|
session: Arc::downgrade(session),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ElicitationReviewer for GuardianMcpElicitationReviewer {
|
|
fn review(
|
|
&self,
|
|
request: ElicitationReviewRequest,
|
|
) -> BoxFuture<'static, anyhow::Result<Option<ElicitationResponse>>> {
|
|
let session = self.session.clone();
|
|
Box::pin(async move {
|
|
let Some(session) = session.upgrade() else {
|
|
return Ok(None);
|
|
};
|
|
review_guardian_mcp_elicitation(session, request).await
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Session {
|
|
pub(crate) fn mcp_elicitation_reviewer(self: &Arc<Self>) -> ElicitationReviewerHandle {
|
|
Arc::new(GuardianMcpElicitationReviewer::new(self))
|
|
}
|
|
|
|
#[expect(
|
|
clippy::await_holding_invalid_type,
|
|
reason = "active turn checks and turn state updates must remain atomic"
|
|
)]
|
|
pub async fn request_mcp_server_elicitation(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
request_id: RequestId,
|
|
params: McpServerElicitationRequestParams,
|
|
) -> McpServerElicitationOutcome {
|
|
if self
|
|
.services
|
|
.mcp_connection_manager
|
|
.load_full()
|
|
.elicitations_auto_deny()
|
|
{
|
|
return McpServerElicitationOutcome {
|
|
response: Some(ElicitationResponse {
|
|
action: codex_rmcp_client::ElicitationAction::Accept,
|
|
content: Some(serde_json::json!({})),
|
|
meta: None,
|
|
}),
|
|
sent: false,
|
|
};
|
|
}
|
|
|
|
let server_name = params.server_name.clone();
|
|
let request = match params.request {
|
|
McpServerElicitationRequest::Form {
|
|
meta,
|
|
message,
|
|
requested_schema,
|
|
} => {
|
|
let requested_schema = match serde_json::to_value(requested_schema) {
|
|
Ok(requested_schema) => requested_schema,
|
|
Err(err) => {
|
|
warn!(
|
|
"failed to serialize MCP elicitation schema for server_name: {server_name}, request_id: {request_id}: {err:#}"
|
|
);
|
|
return McpServerElicitationOutcome {
|
|
response: None,
|
|
sent: false,
|
|
};
|
|
}
|
|
};
|
|
codex_protocol::approvals::ElicitationRequest::Form {
|
|
meta,
|
|
message,
|
|
requested_schema,
|
|
}
|
|
}
|
|
McpServerElicitationRequest::Url {
|
|
meta,
|
|
message,
|
|
url,
|
|
elicitation_id,
|
|
} => codex_protocol::approvals::ElicitationRequest::Url {
|
|
meta,
|
|
message,
|
|
url,
|
|
elicitation_id,
|
|
},
|
|
};
|
|
|
|
let (tx_response, rx_response) = oneshot::channel();
|
|
let prev_entry = {
|
|
let mut active = self.active_turn.lock().await;
|
|
match active.as_mut() {
|
|
Some(at) => {
|
|
let mut ts = at.turn_state.lock().await;
|
|
ts.insert_pending_elicitation(
|
|
server_name.clone(),
|
|
request_id.clone(),
|
|
tx_response,
|
|
)
|
|
}
|
|
None => None,
|
|
}
|
|
};
|
|
if prev_entry.is_some() {
|
|
warn!(
|
|
"Overwriting existing pending elicitation for server_name: {server_name}, request_id: {request_id}"
|
|
);
|
|
}
|
|
let id = match request_id {
|
|
rmcp::model::NumberOrString::String(value) => {
|
|
codex_protocol::mcp::RequestId::String(value.to_string())
|
|
}
|
|
rmcp::model::NumberOrString::Number(value) => {
|
|
codex_protocol::mcp::RequestId::Integer(value)
|
|
}
|
|
};
|
|
let event = EventMsg::ElicitationRequest(ElicitationRequestEvent {
|
|
turn_id: params.turn_id,
|
|
server_name,
|
|
id,
|
|
request,
|
|
});
|
|
let plugin_install_telemetry = plugin_install_elicitation_telemetry_metadata(&event);
|
|
turn_context
|
|
.turn_metadata_state
|
|
.mark_user_input_requested_during_turn();
|
|
self.send_event(turn_context, event).await;
|
|
if let Some(plugin_install_telemetry) = plugin_install_telemetry {
|
|
turn_context
|
|
.session_telemetry
|
|
.record_plugin_install_elicitation_sent(
|
|
plugin_install_telemetry.tool_type.as_str(),
|
|
plugin_install_telemetry.tool_id.as_str(),
|
|
plugin_install_telemetry.tool_name.as_str(),
|
|
);
|
|
}
|
|
McpServerElicitationOutcome {
|
|
response: rx_response.await.ok(),
|
|
sent: true,
|
|
}
|
|
}
|
|
|
|
#[expect(
|
|
clippy::await_holding_invalid_type,
|
|
reason = "active turn checks and manager fallback must stay serialized"
|
|
)]
|
|
pub async fn resolve_elicitation(
|
|
&self,
|
|
server_name: String,
|
|
id: RequestId,
|
|
response: ElicitationResponse,
|
|
) -> anyhow::Result<()> {
|
|
let entry = {
|
|
let mut active = self.active_turn.lock().await;
|
|
match active.as_mut() {
|
|
Some(at) => {
|
|
let mut ts = at.turn_state.lock().await;
|
|
ts.remove_pending_elicitation(&server_name, &id)
|
|
}
|
|
None => None,
|
|
}
|
|
};
|
|
if let Some(tx_response) = entry {
|
|
tx_response
|
|
.send(response)
|
|
.map_err(|e| anyhow::anyhow!("failed to send elicitation response: {e:?}"))?;
|
|
return Ok(());
|
|
}
|
|
|
|
self.services
|
|
.mcp_connection_manager
|
|
.load_full()
|
|
.resolve_elicitation(server_name, id, response)
|
|
.await
|
|
}
|
|
|
|
pub async fn list_resources(
|
|
&self,
|
|
server: &str,
|
|
params: Option<PaginatedRequestParams>,
|
|
) -> anyhow::Result<ListResourcesResult> {
|
|
self.services
|
|
.mcp_connection_manager
|
|
.load_full()
|
|
.list_resources(server, params)
|
|
.await
|
|
}
|
|
|
|
pub async fn list_resource_templates(
|
|
&self,
|
|
server: &str,
|
|
params: Option<PaginatedRequestParams>,
|
|
) -> anyhow::Result<ListResourceTemplatesResult> {
|
|
self.services
|
|
.mcp_connection_manager
|
|
.load_full()
|
|
.list_resource_templates(server, params)
|
|
.await
|
|
}
|
|
|
|
pub async fn read_resource(
|
|
&self,
|
|
server: &str,
|
|
params: ReadResourceRequestParams,
|
|
) -> anyhow::Result<ReadResourceResult> {
|
|
self.services
|
|
.mcp_connection_manager
|
|
.load_full()
|
|
.read_resource(server, params)
|
|
.await
|
|
}
|
|
|
|
pub async fn call_tool(
|
|
&self,
|
|
server: &str,
|
|
tool: &str,
|
|
arguments: Option<serde_json::Value>,
|
|
meta: Option<serde_json::Value>,
|
|
) -> anyhow::Result<CallToolResult> {
|
|
self.services
|
|
.mcp_connection_manager
|
|
.load_full()
|
|
.call_tool(server, tool, arguments, meta)
|
|
.await
|
|
}
|
|
|
|
async fn refresh_mcp_servers_inner(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
mcp_servers: HashMap<String, McpServerConfig>,
|
|
store_mode: OAuthCredentialsStoreMode,
|
|
elicitation_reviewer: Option<ElicitationReviewerHandle>,
|
|
) {
|
|
let auth = self.services.auth_manager.auth().await;
|
|
let config = self.get_config().await;
|
|
let mcp_config = self
|
|
.services
|
|
.mcp_manager
|
|
.runtime_config(config.as_ref())
|
|
.await;
|
|
let tool_plugin_provenance = self
|
|
.services
|
|
.mcp_manager
|
|
.tool_plugin_provenance(config.as_ref())
|
|
.await;
|
|
let mcp_servers =
|
|
effective_mcp_servers_from_configured(mcp_servers, &mcp_config, auth.as_ref());
|
|
let host_owned_codex_apps_enabled =
|
|
host_owned_codex_apps_enabled(&mcp_config, auth.as_ref());
|
|
let auth_statuses =
|
|
compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await;
|
|
let mcp_runtime_context = match turn_context.environments.primary() {
|
|
Some(turn_environment) => McpRuntimeContext::new(
|
|
Arc::clone(&self.services.environment_manager),
|
|
turn_environment.cwd.to_path_buf(),
|
|
),
|
|
None => McpRuntimeContext::new(
|
|
Arc::clone(&self.services.environment_manager),
|
|
#[allow(deprecated)]
|
|
turn_context.cwd.to_path_buf(),
|
|
),
|
|
};
|
|
let mcp_startup_cancellation_token = {
|
|
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
|
|
guard.cancel();
|
|
let cancellation_token = CancellationToken::new();
|
|
*guard = cancellation_token.clone();
|
|
cancellation_token
|
|
};
|
|
let refreshed_manager = McpConnectionManager::new(
|
|
&mcp_servers,
|
|
store_mode,
|
|
auth_statuses,
|
|
&turn_context.approval_policy,
|
|
turn_context.sub_id.clone(),
|
|
self.get_tx_event(),
|
|
mcp_startup_cancellation_token,
|
|
turn_context.permission_profile(),
|
|
mcp_runtime_context,
|
|
config.codex_home.to_path_buf(),
|
|
codex_apps_tools_cache_key(auth.as_ref()),
|
|
host_owned_codex_apps_enabled,
|
|
mcp_config.prefix_mcp_tool_names,
|
|
mcp_config.client_elicitation_capability,
|
|
tool_plugin_provenance,
|
|
auth.as_ref(),
|
|
elicitation_reviewer,
|
|
)
|
|
.await;
|
|
{
|
|
let current_manager = self.services.mcp_connection_manager.load_full();
|
|
refreshed_manager.set_elicitations_auto_deny(current_manager.elicitations_auto_deny());
|
|
}
|
|
self.services
|
|
.mcp_connection_manager
|
|
.store(Arc::new(refreshed_manager));
|
|
}
|
|
|
|
pub(crate) async fn refresh_mcp_servers_if_requested(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
elicitation_reviewer: Option<ElicitationReviewerHandle>,
|
|
) {
|
|
let refresh_config = { self.pending_mcp_server_refresh_config.lock().await.take() };
|
|
let Some(refresh_config) = refresh_config else {
|
|
return;
|
|
};
|
|
|
|
let McpServerRefreshConfig {
|
|
mcp_servers,
|
|
mcp_oauth_credentials_store_mode,
|
|
} = refresh_config;
|
|
|
|
let mcp_servers =
|
|
match serde_json::from_value::<HashMap<String, McpServerConfig>>(mcp_servers) {
|
|
Ok(servers) => servers,
|
|
Err(err) => {
|
|
warn!("failed to parse MCP server refresh config: {err}");
|
|
return;
|
|
}
|
|
};
|
|
let store_mode = match serde_json::from_value::<OAuthCredentialsStoreMode>(
|
|
mcp_oauth_credentials_store_mode,
|
|
) {
|
|
Ok(mode) => mode,
|
|
Err(err) => {
|
|
warn!("failed to parse MCP OAuth refresh config: {err}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
self.refresh_mcp_servers_inner(turn_context, mcp_servers, store_mode, elicitation_reviewer)
|
|
.await;
|
|
}
|
|
|
|
pub(crate) async fn refresh_mcp_servers_now(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
mcp_servers: HashMap<String, McpServerConfig>,
|
|
store_mode: OAuthCredentialsStoreMode,
|
|
elicitation_reviewer: Option<ElicitationReviewerHandle>,
|
|
) {
|
|
self.refresh_mcp_servers_inner(turn_context, mcp_servers, store_mode, elicitation_reviewer)
|
|
.await;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn mcp_startup_cancellation_token(&self) -> CancellationToken {
|
|
self.services
|
|
.mcp_startup_cancellation_token
|
|
.lock()
|
|
.await
|
|
.clone()
|
|
}
|
|
|
|
pub(crate) async fn cancel_mcp_startup(&self) {
|
|
self.services
|
|
.mcp_startup_cancellation_token
|
|
.lock()
|
|
.await
|
|
.cancel();
|
|
}
|
|
}
|
|
|
|
async fn review_guardian_mcp_elicitation(
|
|
session: Arc<Session>,
|
|
request: ElicitationReviewRequest,
|
|
) -> anyhow::Result<Option<ElicitationResponse>> {
|
|
let Some((turn_context, _cancellation_token)) =
|
|
session.active_turn_context_and_cancellation_token().await
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let approvals_reviewer = crate::connectors::mcp_approvals_reviewer(
|
|
turn_context.config.as_ref(),
|
|
request.server_name.as_str(),
|
|
elicitation_connector_id(&request.elicitation),
|
|
);
|
|
if !crate::guardian::routes_approval_to_guardian_with_reviewer(
|
|
turn_context.as_ref(),
|
|
approvals_reviewer,
|
|
) {
|
|
return Ok(None);
|
|
}
|
|
|
|
let guardian_request = match guardian_elicitation_review_request(&request) {
|
|
GuardianElicitationReview::NotRequested => return Ok(None),
|
|
GuardianElicitationReview::Decline(reason) => {
|
|
warn!(
|
|
server_name = %request.server_name,
|
|
request_id = %mcp_elicitation_request_id(&request.request_id),
|
|
reason,
|
|
"declining Guardian MCP elicitation before review"
|
|
);
|
|
return Ok(Some(mcp_elicitation_decline_without_message()));
|
|
}
|
|
GuardianElicitationReview::ApprovalRequest(guardian_request) => *guardian_request,
|
|
};
|
|
|
|
let review_id = crate::guardian::new_guardian_review_id();
|
|
let decision = crate::guardian::review_approval_request(
|
|
&session,
|
|
&turn_context,
|
|
review_id.clone(),
|
|
guardian_request,
|
|
/*retry_reason*/ None,
|
|
)
|
|
.await;
|
|
Ok(Some(
|
|
mcp_elicitation_response_from_guardian_decision(session.as_ref(), &review_id, decision)
|
|
.await,
|
|
))
|
|
}
|
|
|
|
fn guardian_elicitation_review_request(
|
|
request: &ElicitationReviewRequest,
|
|
) -> GuardianElicitationReview {
|
|
let (meta, requested_schema) = match &request.elicitation {
|
|
CreateElicitationRequestParams::FormElicitationParams {
|
|
meta,
|
|
requested_schema,
|
|
..
|
|
} => (meta, Some(requested_schema)),
|
|
CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => {
|
|
return if meta_requests_approval_request(meta) {
|
|
GuardianElicitationReview::Decline(
|
|
"guardian MCP elicitation review only supports form elicitations",
|
|
)
|
|
} else {
|
|
GuardianElicitationReview::NotRequested
|
|
};
|
|
}
|
|
};
|
|
|
|
let Some(meta) = meta.as_ref().map(|meta| &meta.0) else {
|
|
return GuardianElicitationReview::NotRequested;
|
|
};
|
|
if metadata_str(meta, MCP_ELICITATION_REQUEST_TYPE_KEY)
|
|
!= Some(MCP_ELICITATION_REQUEST_TYPE_APPROVAL_REQUEST)
|
|
{
|
|
return GuardianElicitationReview::NotRequested;
|
|
}
|
|
if metadata_str(meta, MCP_ELICITATION_APPROVAL_KIND_KEY)
|
|
!= Some(MCP_ELICITATION_APPROVAL_KIND_MCP_TOOL_CALL)
|
|
{
|
|
return GuardianElicitationReview::Decline(
|
|
"guardian MCP elicitation metadata must declare mcp_tool_call approval kind",
|
|
);
|
|
}
|
|
if requested_schema.is_some_and(|schema| !schema.properties.is_empty()) {
|
|
return GuardianElicitationReview::Decline(
|
|
"guardian MCP elicitation review only supports empty form schemas",
|
|
);
|
|
}
|
|
|
|
let Some(tool_name) = metadata_owned_string(meta, MCP_ELICITATION_TOOL_NAME_KEY) else {
|
|
return GuardianElicitationReview::Decline(
|
|
"guardian MCP elicitation metadata must include a non-empty tool_name",
|
|
);
|
|
};
|
|
let arguments = match meta.get(MCP_ELICITATION_TOOL_PARAMS_KEY) {
|
|
Some(value @ Value::Object(_)) => Some(value.clone()),
|
|
Some(_) => {
|
|
return GuardianElicitationReview::Decline(
|
|
"guardian MCP elicitation tool_params must be an object",
|
|
);
|
|
}
|
|
None => Some(Value::Object(Map::new())),
|
|
};
|
|
|
|
GuardianElicitationReview::ApprovalRequest(Box::new(
|
|
crate::guardian::GuardianApprovalRequest::McpToolCall {
|
|
id: format!(
|
|
"mcp_elicitation:{}:{}",
|
|
request.server_name,
|
|
mcp_elicitation_request_id(&request.request_id)
|
|
),
|
|
server: request.server_name.clone(),
|
|
tool_name,
|
|
arguments,
|
|
connector_id: metadata_owned_string(meta, MCP_ELICITATION_CONNECTOR_ID_KEY),
|
|
connector_name: metadata_owned_string(meta, MCP_ELICITATION_CONNECTOR_NAME_KEY),
|
|
connector_description: metadata_owned_string(
|
|
meta,
|
|
MCP_ELICITATION_CONNECTOR_DESCRIPTION_KEY,
|
|
),
|
|
tool_title: metadata_owned_string(meta, MCP_ELICITATION_TOOL_TITLE_KEY),
|
|
tool_description: metadata_owned_string(meta, MCP_ELICITATION_TOOL_DESCRIPTION_KEY),
|
|
annotations: None,
|
|
},
|
|
))
|
|
}
|
|
|
|
fn elicitation_connector_id(elicitation: &CreateElicitationRequestParams) -> Option<&str> {
|
|
match elicitation {
|
|
CreateElicitationRequestParams::FormElicitationParams { meta, .. }
|
|
| CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => meta
|
|
.as_ref()
|
|
.and_then(|meta| metadata_str(&meta.0, MCP_ELICITATION_CONNECTOR_ID_KEY)),
|
|
}
|
|
}
|
|
|
|
fn meta_requests_approval_request(meta: &Option<Meta>) -> bool {
|
|
meta.as_ref()
|
|
.and_then(|meta| metadata_str(&meta.0, MCP_ELICITATION_REQUEST_TYPE_KEY))
|
|
== Some(MCP_ELICITATION_REQUEST_TYPE_APPROVAL_REQUEST)
|
|
}
|
|
|
|
fn metadata_str<'a>(meta: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
|
|
meta.get(key).and_then(Value::as_str)
|
|
}
|
|
|
|
fn metadata_owned_string(meta: &Map<String, Value>, key: &str) -> Option<String> {
|
|
metadata_str(meta, key)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
fn plugin_install_elicitation_telemetry_metadata(
|
|
event: &EventMsg,
|
|
) -> Option<PluginInstallElicitationTelemetryMetadata> {
|
|
let EventMsg::ElicitationRequest(ElicitationRequestEvent { request, .. }) = event else {
|
|
return None;
|
|
};
|
|
let codex_protocol::approvals::ElicitationRequest::Form {
|
|
meta: Some(Value::Object(meta)),
|
|
..
|
|
} = request
|
|
else {
|
|
return None;
|
|
};
|
|
if metadata_str(meta, MCP_ELICITATION_APPROVAL_KIND_KEY)
|
|
!= Some(MCP_ELICITATION_APPROVAL_KIND_TOOL_SUGGESTION)
|
|
|| metadata_str(meta, TOOL_SUGGESTION_ACTION_KEY) != Some(TOOL_SUGGESTION_ACTION_INSTALL)
|
|
{
|
|
return None;
|
|
}
|
|
|
|
Some(PluginInstallElicitationTelemetryMetadata {
|
|
tool_type: metadata_owned_string(meta, TOOL_SUGGESTION_TOOL_TYPE_KEY)?,
|
|
tool_id: metadata_owned_string(meta, TOOL_SUGGESTION_TOOL_ID_KEY)?,
|
|
tool_name: metadata_owned_string(meta, MCP_ELICITATION_TOOL_NAME_KEY)?,
|
|
})
|
|
}
|
|
|
|
fn mcp_elicitation_request_id(id: &RequestId) -> String {
|
|
match id {
|
|
rmcp::model::NumberOrString::String(value) => value.to_string(),
|
|
rmcp::model::NumberOrString::Number(value) => value.to_string(),
|
|
}
|
|
}
|
|
|
|
async fn mcp_elicitation_response_from_guardian_decision(
|
|
session: &Session,
|
|
review_id: &str,
|
|
decision: ReviewDecision,
|
|
) -> ElicitationResponse {
|
|
let denial_message = match decision {
|
|
ReviewDecision::Denied => {
|
|
Some(crate::guardian::guardian_rejection_message(session, review_id).await)
|
|
}
|
|
_ => None,
|
|
};
|
|
mcp_elicitation_response_from_guardian_decision_parts(decision, denial_message)
|
|
}
|
|
|
|
fn mcp_elicitation_response_from_guardian_decision_parts(
|
|
decision: ReviewDecision,
|
|
denial_message: Option<String>,
|
|
) -> ElicitationResponse {
|
|
match decision {
|
|
ReviewDecision::Approved
|
|
| ReviewDecision::ApprovedForSession
|
|
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
|
| ReviewDecision::NetworkPolicyAmendment { .. } => ElicitationResponse {
|
|
action: ElicitationAction::Accept,
|
|
content: Some(serde_json::json!({})),
|
|
meta: Some(mcp_elicitation_auto_meta()),
|
|
},
|
|
ReviewDecision::Denied => mcp_elicitation_decline_with_message(
|
|
denial_message.unwrap_or_else(|| "Guardian denied this request.".to_string()),
|
|
),
|
|
ReviewDecision::TimedOut => {
|
|
mcp_elicitation_decline_with_message(crate::guardian::guardian_timeout_message())
|
|
}
|
|
ReviewDecision::Abort => ElicitationResponse {
|
|
action: ElicitationAction::Cancel,
|
|
content: None,
|
|
meta: Some(mcp_elicitation_auto_meta()),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn mcp_elicitation_decline_with_message(message: String) -> ElicitationResponse {
|
|
ElicitationResponse {
|
|
action: ElicitationAction::Decline,
|
|
content: None,
|
|
meta: Some(serde_json::json!({
|
|
MCP_ELICITATION_DECLINE_MESSAGE_KEY: message,
|
|
MCP_ELICITATION_APPROVALS_REVIEWER_KEY: ApprovalsReviewer::AutoReview,
|
|
})),
|
|
}
|
|
}
|
|
|
|
fn mcp_elicitation_decline_without_message() -> ElicitationResponse {
|
|
ElicitationResponse {
|
|
action: ElicitationAction::Decline,
|
|
content: None,
|
|
meta: Some(mcp_elicitation_auto_meta()),
|
|
}
|
|
}
|
|
|
|
fn mcp_elicitation_auto_meta() -> serde_json::Value {
|
|
serde_json::json!({
|
|
MCP_ELICITATION_APPROVALS_REVIEWER_KEY: ApprovalsReviewer::AutoReview,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "mcp_tests.rs"]
|
|
mod tests;
|