Files
codex/codex-rs/core/src/connectors.rs
Matthew Zeng 2f3a2d7a86 Using cached connector directory for discoverable tools list (#21497)
## Summary

Startup tool construction currently depends on connector directory
metadata for `tool_suggest` discoverables. On a cold directory cache,
that can put slow connector-directory requests on the blocking path even
though the tools array only needs directory data for install
suggestions, not for the live connector MCP tools themselves.

This PR keeps the discoverables path off that cold network fetch:
- read connector directory metadata from cache only when building
discoverable tools
- persist connector directory metadata to
`~/.codex/cache/codex_app_directory/<hash>.json` and use it to hydrate
the in-memory cache on later runs before the normal refresh path updates
it
- use connector-directory-specific cache naming to distinguish this
metadata cache from the separate Codex Apps tools-spec cache

This reduces first-turn startup work without changing how live connector
MCP tools are sourced. Longer term, directory-backed install suggestions
should move to a search-based flow so they no longer need to be inlined
into the tools prompt at all.

## Testing

- `cargo test -p codex-connectors`
- `cargo test -p codex-chatgpt`
- `cargo test -p codex-core
request_plugin_install_is_available_without_search_tool_after_discovery_attempts`
- `cargo test -p codex-core
tool_suggest_uses_connector_id_fallback_when_directory_cache_is_empty`
2026-05-08 14:14:11 -07:00

686 lines
22 KiB
Rust

use std::collections::HashMap;
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_exec_server::EnvironmentManager;
use codex_exec_server::ExecServerRuntimePaths;
use codex_protocol::models::PermissionProfile;
use codex_tools::DiscoverableTool;
use rmcp::model::ToolAnnotations;
use serde::Deserialize;
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::AppsRequirementsToml;
use codex_config::types::AppToolApproval;
use codex_config::types::AppsConfigToml;
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::McpRuntimeEnvironment;
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::host_owned_codex_apps_enabled;
use codex_mcp::with_codex_apps_mcp;
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AppToolPolicy {
pub enabled: bool,
pub approval: AppToolApproval,
}
impl Default for AppToolPolicy {
fn default() -> Self {
Self {
enabled: true,
approval: AppToolApproval::Auto,
}
}
}
#[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,
auth: Option<&CodexAuth>,
accessible_connectors: &[AppInfo],
) -> anyhow::Result<Vec<DiscoverableTool>> {
let connector_ids = tool_suggest_connector_ids(config).await;
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)
.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(), local_runtime_paths).await?;
list_accessible_connectors_from_mcp_tools_with_environment_manager(
config,
force_refetch,
&environment_manager,
)
.await
}
pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
config: &Config,
force_refetch: bool,
environment_manager: &EnvironmentManager,
) -> 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 plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf()));
let mcp_manager = McpManager::new(Arc::clone(&plugins_manager));
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config).await;
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 mcp_config = config.to_mcp_config(plugins_manager.as_ref()).await;
let mcp_servers = with_codex_apps_mcp(HashMap::new(), auth.as_ref(), &mcp_config);
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,
auth.as_ref(),
)
.await;
let (tx_event, rx_event) = unbounded();
drop(rx_event);
let environment = environment_manager
.default_environment()
.unwrap_or_else(|| environment_manager.local_environment());
let (mut mcp_connection_manager, cancel_token) = McpConnectionManager::new(
&mcp_servers,
config.mcp_oauth_credentials_store_mode,
auth_status_entries,
&config.permissions.approval_policy,
INITIAL_SUBMIT_ID.to_owned(),
tx_event,
PermissionProfile::default(),
McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()),
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
host_owned_codex_apps_enabled,
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(),
});
}
async fn tool_suggest_connector_ids(config: &Config) -> HashSet<String> {
let plugins_input = config.plugins_config_input();
let mut connector_ids = PluginsManager::new(config.codex_home.to_path_buf())
.plugins_for_config(&plugins_input)
.await
.capability_summaries()
.iter()
.flat_map(|plugin| plugin.app_connector_ids.iter())
.map(|connector_id| connector_id.0.clone())
.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
}
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 = read_user_apps_config(config);
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 app_tool_policy(
config: &Config,
connector_id: Option<&str>,
tool_name: &str,
tool_title: Option<&str>,
annotations: Option<&ToolAnnotations>,
) -> AppToolPolicy {
let apps_config = read_apps_config(config);
app_tool_policy_from_apps_config(
apps_config.as_ref(),
connector_id,
tool_name,
tool_title,
annotations,
)
}
pub(crate) fn codex_app_tool_is_enabled(config: &Config, tool_info: &ToolInfo) -> bool {
if tool_info.server_name != CODEX_APPS_MCP_SERVER_NAME {
return true;
}
app_tool_policy(
config,
tool_info.connector_id.as_deref(),
&tool_info.tool.name,
tool_info.tool.title.as_deref(),
tool_info.tool.annotations.as_ref(),
)
.enabled
}
fn read_apps_config(config: &Config) -> Option<AppsConfigToml> {
let apps_config = read_user_apps_config(config);
let had_apps_config = apps_config.is_some();
let mut apps_config = apps_config.unwrap_or_default();
apply_requirements_apps_constraints(
&mut apps_config,
config.config_layer_stack.requirements_toml().apps.as_ref(),
);
if had_apps_config || apps_config.default.is_some() || !apps_config.apps.is_empty() {
Some(apps_config)
} else {
None
}
}
fn read_user_apps_config(config: &Config) -> Option<AppsConfigToml> {
config
.config_layer_stack
.effective_config()
.as_table()
.and_then(|table| table.get("apps"))
.cloned()
.and_then(|value| AppsConfigToml::deserialize(value).ok())
}
fn apply_requirements_apps_constraints(
apps_config: &mut AppsConfigToml,
requirements_apps_config: Option<&AppsRequirementsToml>,
) {
let Some(requirements_apps_config) = requirements_apps_config else {
return;
};
for (app_id, requirement) in &requirements_apps_config.apps {
if requirement.enabled != Some(false) {
continue;
}
let app = apps_config.apps.entry(app_id.clone()).or_default();
app.enabled = false;
}
}
fn app_is_enabled(apps_config: &AppsConfigToml, connector_id: Option<&str>) -> bool {
let default_enabled = apps_config
.default
.as_ref()
.map(|defaults| defaults.enabled)
.unwrap_or(true);
connector_id
.and_then(|connector_id| apps_config.apps.get(connector_id))
.map(|app| app.enabled)
.unwrap_or(default_enabled)
}
fn app_tool_policy_from_apps_config(
apps_config: Option<&AppsConfigToml>,
connector_id: Option<&str>,
tool_name: &str,
tool_title: Option<&str>,
annotations: Option<&ToolAnnotations>,
) -> AppToolPolicy {
let Some(apps_config) = apps_config else {
return AppToolPolicy::default();
};
let app = connector_id.and_then(|connector_id| apps_config.apps.get(connector_id));
let tools = app.and_then(|app| app.tools.as_ref());
let tool_config = tools.and_then(|tools| {
tools
.tools
.get(tool_name)
.or_else(|| tool_title.and_then(|title| tools.tools.get(title)))
});
let approval = tool_config
.and_then(|tool| tool.approval_mode)
.or_else(|| app.and_then(|app| app.default_tools_approval_mode))
.unwrap_or(AppToolApproval::Auto);
if !app_is_enabled(apps_config, connector_id) {
return AppToolPolicy {
enabled: false,
approval,
};
}
if let Some(enabled) = tool_config.and_then(|tool| tool.enabled) {
return AppToolPolicy { enabled, approval };
}
if let Some(enabled) = app.and_then(|app| app.default_tools_enabled) {
return AppToolPolicy { enabled, approval };
}
let app_defaults = apps_config.default.as_ref();
let destructive_enabled = app
.and_then(|app| app.destructive_enabled)
.unwrap_or_else(|| {
app_defaults
.map(|defaults| defaults.destructive_enabled)
.unwrap_or(true)
});
let open_world_enabled = app
.and_then(|app| app.open_world_enabled)
.unwrap_or_else(|| {
app_defaults
.map(|defaults| defaults.open_world_enabled)
.unwrap_or(true)
});
let destructive_hint = annotations
.and_then(|annotations| annotations.destructive_hint)
.unwrap_or(true);
let open_world_hint = annotations
.and_then(|annotations| annotations.open_world_hint)
.unwrap_or(true);
let enabled =
(destructive_enabled || !destructive_hint) && (open_world_enabled || !open_world_hint);
AppToolPolicy { enabled, approval }
}
#[cfg(test)]
#[path = "connectors_tests.rs"]
mod tests;