diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b6d1932315..79f191ee66 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -201,6 +201,13 @@ }, "name": { "type": "string" + }, + "pluginDisplayNames": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index ff9f8d7d3b..ad093d1633 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7820,6 +7820,13 @@ }, "name": { "type": "string" + }, + "pluginDisplayNames": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index da523ea97c..a86b5b8ae4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -404,6 +404,13 @@ }, "name": { "type": "string" + }, + "pluginDisplayNames": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json index 0813ed6f56..d4e99f5086 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json @@ -119,6 +119,13 @@ }, "name": { "type": "string" + }, + "pluginDisplayNames": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json index 4697b34e12..2fb9092cb0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json @@ -119,6 +119,13 @@ }, "name": { "type": "string" + }, + "pluginDisplayNames": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts index 0c9a13b124..5655213718 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts @@ -16,4 +16,4 @@ export type AppInfo = { id: string, name: string, description: string | null, lo * enabled = false * ``` */ -isEnabled: boolean, }; +isEnabled: boolean, pluginDisplayNames: Array, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 83499ae889..5e41100a3d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1708,6 +1708,8 @@ pub struct AppInfo { /// ``` #[serde(default = "default_enabled")] pub is_enabled: bool, + #[serde(default)] + pub plugin_display_names: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 72655b5afb..638a020a8c 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -97,6 +97,7 @@ async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Resu install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }]; let tools = vec![connector_tool("beta", "Beta App")?]; let (server_url, server_handle) = @@ -199,6 +200,7 @@ async fn list_apps_reports_is_enabled_from_config() -> Result<()> { install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }]; let tools = vec![connector_tool("beta", "Beta App")?]; let (server_url, server_handle) = @@ -308,6 +310,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, AppInfo { id: "beta".to_string(), @@ -322,6 +325,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ]; @@ -370,6 +374,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }]; let first_update = read_app_list_updated_notification(&mut mcp).await?; @@ -389,6 +394,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, AppInfo { id: "alpha".to_string(), @@ -403,6 +409,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ]; @@ -443,6 +450,7 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates() install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, AppInfo { id: "beta".to_string(), @@ -457,6 +465,7 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates() install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ]; @@ -516,6 +525,7 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates() install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, AppInfo { id: "alpha".to_string(), @@ -530,6 +540,7 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates() install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ]; @@ -564,6 +575,7 @@ async fn list_apps_does_not_emit_empty_interim_updates() -> Result<()> { install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }]; let (server_url, server_handle) = start_apps_server_with_delays( connectors.clone(), @@ -619,6 +631,7 @@ async fn list_apps_does_not_emit_empty_interim_updates() -> Result<()> { install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }]; let update = read_app_list_updated_notification(&mut mcp).await?; @@ -653,6 +666,7 @@ async fn list_apps_paginates_results() -> Result<()> { install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, AppInfo { id: "beta".to_string(), @@ -667,6 +681,7 @@ async fn list_apps_paginates_results() -> Result<()> { install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ]; @@ -724,6 +739,7 @@ async fn list_apps_paginates_results() -> Result<()> { install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }]; assert_eq!(first_page, expected_first); @@ -767,6 +783,7 @@ async fn list_apps_paginates_results() -> Result<()> { install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }]; assert_eq!(second_page, expected_second); @@ -791,6 +808,7 @@ async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }]; let tools = vec![connector_tool("beta", "Beta App")?]; let (server_url, server_handle) = @@ -895,6 +913,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, AppInfo { id: "beta".to_string(), @@ -909,6 +928,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ]; let initial_tools = vec![connector_tool("beta", "Beta App")?]; @@ -958,6 +978,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }] ); @@ -978,6 +999,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, AppInfo { id: "alpha".to_string(), @@ -992,6 +1014,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ] ); @@ -1021,6 +1044,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }]); server_control.set_tools(Vec::new()); @@ -1050,6 +1074,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, AppInfo { id: "alpha".to_string(), @@ -1064,6 +1089,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ] ); @@ -1091,6 +1117,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }]; let second_update = read_app_list_updated_notification(&mut mcp).await?; assert_eq!(second_update.data, expected_final); diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 81c382f5d6..2dfe6671ae 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -446,6 +446,7 @@ fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo { install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), } } @@ -483,6 +484,7 @@ mod tests { install_url: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), } } @@ -540,6 +542,7 @@ mod tests { install_url: Some(connector_install_url(id, id)), is_accessible, is_enabled: true, + plugin_display_names: Vec::new(), } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7deff83e15..a427a76e39 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -6800,6 +6800,7 @@ mod tests { install_url: None, is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), } } @@ -6887,6 +6888,7 @@ mod tests { }, connector_id: connector_id.map(str::to_string), connector_name: connector_name.map(str::to_string), + plugin_display_names: Vec::new(), } } diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 69ab6f6178..d75b59aa14 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -1,7 +1,9 @@ +use std::collections::BTreeSet; use std::collections::HashMap; use std::collections::HashSet; use std::env; use std::path::PathBuf; +use std::sync::Arc; use std::sync::LazyLock; use std::sync::Mutex as StdMutex; use std::time::Duration; @@ -26,12 +28,14 @@ use crate::default_client::is_first_party_chat_originator; use crate::default_client::originator; use crate::features::Feature; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::mcp::McpManager; use crate::mcp::ToolPluginProvenance; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::with_codex_apps_mcp; use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_connection_manager::codex_apps_tools_cache_key; use crate::plugins::AppConnectorId; +use crate::plugins::PluginsManager; use crate::token_data::TokenData; pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600); @@ -124,9 +128,12 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( let auth_manager = auth_manager_from_config(config); let auth = auth_manager.auth().await; let cache_key = accessible_connectors_cache_key(config, auth.as_ref()); + let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone()))); + let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config); if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key) { let cached_connectors = filter_disallowed_connectors(cached_connectors); + let cached_connectors = with_app_plugin_sources(cached_connectors, &tool_plugin_provenance); return Ok(AccessibleConnectorsStatus { connectors: cached_connectors, codex_apps_ready: true, @@ -212,6 +219,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( if codex_apps_ready || !accessible_connectors.is_empty() { write_cached_accessible_connectors(cache_key, &accessible_connectors); } + let accessible_connectors = + with_app_plugin_sources(accessible_connectors, &tool_plugin_provenance); Ok(AccessibleConnectorsStatus { connectors: accessible_connectors, codex_apps_ready, @@ -293,13 +302,19 @@ pub fn connector_mention_slug(connector: &AppInfo) -> String { pub(crate) fn accessible_connectors_from_mcp_tools( mcp_tools: &HashMap, ) -> Vec { + // ToolInfo already carries plugin provenance, so app-level plugin sources + // can be derived here instead of requiring a separate enrichment pass. let tools = mcp_tools.values().filter_map(|tool| { if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { return None; } let connector_id = tool.connector_id.as_deref()?; let connector_name = normalize_connector_value(tool.connector_name.as_deref()); - Some((connector_id.to_string(), connector_name)) + Some(( + connector_id.to_string(), + connector_name, + tool.plugin_display_names.clone(), + )) }); collect_accessible_connectors(tools) } @@ -336,6 +351,9 @@ pub fn merge_connectors( if existing.distribution_channel.is_none() && connector.distribution_channel.is_some() { existing.distribution_channel = connector.distribution_channel; } + existing + .plugin_display_names + .extend(connector.plugin_display_names); } else { merged.insert(connector_id, connector); } @@ -346,6 +364,8 @@ pub fn merge_connectors( if connector.install_url.is_none() { connector.install_url = Some(connector_install_url(&connector.name, &connector.id)); } + connector.plugin_display_names.sort_unstable(); + connector.plugin_display_names.dedup(); } merged.sort_by(|left, right| { right @@ -409,6 +429,18 @@ pub fn with_app_enabled_state(mut connectors: Vec, config: &Config) -> connectors } +pub fn with_app_plugin_sources( + mut connectors: Vec, + tool_plugin_provenance: &ToolPluginProvenance, +) -> Vec { + for connector in &mut connectors { + connector.plugin_display_names = tool_plugin_provenance + .plugin_display_names_for_connector_id(connector.id.as_str()) + .to_vec(); + } + connectors +} + pub(crate) fn app_tool_policy( config: &Config, connector_id: Option<&str>, @@ -581,35 +613,49 @@ fn app_tool_policy_from_apps_config( fn collect_accessible_connectors(tools: I) -> Vec where - I: IntoIterator)>, + I: IntoIterator, Vec)>, { - let mut connectors: HashMap = HashMap::new(); - for (connector_id, connector_name) in tools { + let mut connectors: HashMap)> = HashMap::new(); + for (connector_id, connector_name, plugin_display_names) in tools { let connector_name = connector_name.unwrap_or_else(|| connector_id.clone()); - if let Some(existing_name) = connectors.get_mut(&connector_id) { + if let Some((existing_name, existing_plugin_display_names)) = + connectors.get_mut(&connector_id) + { if existing_name == &connector_id && connector_name != connector_id { *existing_name = connector_name; } + existing_plugin_display_names.extend(plugin_display_names); } else { - connectors.insert(connector_id, connector_name); + connectors.insert( + connector_id, + ( + connector_name, + plugin_display_names + .into_iter() + .collect::>(), + ), + ); } } let mut accessible: Vec = connectors .into_iter() - .map(|(connector_id, connector_name)| AppInfo { - id: connector_id.clone(), - name: connector_name.clone(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some(connector_install_url(&connector_name, &connector_id)), - is_accessible: true, - is_enabled: true, - }) + .map( + |(connector_id, (connector_name, plugin_display_names))| AppInfo { + id: connector_id.clone(), + name: connector_name.clone(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some(connector_install_url(&connector_name, &connector_id)), + is_accessible: true, + is_enabled: true, + plugin_display_names: plugin_display_names.into_iter().collect(), + }, + ) .collect(); accessible.sort_by(|left, right| { right @@ -640,6 +686,7 @@ fn plugin_app_to_app_info(connector_id: AppConnectorId) -> AppInfo { install_url: Some(connector_install_url(&name, &connector_id)), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), } } @@ -683,7 +730,11 @@ mod tests { use crate::config::types::AppToolConfig; use crate::config::types::AppToolsConfig; use crate::config::types::AppsDefaultConfig; + use crate::mcp_connection_manager::ToolInfo; use pretty_assertions::assert_eq; + use rmcp::model::JsonObject; + use rmcp::model::Tool; + use std::sync::Arc; fn annotations( destructive_hint: Option, @@ -712,13 +763,30 @@ mod tests { labels: None, is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), } } - #[test] - fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() { - let plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string())); - let accessible = AppInfo { + fn plugin_names(names: &[&str]) -> Vec { + names.iter().map(ToString::to_string).collect() + } + + fn test_tool_definition(tool_name: &str) -> Tool { + Tool { + name: tool_name.to_string().into(), + title: None, + description: None, + input_schema: Arc::new(JsonObject::default()), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + } + } + + fn google_calendar_accessible_connector(plugin_display_names: &[&str]) -> AppInfo { + AppInfo { id: "calendar".to_string(), name: "Google Calendar".to_string(), description: Some("Plan events".to_string()), @@ -731,7 +799,30 @@ mod tests { install_url: None, is_accessible: true, is_enabled: true, - }; + plugin_display_names: plugin_names(plugin_display_names), + } + } + + fn codex_app_tool( + tool_name: &str, + connector_id: &str, + connector_name: Option<&str>, + plugin_display_names: &[&str], + ) -> ToolInfo { + ToolInfo { + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool_name: tool_name.to_string(), + tool: test_tool_definition(tool_name), + connector_id: Some(connector_id.to_string()), + connector_name: connector_name.map(ToOwned::to_owned), + plugin_display_names: plugin_names(plugin_display_names), + } + } + + #[test] + fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() { + let plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string())); + let accessible = google_calendar_accessible_connector(&[]); let merged = merge_connectors(vec![plugin], vec![accessible]); @@ -750,11 +841,97 @@ mod tests { install_url: Some(connector_install_url("calendar", "calendar")), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }] ); assert_eq!(connector_mention_slug(&merged[0]), "google-calendar"); } + #[test] + fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() { + let tools = HashMap::from([ + ( + "mcp__codex_apps__calendar_list_events".to_string(), + codex_app_tool( + "calendar_list_events", + "calendar", + None, + &["sample", "sample"], + ), + ), + ( + "mcp__codex_apps__calendar_create_event".to_string(), + codex_app_tool( + "calendar_create_event", + "calendar", + Some("Google Calendar"), + &["beta", "sample"], + ), + ), + ( + "mcp__sample__echo".to_string(), + ToolInfo { + server_name: "sample".to_string(), + tool_name: "echo".to_string(), + tool: test_tool_definition("echo"), + connector_id: None, + connector_name: None, + plugin_display_names: plugin_names(&["ignored"]), + }, + ), + ]); + + let connectors = accessible_connectors_from_mcp_tools(&tools); + + assert_eq!( + connectors, + vec![AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some(connector_install_url("Google Calendar", "calendar")), + branding: None, + app_metadata: None, + labels: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: plugin_names(&["beta", "sample"]), + }] + ); + } + + #[test] + fn merge_connectors_unions_and_dedupes_plugin_display_names() { + let mut plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string())); + plugin.plugin_display_names = plugin_names(&["sample", "alpha", "sample"]); + + let accessible = google_calendar_accessible_connector(&["beta", "alpha"]); + + let merged = merge_connectors(vec![plugin], vec![accessible]); + + assert_eq!( + merged, + vec![AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Plan events".to_string()), + logo_url: Some("https://example.com/logo.png".to_string()), + logo_url_dark: Some("https://example.com/logo-dark.png".to_string()), + distribution_channel: Some("workspace".to_string()), + branding: None, + app_metadata: None, + labels: None, + install_url: Some(connector_install_url("calendar", "calendar")), + is_accessible: true, + is_enabled: true, + plugin_display_names: plugin_names(&["alpha", "beta", "sample"]), + }] + ); + } + #[test] fn app_tool_policy_uses_global_defaults_for_destructive_hints() { let apps_config = AppsConfigToml { diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index fa7cec0855..31556473d0 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -198,6 +198,8 @@ pub(crate) struct ToolInfo { pub(crate) tool: Tool, pub(crate) connector_id: Option, pub(crate) connector_name: Option, + #[serde(default)] + pub(crate) plugin_display_names: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1168,6 +1170,7 @@ fn annotate_tools_with_plugin_sources( None => tool_plugin_provenance .plugin_display_names_for_mcp_server_name(tool.server_name.as_str()), }; + tool.plugin_display_names = plugin_names.to_vec(); if plugin_names.is_empty() { continue; @@ -1571,6 +1574,7 @@ async fn list_tools_for_client_uncached( tool: tool_def, connector_id: tool.connector_id, connector_name, + plugin_display_names: Vec::new(), } }) .collect(); @@ -1684,6 +1688,7 @@ mod tests { }, connector_id: None, connector_name: None, + plugin_display_names: Vec::new(), } } diff --git a/codex-rs/core/src/plugins/injection.rs b/codex-rs/core/src/plugins/injection.rs index e6954039ad..d8adfc92c6 100644 --- a/codex-rs/core/src/plugins/injection.rs +++ b/codex-rs/core/src/plugins/injection.rs @@ -1,5 +1,5 @@ +use std::collections::BTreeSet; use std::collections::HashMap; -use std::collections::HashSet; use codex_protocol::models::DeveloperInstructions; use codex_protocol::models::ResponseItem; @@ -19,38 +19,36 @@ pub(crate) fn build_plugin_injections( return Vec::new(); } - let visible_mcp_server_names = mcp_tools - .values() - .filter(|tool| tool.server_name != CODEX_APPS_MCP_SERVER_NAME) - .map(|tool| tool.server_name.clone()) - .collect::>(); - let enabled_connectors_by_id = available_connectors - .iter() - .filter(|connector| connector.is_enabled) - .map(|connector| { - ( - connector.id.as_str(), - connectors::connector_display_label(connector), - ) - }) - .collect::>(); - // Turn each explicit @plugin mention into a developer hint that points the // model at the plugin's visible MCP servers, enabled apps, and skill prefix. mentioned_plugins .iter() .filter_map(|plugin| { - let available_mcp_servers = plugin - .mcp_server_names - .iter() - .filter(|server_name| visible_mcp_server_names.contains(server_name.as_str())) - .cloned() + let available_mcp_servers = mcp_tools + .values() + .filter(|tool| { + tool.server_name != CODEX_APPS_MCP_SERVER_NAME + && tool + .plugin_display_names + .iter() + .any(|plugin_name| plugin_name == &plugin.display_name) + }) + .map(|tool| tool.server_name.clone()) + .collect::>() + .into_iter() .collect::>(); - let available_apps = plugin - .app_connector_ids + let available_apps = available_connectors .iter() - .filter_map(|connector_id| enabled_connectors_by_id.get(connector_id.0.as_str())) - .cloned() + .filter(|connector| { + connector.is_enabled + && connector + .plugin_display_names + .iter() + .any(|plugin_name| plugin_name == &plugin.display_name) + }) + .map(connectors::connector_display_label) + .collect::>() + .into_iter() .collect::>(); render_explicit_plugin_instructions(plugin, &available_mcp_servers, &available_apps) .map(DeveloperInstructions::new) diff --git a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs index a4878b73a6..cb54a9b569 100644 --- a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs +++ b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs @@ -268,6 +268,7 @@ mod tests { install_url: None, is_accessible: true, is_enabled: enabled, + plugin_display_names: Vec::new(), } } @@ -295,6 +296,7 @@ mod tests { }, connector_id: connector_id.map(str::to_string), connector_name: connector_id.map(str::to_string), + plugin_display_names: Vec::new(), }, ) } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index dcdb48500e..432a5b8203 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -3006,6 +3006,7 @@ mod tests { ), connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), + plugin_display_names: Vec::new(), }, ), ( @@ -3016,6 +3017,7 @@ mod tests { tool: mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})), connector_id: None, connector_name: None, + plugin_display_names: Vec::new(), }, ), ])), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c084650660..35fd5229b1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -5198,6 +5198,7 @@ mod tests { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }]; composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors })); @@ -5238,6 +5239,7 @@ mod tests { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: false, + plugin_display_names: Vec::new(), }]; composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors })); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 72fa61c13a..b568aaffbe 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -6276,6 +6276,7 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }], }), false, @@ -6312,6 +6313,7 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, codex_chatgpt::connectors::AppInfo { id: linear_id.to_string(), @@ -6326,6 +6328,7 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() { install_url: Some("https://example.test/linear".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, ], }), @@ -6368,6 +6371,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, codex_chatgpt::connectors::AppInfo { id: linear_id.to_string(), @@ -6382,6 +6386,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { install_url: Some("https://example.test/linear".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ]; chat.on_connectors_loaded( @@ -6406,6 +6411,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }], }), false, @@ -6449,6 +6455,7 @@ async fn apps_refresh_failure_with_cached_snapshot_triggers_pending_force_refetc install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }]; chat.connectors_cache = ConnectorsCacheState::Ready(ConnectorsSnapshot { connectors: full_connectors.clone(), @@ -6487,6 +6494,7 @@ async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, codex_chatgpt::connectors::AppInfo { id: "unit_test_connector_2".to_string(), @@ -6501,6 +6509,7 @@ async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { install_url: Some("https://example.test/linear".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }, ]; chat.on_connectors_loaded( @@ -6527,6 +6536,7 @@ async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, codex_chatgpt::connectors::AppInfo { id: "connector_openai_hidden".to_string(), @@ -6541,6 +6551,7 @@ async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { install_url: Some("https://example.test/hidden-openai".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }, ], }), @@ -6587,6 +6598,7 @@ async fn apps_popup_shows_disabled_status_for_installed_but_disabled_apps() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: false, + plugin_display_names: Vec::new(), }], }), true, @@ -6640,6 +6652,7 @@ async fn apps_initial_load_applies_enabled_state_from_config() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }], }), true, @@ -6680,6 +6693,7 @@ async fn apps_refresh_preserves_toggled_enabled_state() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }], }), true, @@ -6701,6 +6715,7 @@ async fn apps_refresh_preserves_toggled_enabled_state() { install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, + plugin_display_names: Vec::new(), }], }), true, @@ -6748,6 +6763,7 @@ async fn apps_popup_for_not_installed_app_uses_install_only_selected_description install_url: Some("https://example.test/linear".to_string()), is_accessible: false, is_enabled: true, + plugin_display_names: Vec::new(), }], }), true,