pub use auth::McpAuthStatusEntry;pub use auth::McpOAuthLoginConfig;pub use auth::McpOAuthLoginSupport;pub use auth::McpOAuthScopesSource;pub use auth::ResolvedMcpOAuthScopes;pub use auth::compute_auth_statuses;pub use auth::discover_supported_scopes;pub use auth::oauth_login_support;pub use auth::resolve_oauth_scopes;pub use auth::should_retry_without_scopes;pub(crate) mod auth;use std::collections::HashMap;use std::collections::HashSet;use std::env;use std::path::PathBuf;use std::time::Duration;use async_channel::unbounded;use codex_config::Constrained;use codex_config::McpServerConfig;use codex_config::McpServerTransportConfig;use codex_config::types::AppToolApproval;use codex_config::types::AuthKeyringBackendKind;use codex_config::types::OAuthCredentialsStoreMode;use codex_login::CodexAuth;use codex_plugin::PluginCapabilitySummary;use codex_protocol::mcp::McpServerInfo;use codex_protocol::mcp::Resource;use codex_protocol::mcp::ResourceTemplate;use codex_protocol::mcp::Tool;use codex_protocol::models::PermissionProfile;use codex_protocol::protocol::AskForApproval;use codex_protocol::protocol::McpAuthStatus;use rmcp::model::ElicitationCapability;use rmcp::model::ReadResourceRequestParams;use rmcp::model::ReadResourceResult;use serde_json::Value;use tokio_util::sync::CancellationToken;use crate::ResolvedMcpCatalog;use crate::codex_apps::codex_apps_tools_cache_key;use crate::connection_manager::McpConnectionManager;use crate::runtime::McpRuntimeContext;use crate::server::EffectiveMcpServer;pub const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps";const MCP_TOOL_NAME_PREFIX: &str = "mcp";const MCP_TOOL_NAME_DELIMITER: &str = "__";const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN";#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]pub enum McpSnapshotDetail { #[default] Full, ToolsAndAuthOnly,}impl McpSnapshotDetail { fn include_resources(self) -> bool { matches!(self, Self::Full) }}pub fn qualified_mcp_tool_name_prefix(server_name: &str) -> String { sanitize_responses_api_tool_name(&format!( "{MCP_TOOL_NAME_PREFIX}{MCP_TOOL_NAME_DELIMITER}{server_name}{MCP_TOOL_NAME_DELIMITER}" ))}/// Returns true when MCP permission prompts should resolve as approved instead/// of being shown to the user.pub fn mcp_permission_prompt_is_auto_approved( approval_policy: AskForApproval, permission_profile: &PermissionProfile, context: McpPermissionPromptAutoApproveContext,) -> bool { if context.tool_approval_mode == Some(AppToolApproval::Approve) { return true; } if approval_policy != AskForApproval::Never { return false; } match permission_profile { PermissionProfile::Disabled | PermissionProfile::External { .. } => true, PermissionProfile::Managed { file_system, .. } => { file_system.to_sandbox_policy().has_full_disk_write_access() } }}#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]pub struct McpPermissionPromptAutoApproveContext { pub tool_approval_mode: Option<AppToolApproval>,}/// MCP runtime settings derived from `codex_core::config::Config`.////// This struct should contain only long-lived configuration values that the/// `codex-mcp` crate needs to construct server transports, enforce MCP/// approval/sandbox policy, locate OAuth state, and merge plugin-provided MCP/// servers. Request-scoped or auth-scoped state should not be stored here;/// thread those values explicitly into runtime entry points such as/// [`effective_mcp_servers`] and snapshot collection helpers so config objects/// do not go stale when auth changes.#[derive(Debug, Clone)]pub struct McpConfig { /// Base URL for ChatGPT-hosted app MCP servers, copied from the root config. pub chatgpt_base_url: String, /// Optional product SKU forwarded to the host-owned apps MCP server. pub apps_mcp_product_sku: Option<String>, /// Codex home directory used for MCP OAuth state and app-tool cache files. pub codex_home: PathBuf, /// Preferred credential store for MCP OAuth tokens. pub mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode, /// Backend used when MCP OAuth storage is configured for keyring-backed persistence. pub auth_keyring_backend_kind: AuthKeyringBackendKind, /// Optional fixed localhost callback port for MCP OAuth login. pub mcp_oauth_callback_port: Option<u16>, /// Optional OAuth redirect URI override for MCP login. pub mcp_oauth_callback_url: Option<String>, /// Whether skill MCP dependency installation prompts are enabled. pub skill_mcp_dependency_install_enabled: bool, /// Approval policy used for MCP tool calls and MCP elicitation requests. pub approval_policy: Constrained<AskForApproval>, /// Optional path to `codex-linux-sandbox` for sandboxed MCP tool execution. pub codex_linux_sandbox_exe: Option<PathBuf>, /// Whether to use legacy Landlock behavior in the MCP sandbox state. pub use_legacy_landlock: bool, /// Whether the app MCP integration is enabled by config. /// /// ChatGPT auth is checked separately before a materialized host-owned Apps /// server can be used. pub apps_enabled: bool, /// Whether model-visible MCP tool namespaces should keep the legacy /// `mcp__` prefix. pub prefix_mcp_tool_names: bool, /// Client-side elicitation capabilities advertised during MCP initialization. pub client_elicitation_capability: ElicitationCapability, /// Resolved MCP registrations keyed by logical server name. pub mcp_server_catalog: ResolvedMcpCatalog, /// Plugin metadata used to attribute connector tools to plugin display names. /// MCP registrations retain their own package attribution in the catalog. pub plugin_capability_summaries: Vec<PluginCapabilitySummary>,}#[derive(Debug, Clone, Default, PartialEq, Eq)]pub struct ToolPluginProvenance { plugin_display_names_by_connector_id: HashMap<String, Vec<String>>, plugin_display_names_by_mcp_server_name: HashMap<String, Vec<String>>, plugin_ids_by_mcp_server_name: HashMap<String, String>, selected_plugin_mcp_server_names: HashSet<String>,}impl ToolPluginProvenance { pub fn plugin_display_names_for_connector_id(&self, connector_id: &str) -> &[String] { self.plugin_display_names_by_connector_id .get(connector_id) .map(Vec::as_slice) .unwrap_or(&[]) } pub fn plugin_display_names_for_mcp_server_name(&self, server_name: &str) -> &[String] { self.plugin_display_names_by_mcp_server_name .get(server_name) .map(Vec::as_slice) .unwrap_or(&[]) } pub fn plugin_id_for_mcp_server_name(&self, server_name: &str) -> Option<&str> { self.plugin_ids_by_mcp_server_name .get(server_name) .map(String::as_str) } pub(crate) fn is_selected_plugin_mcp_server(&self, server_name: &str) -> bool { self.selected_plugin_mcp_server_names.contains(server_name) } fn from_config(config: &McpConfig) -> Self { let mut tool_plugin_provenance = Self::default(); for plugin in &config.plugin_capability_summaries { for connector_id in &plugin.app_connector_ids { tool_plugin_provenance .plugin_display_names_by_connector_id .entry(connector_id.0.clone()) .or_default() .push(plugin.display_name.clone()); } } for (server_name, attribution) in config .mcp_server_catalog .plugin_attributions_by_server_name() { tool_plugin_provenance .plugin_display_names_by_mcp_server_name .insert( server_name.clone(), vec![attribution.display_name().to_string()], ); tool_plugin_provenance .plugin_ids_by_mcp_server_name .insert(server_name, attribution.plugin_id().to_string()); } tool_plugin_provenance .selected_plugin_mcp_server_names .extend( config .mcp_server_catalog .selected_plugin_server_names() .map(str::to_string), ); for plugin_names in tool_plugin_provenance .plugin_display_names_by_connector_id .values_mut() .chain( tool_plugin_provenance .plugin_display_names_by_mcp_server_name .values_mut(), ) { plugin_names.sort_unstable(); plugin_names.dedup(); } tool_plugin_provenance }}pub fn host_owned_codex_apps_enabled(config: &McpConfig, auth: Option<&CodexAuth>) -> bool { config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend)}pub fn configured_mcp_servers(config: &McpConfig) -> HashMap<String, McpServerConfig> { config.mcp_server_catalog.configured_servers()}pub fn effective_mcp_servers( config: &McpConfig, auth: Option<&CodexAuth>,) -> HashMap<String, EffectiveMcpServer> { effective_mcp_servers_from_configured(configured_mcp_servers(config), config, auth)}/// Converts a materialized server map to its auth-gated runtime view.////// Compatibility built-ins and extension overlays must already be reflected in/// `configured_servers`; this function does not synthesize missing servers.pub fn effective_mcp_servers_from_configured( configured_servers: HashMap<String, McpServerConfig>, config: &McpConfig, auth: Option<&CodexAuth>,) -> HashMap<String, EffectiveMcpServer> { let mut servers = configured_servers .into_iter() .map(|(name, server)| (name, EffectiveMcpServer::configured(server))) .collect::<HashMap<_, _>>(); if !host_owned_codex_apps_enabled(config, auth) { servers.remove(CODEX_APPS_MCP_SERVER_NAME); } servers}pub fn tool_plugin_provenance(config: &McpConfig) -> ToolPluginProvenance { ToolPluginProvenance::from_config(config)}pub async fn read_mcp_resource( config: &McpConfig, auth: Option<&CodexAuth>, runtime_context: McpRuntimeContext, server: &str, uri: &str,) -> anyhow::Result<ReadResourceResult> { let mut mcp_servers = effective_mcp_servers(config, auth); let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); mcp_servers.retain(|name, _| name == server); let auth_statuses = compute_auth_statuses( mcp_servers.iter(), config.mcp_oauth_credentials_store_mode, config.auth_keyring_backend_kind, auth, ) .await; let (tx_event, rx_event) = unbounded(); drop(rx_event); let cancel_token = CancellationToken::new(); let manager = McpConnectionManager::new( &mcp_servers, config.mcp_oauth_credentials_store_mode, config.auth_keyring_backend_kind, auth_statuses, &config.approval_policy, String::new(), tx_event, cancel_token.clone(), PermissionProfile::default(), runtime_context, config.codex_home.clone(), codex_apps_tools_cache_key(auth), host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), tool_plugin_provenance(config), auth, /*elicitation_reviewer*/ None, ) .await; let result = manager .read_resource(server, ReadResourceRequestParams::new(uri)) .await; cancel_token.cancel(); result}#[derive(Debug, Clone)]pub struct McpServerStatusSnapshot { pub server_infos: HashMap<String, McpServerInfo>, pub tools_by_server: HashMap<String, HashMap<String, Tool>>, pub resources: HashMap<String, Vec<Resource>>, pub resource_templates: HashMap<String, Vec<ResourceTemplate>>, pub auth_statuses: HashMap<String, McpAuthStatus>, pub server_names: Vec<String>,}pub async fn collect_mcp_server_status_snapshot_with_detail( config: &McpConfig, auth: Option<&CodexAuth>, submit_id: String, runtime_context: McpRuntimeContext, detail: McpSnapshotDetail,) -> McpServerStatusSnapshot { let mcp_servers = effective_mcp_servers(config, auth); let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); let tool_plugin_provenance = tool_plugin_provenance(config); if mcp_servers.is_empty() { return McpServerStatusSnapshot { server_infos: HashMap::new(), tools_by_server: HashMap::new(), resources: HashMap::new(), resource_templates: HashMap::new(), auth_statuses: HashMap::new(), server_names: Vec::new(), }; } let auth_status_entries = compute_auth_statuses( mcp_servers.iter(), config.mcp_oauth_credentials_store_mode, config.auth_keyring_backend_kind, auth, ) .await; let server_names = mcp_servers.keys().cloned().collect(); 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.clone(), &config.approval_policy, submit_id, tx_event, cancel_token.clone(), PermissionProfile::default(), runtime_context, config.codex_home.clone(), codex_apps_tools_cache_key(auth), host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), tool_plugin_provenance, auth, /*elicitation_reviewer*/ None, ) .await; let snapshot = collect_mcp_server_status_snapshot_from_manager( &mcp_connection_manager, auth_status_entries, server_names, detail, ) .await; cancel_token.cancel(); snapshot}/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`./// MCP server/tool names are user-controlled, so sanitize the fully-qualified/// name we expose to the model by replacing any disallowed character with `_`.pub(crate) fn sanitize_responses_api_tool_name(name: &str) -> String { let mut sanitized = String::with_capacity(name.len()); for c in name.chars() { if c.is_ascii_alphanumeric() || c == '_' { sanitized.push(c); } else { sanitized.push('_'); } } if sanitized.is_empty() { "_".to_string() } else { sanitized }}fn codex_apps_mcp_bearer_token_env_var() -> Option<String> { match env::var(CODEX_CONNECTORS_TOKEN_ENV_VAR) { Ok(value) if !value.trim().is_empty() => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()), Ok(_) => None, Err(env::VarError::NotPresent) => None, Err(env::VarError::NotUnicode(_)) => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()), }}fn normalize_codex_apps_base_url(base_url: &str) -> String { let mut base_url = base_url.trim_end_matches('/').to_string(); if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") { base_url = format!("{base_url}/backend-api"); } base_url}fn codex_apps_mcp_url_for_base_url(base_url: &str) -> String { let base_url = normalize_codex_apps_base_url(base_url); let (base_url, default_path) = if base_url.contains("/backend-api") { (base_url, "wham/apps") } else if base_url.contains("/api/codex") { (base_url, "apps") } else { (format!("{base_url}/api/codex"), "apps") }; format!("{base_url}/{default_path}")}pub fn codex_apps_mcp_server_config( chatgpt_base_url: &str, apps_mcp_product_sku: Option<&str>,) -> McpServerConfig { mcp_server_config_for_url( codex_apps_mcp_url_for_base_url(chatgpt_base_url), apps_mcp_product_sku, )}/// Builds the ChatGPT-hosted plugin runtime served by plugin-service.pub fn hosted_plugin_runtime_mcp_server_config( chatgpt_base_url: &str, apps_mcp_product_sku: Option<&str>,) -> McpServerConfig { let base_url = normalize_codex_apps_base_url(chatgpt_base_url); let base_url = if base_url.contains("/backend-api") || base_url.contains("/api/codex") { base_url } else { format!("{base_url}/api/codex") }; mcp_server_config_for_url(format!("{base_url}/ps/mcp"), apps_mcp_product_sku)}fn mcp_server_config_for_url(url: String, apps_mcp_product_sku: Option<&str>) -> McpServerConfig { let http_headers = apps_mcp_product_sku.map(|product_sku| { HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.to_string())]) }); McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var: codex_apps_mcp_bearer_token_env_var(), http_headers, env_http_headers: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(30)), tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }}fn protocol_tool_from_rmcp_tool(name: &str, tool: &rmcp::model::Tool) -> Option<Tool> { match serde_json::to_value(tool) { Ok(value) => match Tool::from_mcp_value(value) { Ok(tool) => Some(tool), Err(err) => { tracing::warn!("Failed to convert MCP tool '{name}': {err}"); None } }, Err(err) => { tracing::warn!("Failed to serialize MCP tool '{name}': {err}"); None } }}fn auth_statuses_from_entries( auth_status_entries: &HashMap<String, crate::mcp::auth::McpAuthStatusEntry>,) -> HashMap<String, McpAuthStatus> { auth_status_entries .iter() .map(|(name, entry)| (name.clone(), entry.auth_status)) .collect::<HashMap<_, _>>()}fn convert_mcp_resources( resources: HashMap<String, Vec<rmcp::model::Resource>>,) -> HashMap<String, Vec<Resource>> { resources .into_iter() .map(|(name, resources)| { let resources = resources .into_iter() .filter_map(|resource| match serde_json::to_value(resource) { Ok(value) => match Resource::from_mcp_value(value.clone()) { Ok(resource) => Some(resource), Err(err) => { let (uri, resource_name) = match value { Value::Object(obj) => ( obj.get("uri") .and_then(|v| v.as_str().map(ToString::to_string)), obj.get("name") .and_then(|v| v.as_str().map(ToString::to_string)), ), _ => (None, None), }; tracing::warn!( "Failed to convert MCP resource (uri={uri:?}, name={resource_name:?}): {err}" ); None } }, Err(err) => { tracing::warn!("Failed to serialize MCP resource: {err}"); None } }) .collect::<Vec<_>>(); (name, resources) }) .collect::<HashMap<_, _>>()}fn convert_mcp_resource_templates( resource_templates: HashMap<String, Vec<rmcp::model::ResourceTemplate>>,) -> HashMap<String, Vec<ResourceTemplate>> { resource_templates .into_iter() .map(|(name, templates)| { let templates = templates .into_iter() .filter_map(|template| match serde_json::to_value(template) { Ok(value) => match ResourceTemplate::from_mcp_value(value.clone()) { Ok(template) => Some(template), Err(err) => { let (uri_template, template_name) = match value { Value::Object(obj) => ( obj.get("uriTemplate") .or_else(|| obj.get("uri_template")) .and_then(|v| v.as_str().map(ToString::to_string)), obj.get("name") .and_then(|v| v.as_str().map(ToString::to_string)), ), _ => (None, None), }; tracing::warn!( "Failed to convert MCP resource template (uri_template={uri_template:?}, name={template_name:?}): {err}" ); None } }, Err(err) => { tracing::warn!("Failed to serialize MCP resource template: {err}"); None } }) .collect::<Vec<_>>(); (name, templates) }) .collect::<HashMap<_, _>>()}async fn collect_mcp_server_status_snapshot_from_manager( mcp_connection_manager: &McpConnectionManager, auth_status_entries: HashMap<String, crate::mcp::auth::McpAuthStatusEntry>, server_names: Vec<String>, detail: McpSnapshotDetail,) -> McpServerStatusSnapshot { let (tools, resources, resource_templates) = tokio::join!( mcp_connection_manager.list_all_tools(), async { if detail.include_resources() { mcp_connection_manager.list_all_resources().await } else { HashMap::new() } }, async { if detail.include_resources() { mcp_connection_manager.list_all_resource_templates().await } else { HashMap::new() } }, ); let server_infos = mcp_connection_manager.list_available_server_infos().await; let mut tools_by_server = HashMap::<String, HashMap<String, Tool>>::new(); for tool_info in tools { let raw_tool_name = tool_info.tool.name.to_string(); let Some(tool) = protocol_tool_from_rmcp_tool(&raw_tool_name, &tool_info.tool) else { continue; }; let tool_name = tool.name.clone(); tools_by_server .entry(tool_info.server_name) .or_default() .insert(tool_name, tool); } McpServerStatusSnapshot { server_infos, tools_by_server, resources: convert_mcp_resources(resources), resource_templates: convert_mcp_resource_templates(resource_templates), auth_statuses: auth_statuses_from_entries(&auth_status_entries), server_names, }}#[cfg(test)]#[path = "mod_tests.rs"]mod tests;