use std::collections::HashSet;use std::sync::Arc;use std::sync::LazyLock;use std::sync::Mutex as StdMutex;use std::time::Duration;use std::time::Instant;use async_channel::unbounded;pub use codex_app_server_protocol::AppBranding;pub use codex_app_server_protocol::AppInfo;pub use codex_app_server_protocol::AppMetadata;use codex_connectors::ConnectorDirectoryCacheContext;use codex_connectors::ConnectorDirectoryCacheKey;use codex_connectors::app_is_enabled;use codex_connectors::apps_config_from_layer_stack;use codex_exec_server::EnvironmentManager;use codex_exec_server::ExecServerRuntimePaths;use codex_protocol::models::PermissionProfile;use codex_tools::DiscoverableTool;use tokio_util::sync::CancellationToken;use tracing::instrument;use tracing::warn;use crate::config::Config;use crate::mcp::McpManager;use crate::plugins::list_tool_suggest_discoverable_plugins;use crate::session::INITIAL_SUBMIT_ID;use codex_config::types::ApprovalsReviewer;use codex_config::types::ToolSuggestDiscoverableType;use codex_core_plugins::PluginsManager;use codex_features::Feature;use codex_login::AuthManager;use codex_login::CodexAuth;use codex_login::default_client::originator;use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;use codex_mcp::McpConnectionManager;use codex_mcp::McpRuntimeContext;use codex_mcp::ToolInfo;use codex_mcp::ToolPluginProvenance;use codex_mcp::codex_apps_tools_cache_key;use codex_mcp::compute_auth_statuses;use codex_mcp::effective_mcp_servers;use codex_mcp::host_owned_codex_apps_enabled;use codex_mcp::tool_plugin_provenance;const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);#[derive(Clone, PartialEq, Eq)]struct AccessibleConnectorsCacheKey { chatgpt_base_url: String, account_id: Option<String>, chatgpt_user_id: Option<String>, is_workspace_account: bool,}#[derive(Clone)]struct CachedAccessibleConnectors { key: AccessibleConnectorsCacheKey, expires_at: Instant, connectors: Vec<AppInfo>,}static ACCESSIBLE_CONNECTORS_CACHE: LazyLock<StdMutex<Option<CachedAccessibleConnectors>>> = LazyLock::new(|| StdMutex::new(None));#[derive(Debug, Clone)]pub struct AccessibleConnectorsStatus { pub connectors: Vec<AppInfo>, pub codex_apps_ready: bool,}pub async fn list_accessible_connectors_from_mcp_tools( config: &Config,) -> anyhow::Result<Vec<AppInfo>> { Ok( list_accessible_connectors_from_mcp_tools_with_options_and_status( config, /*force_refetch*/ false, ) .await? .connectors, )}pub(crate) async fn list_accessible_and_enabled_connectors_from_manager( mcp_connection_manager: &McpConnectionManager, config: &Config,) -> Vec<AppInfo> { with_app_enabled_state( accessible_connectors_from_mcp_tools(&mcp_connection_manager.list_all_tools().await), config, ) .into_iter() .filter(|connector| connector.is_accessible && connector.is_enabled) .collect()}pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( config: &Config, plugins_manager: &PluginsManager, auth: Option<&CodexAuth>, accessible_connectors: &[AppInfo], loaded_plugin_app_connector_ids: &[String],) -> anyhow::Result<Vec<DiscoverableTool>> { let connector_ids = tool_suggest_connector_ids(config, loaded_plugin_app_connector_ids); let directory_connectors = codex_connectors::merge::merge_plugin_connectors( cached_directory_connectors_for_tool_suggest_with_auth(config, auth).await, connector_ids.iter().cloned(), ); let discoverable_connectors = codex_connectors::filter::filter_tool_suggest_discoverable_connectors( directory_connectors, accessible_connectors, &connector_ids, originator().value.as_str(), ) .into_iter() .map(DiscoverableTool::from); let discoverable_plugins = list_tool_suggest_discoverable_plugins( config, plugins_manager, auth, loaded_plugin_app_connector_ids, ) .await? .into_iter() .map(DiscoverableTool::from); Ok(discoverable_connectors .chain(discoverable_plugins) .collect())}pub async fn list_cached_accessible_connectors_from_mcp_tools( config: &Config,) -> Option<Vec<AppInfo>> { let auth_manager = AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; let auth = auth_manager.auth().await; if !config .features .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend)) { return Some(Vec::new()); } let cache_key = accessible_connectors_cache_key(config, auth.as_ref()); read_cached_accessible_connectors(&cache_key).map(|connectors| { codex_connectors::filter::filter_disallowed_connectors( connectors, originator().value.as_str(), ) })}pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools( config: &Config, auth: Option<&CodexAuth>, mcp_tools: &[ToolInfo],) { if !config.features.enabled(Feature::Apps) { return; } let cache_key = accessible_connectors_cache_key(config, auth); let accessible_connectors = codex_connectors::filter::filter_disallowed_connectors( accessible_connectors_from_mcp_tools(mcp_tools), originator().value.as_str(), ); write_cached_accessible_connectors(cache_key, &accessible_connectors);}pub async fn list_accessible_connectors_from_mcp_tools_with_options( config: &Config, force_refetch: bool,) -> anyhow::Result<Vec<AppInfo>> { Ok( list_accessible_connectors_from_mcp_tools_with_options_and_status(config, force_refetch) .await? .connectors, )}pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( config: &Config, force_refetch: bool,) -> anyhow::Result<AccessibleConnectorsStatus> { // TODO: Wire callers that already own an EnvironmentManager into // list_accessible_connectors_from_mcp_tools_with_environment_manager instead // of constructing a temporary manager here. let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths( config.codex_self_exe.clone(), config.codex_linux_sandbox_exe.clone(), )?; let environment_manager = EnvironmentManager::from_codex_home(config.codex_home.clone(), Some(local_runtime_paths)) .await?; list_accessible_connectors_from_mcp_tools_with_environment_manager( config, force_refetch, Arc::new(environment_manager), ) .await}pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( config: &Config, force_refetch: bool, environment_manager: Arc<EnvironmentManager>,) -> anyhow::Result<AccessibleConnectorsStatus> { let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf())); let mcp_manager = Arc::new(McpManager::new(plugins_manager)); list_accessible_connectors_from_mcp_tools_with_mcp_manager( config, force_refetch, environment_manager, mcp_manager, ) .await}pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( config: &Config, force_refetch: bool, environment_manager: Arc<EnvironmentManager>, mcp_manager: Arc<McpManager>,) -> anyhow::Result<AccessibleConnectorsStatus> { let auth_manager = AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; let auth = auth_manager.auth().await; if !config .features .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend)) { return Ok(AccessibleConnectorsStatus { connectors: Vec::new(), codex_apps_ready: true, }); } let cache_key = accessible_connectors_cache_key(config, auth.as_ref()); let mcp_config = mcp_manager.runtime_config(config).await; let tool_plugin_provenance = tool_plugin_provenance(&mcp_config); if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key) { let cached_connectors = codex_connectors::filter::filter_disallowed_connectors( cached_connectors, originator().value.as_str(), ); let cached_connectors = with_app_plugin_sources(cached_connectors, &tool_plugin_provenance); return Ok(AccessibleConnectorsStatus { connectors: cached_connectors, codex_apps_ready: true, }); } let mut mcp_servers = effective_mcp_servers(&mcp_config, auth.as_ref()); mcp_servers.retain(|name, _| name == CODEX_APPS_MCP_SERVER_NAME); let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(&mcp_config, auth.as_ref()); if mcp_servers.is_empty() { return Ok(AccessibleConnectorsStatus { connectors: Vec::new(), codex_apps_ready: true, }); } let auth_status_entries = compute_auth_statuses( mcp_servers.iter(), config.mcp_oauth_credentials_store_mode, config.auth_keyring_backend_kind(), auth.as_ref(), ) .await; let (tx_event, rx_event) = unbounded(); drop(rx_event); let cancel_token = CancellationToken::new(); let mcp_connection_manager = McpConnectionManager::new( &mcp_servers, config.mcp_oauth_credentials_store_mode, config.auth_keyring_backend_kind(), auth_status_entries, &config.permissions.approval_policy, INITIAL_SUBMIT_ID.to_owned(), tx_event, cancel_token.clone(), PermissionProfile::default(), // Connector discovery is threadless. Use an actually configured env if // one exists, but do not reintroduce the old hidden-local fallback. McpRuntimeContext::new(environment_manager, config.cwd.to_path_buf()), 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, ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, ) .await; let refreshed_tools = if force_refetch { match mcp_connection_manager .hard_refresh_codex_apps_tools_cache() .await { Ok(tools) => Some(tools), Err(err) => { warn!( "failed to force-refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}', using cached/startup tools: {err:#}" ); None } } } else { None }; let refreshed_tools_succeeded = refreshed_tools.is_some(); let mut tools = if let Some(tools) = refreshed_tools { tools } else { mcp_connection_manager.list_all_tools().await }; let mut should_reload_tools = false; let codex_apps_ready = if refreshed_tools_succeeded { true } else if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) { let immediate_ready = mcp_connection_manager .wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, Duration::ZERO) .await; if immediate_ready { true } else if tools.is_empty() { let timeout = cfg .configured_config() .and_then(|config| config.startup_timeout_sec) .unwrap_or(CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS); let ready = mcp_connection_manager .wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, timeout) .await; should_reload_tools = ready; ready } else { false } } else { false }; if should_reload_tools { tools = mcp_connection_manager.list_all_tools().await; } if codex_apps_ready { cancel_token.cancel(); } let accessible_connectors = codex_connectors::filter::filter_disallowed_connectors( accessible_connectors_from_mcp_tools(&tools), originator().value.as_str(), ); 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); mcp_connection_manager.shutdown().await; Ok(AccessibleConnectorsStatus { connectors: accessible_connectors, codex_apps_ready, })}fn accessible_connectors_cache_key( config: &Config, auth: Option<&CodexAuth>,) -> AccessibleConnectorsCacheKey { let account_id = auth.and_then(CodexAuth::get_account_id); let chatgpt_user_id = auth.and_then(CodexAuth::get_chatgpt_user_id); let is_workspace_account = auth.is_some_and(CodexAuth::is_workspace_account); AccessibleConnectorsCacheKey { chatgpt_base_url: config.chatgpt_base_url.clone(), account_id, chatgpt_user_id, is_workspace_account, }}fn read_cached_accessible_connectors( cache_key: &AccessibleConnectorsCacheKey,) -> Option<Vec<AppInfo>> { let mut cache_guard = ACCESSIBLE_CONNECTORS_CACHE .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let now = Instant::now(); if let Some(cached) = cache_guard.as_ref() { if now < cached.expires_at && cached.key == *cache_key { return Some(cached.connectors.clone()); } if now >= cached.expires_at { *cache_guard = None; } } None}fn write_cached_accessible_connectors( cache_key: AccessibleConnectorsCacheKey, connectors: &[AppInfo],) { let mut cache_guard = ACCESSIBLE_CONNECTORS_CACHE .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); *cache_guard = Some(CachedAccessibleConnectors { key: cache_key, expires_at: Instant::now() + codex_connectors::CONNECTORS_CACHE_TTL, connectors: connectors.to_vec(), });}fn tool_suggest_connector_ids( config: &Config, loaded_plugin_app_connector_ids: &[String],) -> HashSet<String> { let mut connector_ids = loaded_plugin_app_connector_ids .iter() .cloned() .collect::<HashSet<_>>(); connector_ids.extend( config .tool_suggest .discoverables .iter() .filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Connector) .map(|discoverable| discoverable.id.clone()), ); let disabled_connector_ids = config .tool_suggest .disabled_tools .iter() .filter(|disabled_tool| disabled_tool.kind == ToolSuggestDiscoverableType::Connector) .map(|disabled_tool| disabled_tool.id.as_str()) .collect::<HashSet<_>>(); connector_ids.retain(|connector_id| !disabled_connector_ids.contains(connector_id.as_str())); connector_ids}#[instrument(level = "trace", skip_all)]async fn cached_directory_connectors_for_tool_suggest_with_auth( config: &Config, auth: Option<&CodexAuth>,) -> Vec<AppInfo> { if !config.features.enabled(Feature::Apps) { return Vec::new(); } let loaded_auth; let auth = if let Some(auth) = auth { Some(auth) } else { let auth_manager = AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; loaded_auth = auth_manager.auth().await; loaded_auth.as_ref() }; let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else { return Vec::new(); }; let account_id = match auth.get_account_id() { Some(account_id) if !account_id.is_empty() => account_id, _ => return Vec::new(), }; let is_workspace_account = auth.is_workspace_account(); let cache_context = ConnectorDirectoryCacheContext::new( config.codex_home.to_path_buf(), ConnectorDirectoryCacheKey::new( config.chatgpt_base_url.clone(), Some(account_id), auth.get_chatgpt_user_id(), is_workspace_account, ), ); codex_connectors::cached_directory_connectors(&cache_context).unwrap_or_default()}pub(crate) fn accessible_connectors_from_mcp_tools(mcp_tools: &[ToolInfo]) -> Vec<AppInfo> { // 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.iter().filter_map(|tool| { if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { return None; } let connector_id = tool.connector_id.as_deref()?; Some(codex_connectors::accessible::AccessibleConnectorTool { connector_id: connector_id.to_string(), connector_name: tool.connector_name.clone(), connector_description: tool.namespace_description.clone(), plugin_display_names: tool.plugin_display_names.clone(), }) }); codex_connectors::accessible::collect_accessible_connectors(tools)}pub fn with_app_enabled_state(mut connectors: Vec<AppInfo>, config: &Config) -> Vec<AppInfo> { let user_apps_config = apps_config_from_layer_stack(&config.config_layer_stack); let requirements_apps_config = config.config_layer_stack.requirements_toml().apps.as_ref(); if user_apps_config.is_none() && requirements_apps_config.is_none() { return connectors; } for connector in &mut connectors { if let Some(apps_config) = user_apps_config.as_ref() && (apps_config.default.is_some() || apps_config.apps.contains_key(connector.id.as_str())) { connector.is_enabled = app_is_enabled(apps_config, Some(connector.id.as_str())); } if requirements_apps_config .and_then(|apps| apps.apps.get(connector.id.as_str())) .is_some_and(|app| app.enabled == Some(false)) { connector.is_enabled = false; } } connectors}pub fn with_app_plugin_sources( mut connectors: Vec<AppInfo>, tool_plugin_provenance: &ToolPluginProvenance,) -> Vec<AppInfo> { 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 mcp_approvals_reviewer( config: &Config, server_name: &str, connector_id: Option<&str>,) -> ApprovalsReviewer { let app_reviewer = if server_name == CODEX_APPS_MCP_SERVER_NAME { apps_config_from_layer_stack(&config.config_layer_stack).and_then(|apps_config| { connector_id .and_then(|connector_id| apps_config.apps.get(connector_id)) .and_then(|app| app.approvals_reviewer) .or_else(|| { apps_config .default .and_then(|defaults| defaults.approvals_reviewer) }) }) } else { None }; if let Some(reviewer) = app_reviewer && config .config_layer_stack .requirements() .approvals_reviewer .can_set(&reviewer) .is_ok() { return reviewer; } config.approvals_reviewer}#[cfg(test)]#[path = "connectors_tests.rs"]mod tests;