Split plugin install discovery into list and request tools (#23372)

## 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`
This commit is contained in:
Matthew Zeng
2026-05-19 14:45:37 -07:00
committed by GitHub
parent 1509ae6d8d
commit 8335b56c33
17 changed files with 667 additions and 158 deletions

View File

@@ -5,6 +5,7 @@ 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;
@@ -21,6 +22,10 @@ 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 {
@@ -33,6 +38,18 @@ 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 {
@@ -70,7 +87,7 @@ impl Session {
turn_context: &TurnContext,
request_id: RequestId,
params: McpServerElicitationRequestParams,
) -> Option<ElicitationResponse> {
) -> McpServerElicitationOutcome {
if self
.services
.mcp_connection_manager
@@ -78,11 +95,14 @@ impl Session {
.await
.elicitations_auto_deny()
{
return Some(ElicitationResponse {
action: codex_rmcp_client::ElicitationAction::Accept,
content: Some(serde_json::json!({})),
meta: None,
});
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();
@@ -98,7 +118,10 @@ impl Session {
warn!(
"failed to serialize MCP elicitation schema for server_name: {server_name}, request_id: {request_id}: {err:#}"
);
return None;
return McpServerElicitationOutcome {
response: None,
sent: false,
};
}
};
codex_protocol::approvals::ElicitationRequest::Form {
@@ -154,11 +177,24 @@ impl Session {
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;
rx_response.await.ok()
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(
@@ -551,6 +587,33 @@ fn metadata_owned_string(meta: &Map<String, Value>, key: &str) -> Option<String>
.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(),

View File

@@ -96,6 +96,63 @@ fn guardian_elicitation_review_request_defaults_missing_tool_params() {
assert_eq!(arguments, Some(json!({})));
}
#[test]
fn plugin_install_elicitation_telemetry_metadata_requires_install_tool_suggestion() {
let event = EventMsg::ElicitationRequest(ElicitationRequestEvent {
turn_id: Some("turn-1".to_string()),
server_name: "codex_apps".to_string(),
id: codex_protocol::mcp::RequestId::String("request-1".to_string()),
request: codex_protocol::approvals::ElicitationRequest::Form {
meta: Some(json!({
"codex_approval_kind": "tool_suggestion",
"suggest_type": "install",
"tool_type": "plugin",
"tool_id": "slack@openai-curated",
"tool_name": "Slack",
})),
message: "Install Slack?".to_string(),
requested_schema: json!({
"type": "object",
"properties": {},
}),
},
});
assert_eq!(
plugin_install_elicitation_telemetry_metadata(&event),
Some(PluginInstallElicitationTelemetryMetadata {
tool_type: "plugin".to_string(),
tool_id: "slack@openai-curated".to_string(),
tool_name: "Slack".to_string(),
})
);
let enable_event = EventMsg::ElicitationRequest(ElicitationRequestEvent {
turn_id: Some("turn-1".to_string()),
server_name: "codex_apps".to_string(),
id: codex_protocol::mcp::RequestId::String("request-2".to_string()),
request: codex_protocol::approvals::ElicitationRequest::Form {
meta: Some(json!({
"codex_approval_kind": "tool_suggestion",
"suggest_type": "enable",
"tool_type": "plugin",
"tool_id": "slack@openai-curated",
"tool_name": "Slack",
})),
message: "Enable Slack?".to_string(),
requested_schema: json!({
"type": "object",
"properties": {},
}),
},
});
assert_eq!(
plugin_install_elicitation_telemetry_metadata(&enable_event),
None
);
}
#[test]
fn guardian_elicitation_review_request_requires_opt_in() {
let request = form_request(meta(json!({

View File

@@ -328,13 +328,14 @@ async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled()
.await;
assert_eq!(
response,
response.response,
Some(ElicitationResponse {
action: ElicitationAction::Accept,
content: Some(json!({})),
meta: None,
})
);
assert!(!response.sent);
assert!(rx.try_recv().is_err());
}