mirror of
https://github.com/openai/codex.git
synced 2026-05-26 05:55:36 +00:00
## Summary - Add `list_available_plugins_to_install` as the inventory step for plugin and connector install suggestions. - Slim `request_plugin_install` so it only handles the actual elicitation, instead of carrying the full discoverable list in its prompt. - Emit send-time telemetry when an install elicitation is dispatched, including requested tool identity in the event payload. - Emit install-result telemetry through `SessionTelemetry`, including tool type, user response action, and completion status. - Update registration and tests to cover the new two-step flow while keeping the existing `tool_suggest` feature gate unchanged. ## Testing - `just fmt` - `cargo test -p codex-tools` - `cargo test -p codex-core request_plugin_install` - `cargo test -p codex-core list_available_plugins_to_install` - `cargo test -p codex-core install_suggestion_tools_can_be_registered_without_search_tool` - `cargo test -p codex-otel manager_records_plugin_install_suggestion_metric` - `cargo test -p codex-otel manager_records_plugin_install_elicitation_sent_metric` - `just fix -p codex-core` - `just fix -p codex-tools` - `just fix -p codex-otel` - `cargo check -p codex-core`
693 lines
24 KiB
Rust
693 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
|
|
.read()
|
|
.await
|
|
.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
|
|
.read()
|
|
.await
|
|
.resolve_elicitation(server_name, id, response)
|
|
.await
|
|
}
|
|
|
|
#[expect(
|
|
clippy::await_holding_invalid_type,
|
|
reason = "MCP resource calls are serialized through the session-owned manager guard"
|
|
)]
|
|
pub async fn list_resources(
|
|
&self,
|
|
server: &str,
|
|
params: Option<PaginatedRequestParams>,
|
|
) -> anyhow::Result<ListResourcesResult> {
|
|
self.services
|
|
.mcp_connection_manager
|
|
.read()
|
|
.await
|
|
.list_resources(server, params)
|
|
.await
|
|
}
|
|
|
|
#[expect(
|
|
clippy::await_holding_invalid_type,
|
|
reason = "MCP resource calls are serialized through the session-owned manager guard"
|
|
)]
|
|
pub async fn list_resource_templates(
|
|
&self,
|
|
server: &str,
|
|
params: Option<PaginatedRequestParams>,
|
|
) -> anyhow::Result<ListResourceTemplatesResult> {
|
|
self.services
|
|
.mcp_connection_manager
|
|
.read()
|
|
.await
|
|
.list_resource_templates(server, params)
|
|
.await
|
|
}
|
|
|
|
#[expect(
|
|
clippy::await_holding_invalid_type,
|
|
reason = "MCP resource calls are serialized through the session-owned manager guard"
|
|
)]
|
|
pub async fn read_resource(
|
|
&self,
|
|
server: &str,
|
|
params: ReadResourceRequestParams,
|
|
) -> anyhow::Result<ReadResourceResult> {
|
|
self.services
|
|
.mcp_connection_manager
|
|
.read()
|
|
.await
|
|
.read_resource(server, params)
|
|
.await
|
|
}
|
|
|
|
#[expect(
|
|
clippy::await_holding_invalid_type,
|
|
reason = "MCP tool calls are serialized through the session-owned manager guard"
|
|
)]
|
|
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
|
|
.read()
|
|
.await
|
|
.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 = config
|
|
.to_mcp_config(self.services.plugins_manager.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_environment = match turn_context.environments.primary() {
|
|
Some(turn_environment) => McpRuntimeEnvironment::new(
|
|
Some(Arc::clone(&turn_environment.environment)),
|
|
self.services.environment_manager.try_local_environment(),
|
|
turn_environment.cwd.to_path_buf(),
|
|
),
|
|
None => McpRuntimeEnvironment::new(
|
|
self.services
|
|
.environment_manager
|
|
.default_or_local_environment(),
|
|
self.services.environment_manager.try_local_environment(),
|
|
#[allow(deprecated)]
|
|
turn_context.cwd.to_path_buf(),
|
|
),
|
|
};
|
|
{
|
|
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
|
|
guard.cancel();
|
|
*guard = CancellationToken::new();
|
|
}
|
|
let (refreshed_manager, cancel_token) = McpConnectionManager::new(
|
|
&mcp_servers,
|
|
store_mode,
|
|
auth_statuses,
|
|
&turn_context.approval_policy,
|
|
turn_context.sub_id.clone(),
|
|
self.get_tx_event(),
|
|
turn_context.permission_profile(),
|
|
mcp_runtime_environment,
|
|
config.codex_home.to_path_buf(),
|
|
codex_apps_tools_cache_key(auth.as_ref()),
|
|
host_owned_codex_apps_enabled,
|
|
mcp_config.client_elicitation_capability,
|
|
tool_plugin_provenance,
|
|
auth.as_ref(),
|
|
elicitation_reviewer,
|
|
)
|
|
.await;
|
|
{
|
|
let current_manager = self.services.mcp_connection_manager.read().await;
|
|
refreshed_manager.set_elicitations_auto_deny(current_manager.elicitations_auto_deny());
|
|
}
|
|
{
|
|
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
|
|
if guard.is_cancelled() {
|
|
cancel_token.cancel();
|
|
}
|
|
*guard = cancel_token;
|
|
}
|
|
|
|
let mut old_manager = {
|
|
let mut manager = self.services.mcp_connection_manager.write().await;
|
|
std::mem::replace(&mut *manager, refreshed_manager)
|
|
};
|
|
old_manager.shutdown().await;
|
|
}
|
|
|
|
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);
|
|
};
|
|
|
|
if !crate::guardian::routes_approval_to_guardian(turn_context.as_ref()) {
|
|
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 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;
|