//! Aggregates MCP server connections for Codex. //! //! [`McpConnectionSet`] is the private connection set behind //! [`crate::McpRuntime`] and [`crate::McpBinding`]. It coordinates startup status //! events, keeps server metadata, and aggregates tools and resources across //! running RMCP clients. #[path = "connection_manager/required.rs"] mod required; #[path = "connection_manager/tool_catalog.rs"] mod tool_catalog; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::Ordering; use std::time::Duration; use crate::binding_clients::McpBindingClients; use crate::elicitation::ElicitationRequestManager; use crate::elicitation::ElicitationRequestRouter; use crate::elicitation::ElicitationReviewerHandle; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::ToolPluginProvenance; use crate::rmcp_client::AsyncManagedClient; use crate::rmcp_client::DEFAULT_STARTUP_TIMEOUT; use crate::rmcp_client::ManagedClient; use crate::rmcp_client::StartupOutcomeError; use crate::runtime::McpPublicationGate; use crate::runtime::McpRuntimeContext; use crate::server::EffectiveMcpServer; use crate::server::McpServerMetadata; use crate::tool_catalog_cache::McpToolCatalogCache; use crate::tools::ToolInfo; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use async_channel::Sender; use codex_api::SharedAuthProvider; use codex_config::Constrained; use codex_config::McpServerAuth; use codex_config::McpServerConfig; use codex_config::McpServerTransportConfig; use codex_config::types::AuthKeyringBackendKind; use codex_config::types::OAuthCredentialsStoreMode; use codex_connectors::ConnectorRuntimeContextKey; use codex_connectors::ConnectorRuntimeManager; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_protocol::mcp::CallToolResult; use codex_protocol::mcp::McpServerInfo; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::McpStartupCompleteEvent; use codex_protocol::protocol::McpStartupFailure; use codex_protocol::protocol::McpStartupFailureReason; use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; use codex_rmcp_client::McpAuthState; use codex_rmcp_client::McpLoginRequirement; use codex_rmcp_client::determine_streamable_http_auth_status_from_credentials; use rmcp::model::ElicitationCapability; use rmcp::model::ListResourcesResult; use rmcp::model::PaginatedRequestParams; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use serde_json::Value as JsonValue; use tokio::sync::Mutex; use tokio::sync::RwLock; use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; use tracing::warn; const MCP_UI_META_KEY: &str = "ui"; const MCP_UI_VISIBILITY_META_KEY: &str = "visibility"; const MCP_UI_MODEL_VISIBILITY: &str = "model"; /// Returns whether a tool may be included in model-facing tool declarations. /// /// Tools without visibility metadata remain visible. /// Tools with visibility metadata are hidden unless they explicitly include `model`. /// /// pub fn tool_is_model_visible(tool: &ToolInfo) -> bool { let Some(visibility) = tool .tool .meta .as_deref() .and_then(|meta| meta.get(MCP_UI_META_KEY)) .and_then(JsonValue::as_object) .and_then(|ui| ui.get(MCP_UI_VISIBILITY_META_KEY)) .and_then(JsonValue::as_array) else { return true; }; visibility .iter() .any(|target| target.as_str() == Some(MCP_UI_MODEL_VISIBILITY)) } /// A thin wrapper around a set of running [`RmcpClient`] instances. pub(crate) struct McpConnectionSet { clients: HashMap, server_metadata: HashMap, required_servers: Vec, tool_catalog_revision: Arc>, codex_apps_tools_override: RwLock>>, codex_apps_refresh_lock: Mutex<()>, tool_plugin_provenance: Arc, prefix_mcp_tool_names: bool, startup_cancellation_token: CancellationToken, } impl McpConnectionSet { /// Creates an MCP connection manager. Threadless callers can pass no `tx_event`; startup /// notifications are then skipped and interactive elicitations are declined. #[allow(clippy::too_many_arguments)] pub async fn new( mcp_servers: &HashMap, store_mode: OAuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, approval_policy: &Constrained, submit_id: String, tx_event: Option>, startup_cancellation_token: CancellationToken, initial_permission_profile: PermissionProfile, runtime_context: McpRuntimeContext, codex_home: PathBuf, codex_apps_tools_cache: ConnectorRuntimeManager, tool_catalog_cache: McpToolCatalogCache, codex_apps_tools_cache_key: ConnectorRuntimeContextKey, prefix_mcp_tool_names: bool, client_elicitation_capability: ElicitationCapability, supports_openai_form_elicitation: bool, tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, codex_apps_auth_manager: Option>, elicitation_reviewer: Option, elicitation_lifecycle: Option, elicitation_router: ElicitationRequestRouter, publication_gate: McpPublicationGate, ) -> Self { let mut clients = HashMap::new(); let mut server_metadata = HashMap::new(); let mut required_servers = mcp_servers .iter() .filter(|(_, server)| server.enabled() && server.required()) .map(|(server_name, _)| server_name.clone()) .collect::>(); required_servers.sort(); let mut join_set = JoinSet::new(); let elicitation_requests = ElicitationRequestManager::new( approval_policy.value(), initial_permission_profile, elicitation_reviewer, elicitation_lifecycle, elicitation_router, ); let tool_plugin_provenance = Arc::new(tool_plugin_provenance); let startup_submit_id = submit_id; let startup_publication_gate = publication_gate.clone(); let static_chatgpt_auth_provider = auth .filter(|auth| auth.uses_codex_backend()) .map(codex_model_provider::auth_provider_from_auth); let codex_apps_auth_provider = codex_apps_auth_manager.and_then(|auth_manager| { auth.filter(|auth| auth.uses_codex_backend()).map(|auth| { codex_model_provider::auth_provider_from_auth_manager(auth_manager, auth) }) }); let mcp_servers = mcp_servers.clone(); for (server_name, server) in mcp_servers .into_iter() .filter(|(_, server)| server.enabled()) { server_metadata.insert(server_name.clone(), McpServerMetadata::from(&server)); let cancel_token = startup_cancellation_token.child_token(); let configured_config = server.configured_config().cloned(); let resolved_environment = configured_config.as_ref().map_or_else( || Ok(None), |config| runtime_context.resolve_server_environment(&server_name, config), ); // For built-in Codex Apps, `CODEX_CONNECTORS_TOKEN` is a debug // override: it supplies runtime auth but bypasses the shared tools // cache. let uses_env_bearer_token = configured_config .as_ref() .is_some_and(|config| match &config.transport { McpServerTransportConfig::StreamableHttp { bearer_token_env_var, .. } => bearer_token_env_var.is_some(), McpServerTransportConfig::Stdio { .. } => false, }); let shares_codex_apps_tools_cache = should_share_codex_apps_tools_cache(&server_name, uses_env_bearer_token); let codex_apps_tools_cache_context = shares_codex_apps_tools_cache.then(|| { codex_apps_tools_cache .context(codex_home.clone(), codex_apps_tools_cache_key.clone()) }); // The reserved Codex Apps registration follows the shared // AuthManager across refreshes. In the hosted-plugin path, this // is the ChatGPT /ps/mcp connection. User-configured MCP // registrations keep their existing configured auth path. let chatgpt_auth_provider = if server_name == CODEX_APPS_MCP_SERVER_NAME { codex_apps_auth_provider .clone() .or_else(|| static_chatgpt_auth_provider.clone()) } else { static_chatgpt_auth_provider.clone() }; // If Codex Apps has an env bearer token, that is its auth path. Do // not also attach the ambient CodexAuth provider. let runtime_auth_provider = if server_name == CODEX_APPS_MCP_SERVER_NAME && uses_env_bearer_token { None } else { chatgpt_auth_provider_for_server(&server, chatgpt_auth_provider) }; let tool_catalog_cache_context = if server_name == CODEX_APPS_MCP_SERVER_NAME { None } else if let Some(config) = configured_config.as_ref() && let Ok(environment) = resolved_environment.as_ref() { tool_catalog_cache.context( &server_name, config, &runtime_context, environment.as_ref(), &client_elicitation_capability, supports_openai_form_elicitation, ) } else { None }; let has_runtime_auth = runtime_auth_provider.is_some(); let async_managed_client = AsyncManagedClient::new( server_name.clone(), startup_submit_id.clone(), server, store_mode, keyring_backend_kind, cancel_token.clone(), tx_event.clone(), elicitation_requests.clone(), codex_apps_tools_cache_context, tool_catalog_cache_context, Arc::clone(&tool_plugin_provenance), runtime_context.clone(), resolved_environment, runtime_auth_provider, client_elicitation_capability.clone(), supports_openai_form_elicitation, ); clients.insert(server_name.clone(), async_managed_client.clone()); let tx_event = tx_event.clone(); let submit_id = startup_submit_id.clone(); let publication_gate = publication_gate.clone(); join_set.spawn(async move { if !publication_gate.wait().await { return (server_name, Err(StartupOutcomeError::Cancelled)); } if let Some(tx_event) = tx_event.as_ref() { let _ = emit_update( submit_id.as_str(), tx_event, McpStartupUpdateEvent { server: server_name.clone(), status: McpStartupStatus::Starting, }, ) .await; } let mut outcome = async_managed_client.client().await; if cancel_token.is_cancelled() { outcome = Err(StartupOutcomeError::Cancelled); } if let Some(tx_event) = tx_event.as_ref() { let auth_state = match &outcome { Err(error) if error.is_authentication_required() && !has_runtime_auth => { configured_config.as_ref().and_then(|config| { let McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, http_headers, env_http_headers, } = &config.transport else { return None; }; match determine_streamable_http_auth_status_from_credentials( &server_name, url, bearer_token_env_var.as_deref(), http_headers.clone(), env_http_headers.clone(), store_mode, keyring_backend_kind, ) { Ok(auth_state) => auth_state, Err(error) => { warn!( "failed to read stored auth status for MCP server `{server_name}`: {error:?}" ); None } } }) } Ok(_) | Err(_) => None, }; if cancel_token.is_cancelled() { outcome = Err(StartupOutcomeError::Cancelled); } let status = match &outcome { Ok(_) => McpStartupStatus::Ready, Err(StartupOutcomeError::Cancelled) => McpStartupStatus::Cancelled, Err(error) => { let reason = mcp_startup_failure_reason(auth_state, error); let error_str = mcp_init_error_display( server_name.as_str(), configured_config.as_ref(), error, ); McpStartupStatus::Failed { error: error_str, reason, } } }; let _ = emit_update( submit_id.as_str(), tx_event, McpStartupUpdateEvent { server: server_name.clone(), status, }, ) .await; } if cancel_token.is_cancelled() { outcome = Err(StartupOutcomeError::Cancelled); } if matches!(&outcome, Err(StartupOutcomeError::Failed { .. })) { async_managed_client.reconnect_failed_startup().await; } (server_name, outcome) }); } let manager = Self { clients, server_metadata, required_servers, tool_catalog_revision: Arc::new(RwLock::new(0)), codex_apps_tools_override: RwLock::new(None), codex_apps_refresh_lock: Mutex::new(()), tool_plugin_provenance, prefix_mcp_tool_names, startup_cancellation_token, }; tokio::spawn(async move { let outcomes = join_set.join_all().await; if !startup_publication_gate.wait().await { return; } if let Some(tx_event) = tx_event { let mut summary = McpStartupCompleteEvent::default(); for (server_name, outcome) in outcomes { match outcome { Ok(_) => summary.ready.push(server_name), Err(StartupOutcomeError::Cancelled) => summary.cancelled.push(server_name), Err(StartupOutcomeError::Failed { error, .. }) => { summary.failed.push(McpStartupFailure { server: server_name, error, }) } } } let _ = tx_event .send(Event { id: startup_submit_id, msg: EventMsg::McpStartupComplete(summary), }) .await; } }); manager } pub fn empty(prefix_mcp_tool_names: bool) -> Self { Self { clients: HashMap::new(), server_metadata: HashMap::new(), required_servers: Vec::new(), tool_catalog_revision: Arc::new(RwLock::new(0)), codex_apps_tools_override: RwLock::new(None), codex_apps_refresh_lock: Mutex::new(()), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), prefix_mcp_tool_names, startup_cancellation_token: CancellationToken::new(), } } pub fn has_servers(&self) -> bool { !self.clients.is_empty() } pub(crate) fn cancel_startup(&self) { self.startup_cancellation_token.cancel(); } pub(crate) fn contains_server(&self, server_name: &str) -> bool { self.clients.contains_key(server_name) } /// Stop all MCP clients owned by this manager and terminate stdio server processes. pub async fn shutdown(&self) { self.startup_cancellation_token.cancel(); let clients = self.clients.values().cloned().collect::>(); // Keep cleanup alive if an interrupt cancels the refresh that requested it. let shutdown_task = tokio::spawn(async move { for client in clients { client.shutdown().await; } }); if let Err(error) = shutdown_task.await { warn!("MCP client shutdown task failed: {error}"); } } pub fn plugin_id_for_mcp_server_name(&self, server_name: &str) -> Option<&str> { self.tool_plugin_provenance .plugin_id_for_mcp_server_name(server_name) } pub fn is_selected_plugin_mcp_server(&self, server_name: &str) -> bool { self.tool_plugin_provenance .is_selected_plugin_mcp_server(server_name) } pub async fn wait_for_server_ready(&self, server_name: &str, timeout: Duration) -> bool { let Some(async_managed_client) = self.clients.get(server_name) else { return false; }; match tokio::time::timeout(timeout, async_managed_client.client()).await { Ok(Ok(_)) => true, Ok(Err(_)) | Err(_) => false, } } pub async fn list_all_resources( &self, include_server: impl Fn(&str) -> bool, ) -> HashMap> { self.ready_clients_matching(&include_server) .await .list_all_resources(|_| true) .await } pub async fn list_all_resource_templates( &self, include_server: impl Fn(&str) -> bool, ) -> HashMap> { self.ready_clients_matching(&include_server) .await .list_all_resource_templates(|_| true) .await } async fn ready_clients_matching( &self, include_server: &impl Fn(&str) -> bool, ) -> McpBindingClients { let mut clients = HashMap::new(); for (server, client) in self .clients .iter() .filter(|(server, _)| include_server(server)) { if let Ok(client) = client.client().await { clients.insert(server.clone(), Arc::new(client)); } } McpBindingClients::new(clients) } /// Invoke the tool indicated by the (server, tool) pair. pub async fn call_tool( &self, server: &str, tool: &str, arguments: Option, meta: Option, ) -> Result { let client = self.client_by_name(server).await?; if !client.tool_filter.allows(tool) { return Err(anyhow!( "tool '{tool}' is disabled for MCP server '{server}'" )); } let result: rmcp::model::CallToolResult = client .client .call_tool(tool.to_string(), arguments, meta, client.tool_timeout) .await .with_context(|| format!("tool call failed for `{server}/{tool}`"))?; let content = result .content .into_iter() .map(|content| { serde_json::to_value(content) .unwrap_or_else(|_| serde_json::Value::String("".to_string())) }) .collect(); Ok(CallToolResult { content, structured_content: result.structured_content, is_error: result.is_error, meta: result.meta.and_then(|meta| serde_json::to_value(meta).ok()), }) } /// List resources from the specified server. pub async fn list_resources( &self, server: &str, params: Option, ) -> Result { let managed = self.client_by_name(server).await?; let timeout = managed.tool_timeout; managed .client .list_resources(params, timeout) .await .with_context(|| format!("resources/list failed for `{server}`")) } /// Read a resource from the specified server. pub async fn read_resource( &self, server: &str, params: ReadResourceRequestParams, ) -> Result { let managed = self.client_by_name(server).await?; let client = managed.client.clone(); let timeout = managed.tool_timeout; let uri = params.uri.clone(); client .read_resource(params, timeout) .await .with_context(|| format!("resources/read failed for `{server}` ({uri})")) } /// Returns presentation metadata from the current connection. /// Codex Apps metadata may come from its existing cache; regular MCP server information is /// connection-specific, so pending regular clients are awaited. pub(crate) async fn list_available_server_infos(&self) -> HashMap { let mut server_infos = HashMap::new(); for (server_name, client) in &self.clients { if !client.startup_complete.load(Ordering::Acquire) && let Some(server_info) = client.cached_server_info.clone() { server_infos.insert(server_name.clone(), server_info); continue; } match client.client().await { Ok(managed_client) => { server_infos.insert(server_name.clone(), managed_client.server_info); } Err(_) => { if let Some(server_info) = client.cached_server_info.clone() { server_infos.insert(server_name.clone(), server_info); } } } } server_infos } async fn client_by_name(&self, name: &str) -> Result { self.clients .get(name) .ok_or_else(|| anyhow!("unknown MCP server '{name}'"))? .client() .await .context("failed to get client") } #[cfg(test)] fn new_uninitialized( _approval_policy: &Constrained, _permission_profile: &Constrained, prefix_mcp_tool_names: bool, ) -> Self { Self::empty(prefix_mcp_tool_names) } } impl Drop for McpConnectionSet { fn drop(&mut self) { self.startup_cancellation_token.cancel(); self.clients.clear(); } } /// Makes ChatGPT authentication available to servers that explicitly opt in. /// The HTTP transport applies it only when no configured authorization resolves. fn chatgpt_auth_provider_for_server( server: &EffectiveMcpServer, chatgpt_auth_provider: Option, ) -> Option { if !server .configured_config() .is_some_and(|config| matches!(&config.auth, McpServerAuth::ChatGpt)) { return None; } chatgpt_auth_provider } fn should_share_codex_apps_tools_cache(server_name: &str, uses_env_bearer_token: bool) -> bool { server_name == CODEX_APPS_MCP_SERVER_NAME && !uses_env_bearer_token } async fn emit_update( submit_id: &str, tx_event: &Sender, update: McpStartupUpdateEvent, ) -> Result<(), async_channel::SendError> { tx_event .send(Event { id: submit_id.to_string(), msg: EventMsg::McpStartupUpdate(update), }) .await } fn mcp_startup_failure_reason( auth_state: Option, error: &StartupOutcomeError, ) -> Option { if !error.is_authentication_required() { return None; } match auth_state { Some(McpAuthState::LoggedOut(McpLoginRequirement::Reauthentication)) => { Some(McpStartupFailureReason::ReauthenticationRequired) } Some( McpAuthState::Unsupported | McpAuthState::LoggedOut(McpLoginRequirement::Login) | McpAuthState::BearerToken | McpAuthState::OAuth, ) | None => None, } } fn mcp_init_error_display( server_name: &str, config: Option<&McpServerConfig>, err: &StartupOutcomeError, ) -> String { if let Some(McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, http_headers, .. }) = config.map(|config| &config.transport) && url == "https://api.githubcopilot.com/mcp/" && bearer_token_env_var.is_none() && http_headers.as_ref().map(HashMap::is_empty).unwrap_or(true) { format!( "GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN" ) } else if is_mcp_client_auth_required_error(err) { format!( "The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`." ) } else if is_mcp_client_startup_timeout_error(err) { let startup_timeout_secs = config .and_then(|config| config.startup_timeout_sec) .unwrap_or(DEFAULT_STARTUP_TIMEOUT) .as_secs(); format!( "MCP client for `{server_name}` timed out after {startup_timeout_secs} seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.{server_name}]\nstartup_timeout_sec = XX" ) } else { format!("MCP client for `{server_name}` failed to start: {err:#}") } } fn is_mcp_client_auth_required_error(error: &StartupOutcomeError) -> bool { match error { StartupOutcomeError::Failed { error, .. } => error.contains("Auth required"), _ => false, } } fn is_mcp_client_startup_timeout_error(error: &StartupOutcomeError) -> bool { match error { StartupOutcomeError::Failed { error, .. } => { error.contains("request timed out") || error.contains("timed out handshaking with MCP server") || error.contains("MCP client startup timed out") } _ => false, } } #[cfg(test)] #[path = "connection_manager_tests.rs"] mod tests;