From f4c21180443f64357469cfd6a3512f13792cf5b9 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 24 Jun 2026 15:30:33 +0100 Subject: [PATCH] Route selected apps over MCP fallbacks --- .../tests/suite/v2/executor_connectors.rs | 36 ++++++++++++++----- codex-rs/connectors/src/snapshot.rs | 21 +++++++++-- codex-rs/core/src/mcp.rs | 30 +++++++++++----- 3 files changed, 69 insertions(+), 18 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/executor_connectors.rs b/codex-rs/app-server/tests/suite/v2/executor_connectors.rs index b284cb3166..d2839ad576 100644 --- a/codex-rs/app-server/tests/suite/v2/executor_connectors.rs +++ b/codex-rs/app-server/tests/suite/v2/executor_connectors.rs @@ -11,6 +11,8 @@ use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::CapabilityRootLocation; +use codex_app_server_protocol::ListMcpServerStatusParams; +use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SelectedCapabilityRoot; @@ -29,6 +31,10 @@ use super::app_list::connector_tool; use super::app_list::start_apps_server_with_delays_and_access_token; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); +const APP_CONFIG: &[u8] = br#"{"apps":{"calendar_app":{"id":"calendar"}}}"#; +const APP_NAME: &str = "calendar_app"; +const CONFLICTING_MCP_CONFIG: &[u8] = + br#"{"mcpServers":{"calendar_app":{"command":"must-not-start","startup_timeout_sec":1}}}"#; const CONNECTOR_ID: &str = "calendar"; const CONNECTOR_NAME: &str = "Calendar"; const PLUGIN_DISPLAY_NAME: &str = "Executor Calendar"; @@ -92,13 +98,8 @@ async fn selected_executor_connector_is_frozen_thread_scoped_and_hosted() -> Res .await?; let app_config_path = plugin_root.join(".app.json")?; executor_file_system - .write_file( - &app_config_path, - br#"{"apps":{"calendar":{"id":"calendar"}}}"#.to_vec(), - /*sandbox*/ None, - ) + .write_file(&app_config_path, APP_CONFIG.to_vec(), /*sandbox*/ None) .await?; - let mut app_server = TestAppServer::new_with_auto_env(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; let environment_id = app_server.auto_env_params()?.environment_id; @@ -177,10 +178,13 @@ async fn selected_executor_connector_is_frozen_thread_scoped_and_hosted() -> Res } ); + executor_file_system + .write_file(&app_config_path, APP_CONFIG.to_vec(), /*sandbox*/ None) + .await?; executor_file_system .write_file( - &app_config_path, - br#"{"apps":{"calendar":{"id":"calendar"}}}"#.to_vec(), + &plugin_root.join(".mcp.json")?, + CONFLICTING_MCP_CONFIG.to_vec(), /*sandbox*/ None, ) .await?; @@ -195,6 +199,22 @@ async fn selected_executor_connector_is_frozen_thread_scoped_and_hosted() -> Res }), ) .await?; + let request_id = app_server + .send_list_mcp_server_status_request(ListMcpServerStatusParams { + cursor: None, + limit: None, + detail: None, + thread_id: Some(model_selected_thread.clone()), + }) + .await?; + let response = timeout( + DEFAULT_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ListMcpServerStatusResponse = to_response(response)?; + assert!(response.data.iter().all(|server| server.name != APP_NAME)); + let selected_description = model_tool_description(&mut app_server, &responses_server, &model_selected_thread).await?; let unselected_description = diff --git a/codex-rs/connectors/src/snapshot.rs b/codex-rs/connectors/src/snapshot.rs index a07b1ef589..bf14f8e0f0 100644 --- a/codex-rs/connectors/src/snapshot.rs +++ b/codex-rs/connectors/src/snapshot.rs @@ -10,6 +10,7 @@ use codex_plugin::PluginCapabilitySummary; pub struct PluginConnectorSource { plugin_id: String, plugin_display_name: String, + app_names: HashSet, connector_ids: Vec, } @@ -20,13 +21,21 @@ impl PluginConnectorSource { plugin_display_name: impl Into, declarations: impl IntoIterator, ) -> Self { - Self::from_connector_ids( + let declarations = declarations.into_iter().collect::>(); + let app_names = declarations + .iter() + .filter(|declaration| !declaration.connector_id.0.trim().is_empty()) + .map(|declaration| declaration.name.clone()) + .collect(); + let mut source = Self::from_connector_ids( plugin_id, plugin_display_name, declarations .into_iter() .map(|declaration| declaration.connector_id), - ) + ); + source.app_names = app_names; + source } /// Creates one plugin source from connector IDs that were already parsed. @@ -44,6 +53,7 @@ impl PluginConnectorSource { Self { plugin_id: plugin_id.into(), plugin_display_name: plugin_display_name.into(), + app_names: HashSet::new(), connector_ids, } } @@ -125,6 +135,13 @@ impl ConnectorSnapshot { .unwrap_or_default() } + /// Returns whether one package declares an app with the given logical name. + pub fn plugin_declares_app(&self, plugin_id: &str, app_name: &str) -> bool { + self.sources + .iter() + .any(|source| source.plugin_id == plugin_id && source.app_names.contains(app_name)) + } + /// Combines two snapshots while preserving source order and provenance. pub fn merged_with(&self, other: &Self) -> Self { Self::from_plugin_sources(self.sources.iter().chain(&other.sources).cloned()) diff --git a/codex-rs/core/src/mcp.rs b/codex-rs/core/src/mcp.rs index c372966e1c..910c3a4307 100644 --- a/codex-rs/core/src/mcp.rs +++ b/codex-rs/core/src/mcp.rs @@ -5,6 +5,7 @@ use crate::config::Config; use codex_config::McpServerConfig; use codex_connectors::ConnectorSnapshot; use codex_core_plugins::PluginsManager; +use codex_core_plugins::apps_route_available; use codex_extension_api::ExtensionDataInit; use codex_extension_api::ExtensionRegistry; use codex_extension_api::McpServerContribution; @@ -96,6 +97,8 @@ impl McpManager { Some(thread_init) => McpServerContributionContext::for_thread(config, thread_init), None => McpServerContributionContext::global(config), }; + let selected_connector_snapshot = + thread_init.and_then(ExtensionDataInit::get::); let mut selected_plugin_registrations = Vec::new(); let mut overlays = Vec::new(); // A contributor can emit multiple ordered actions, so order each action globally rather @@ -118,14 +121,25 @@ impl McpManager { plugin_display_name, selection_order, config, - } => selected_plugin_registrations.push( - McpServerRegistration::from_selected_plugin( - name, - McpPluginAttribution::new(plugin_id, plugin_display_name), - selection_order, - *config, - ), - ), + } => { + let routed_through_app = + apps_route_available(self.plugins_manager.auth_mode()) + && selected_connector_snapshot + .as_ref() + .is_some_and(|snapshot| { + snapshot.plugin_declares_app(&plugin_id, &name) + }); + if !routed_through_app { + selected_plugin_registrations.push( + McpServerRegistration::from_selected_plugin( + name, + McpPluginAttribution::new(plugin_id, plugin_display_name), + selection_order, + *config, + ), + ); + } + } McpServerContribution::Remove { name } => { overlays.push(OrderedMcpOverlay::Remove { contributor_id: contributor.id(),