//! Codex Apps support for the host-owned apps MCP server. //! //! This module owns the pieces that are unique to ChatGPT-hosted app //! connectors: cache scoping by authenticated user, disk cache reads/writes, //! connector allow-list filtering, and the normalization that turns app //! connector/tool metadata into model-visible MCP callable names. use std::path::PathBuf; use std::time::Instant; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::runtime::emit_duration; use crate::tools::MCP_TOOLS_CACHE_WRITE_DURATION_METRIC; use crate::tools::ToolInfo; use anyhow::Context; use codex_login::CodexAuth; use codex_protocol::mcp::McpServerInfo; use codex_utils_plugins::mcp_connector::is_connector_id_allowed; use codex_utils_plugins::mcp_connector::sanitize_name; use serde::Deserialize; use serde::Serialize; use sha1::Digest; use sha1::Sha1; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CodexAppsToolsCacheKey { pub(crate) account_id: Option, pub(crate) chatgpt_user_id: Option, pub(crate) is_workspace_account: bool, } pub fn codex_apps_tools_cache_key(auth: Option<&CodexAuth>) -> CodexAppsToolsCacheKey { CodexAppsToolsCacheKey { account_id: auth.and_then(CodexAuth::get_account_id), chatgpt_user_id: auth.and_then(CodexAuth::get_chatgpt_user_id), is_workspace_account: auth.is_some_and(CodexAuth::is_workspace_account), } } #[derive(Clone)] pub(crate) struct CodexAppsToolsCacheContext { pub(crate) codex_home: PathBuf, pub(crate) user_key: CodexAppsToolsCacheKey, } impl CodexAppsToolsCacheContext { pub(crate) fn tools_cache_path(&self) -> PathBuf { self.cache_path_in(CODEX_APPS_TOOLS_CACHE_DIR) } pub(crate) fn server_info_cache_path(&self) -> PathBuf { self.cache_path_in(CODEX_APPS_SERVER_INFO_CACHE_DIR) } fn cache_path_in(&self, cache_dir: &str) -> PathBuf { let user_key_json = serde_json::to_string(&self.user_key).unwrap_or_default(); let user_key_hash = sha1_hex(&user_key_json); self.codex_home .join(cache_dir) .join(format!("{user_key_hash}.json")) } } pub(crate) enum CachedCodexAppsToolsLoad { Hit(Vec), Missing, Invalid, } pub(crate) fn normalize_codex_apps_tool_title( server_name: &str, connector_name: Option<&str>, value: &str, ) -> String { if server_name != CODEX_APPS_MCP_SERVER_NAME { return value.to_string(); } let Some(connector_name) = connector_name .map(str::trim) .filter(|name| !name.is_empty()) else { return value.to_string(); }; let prefix = format!("{connector_name}_"); if let Some(stripped) = value.strip_prefix(&prefix) && !stripped.is_empty() { return stripped.to_string(); } value.to_string() } pub(crate) fn normalize_codex_apps_callable_name( server_name: &str, tool_name: &str, connector_id: Option<&str>, connector_name: Option<&str>, ) -> String { if server_name != CODEX_APPS_MCP_SERVER_NAME { return tool_name.to_string(); } let tool_name = sanitize_name(tool_name); if let Some(connector_name) = connector_name .map(str::trim) .map(sanitize_name) .filter(|name| !name.is_empty()) && let Some(stripped) = tool_name.strip_prefix(&connector_name) && !stripped.is_empty() { return stripped.to_string(); } if let Some(connector_id) = connector_id .map(str::trim) .map(sanitize_name) .filter(|name| !name.is_empty()) && let Some(stripped) = tool_name.strip_prefix(&connector_id) && !stripped.is_empty() { return stripped.to_string(); } tool_name } pub(crate) fn normalize_codex_apps_callable_namespace( server_name: &str, connector_name: Option<&str>, ) -> String { if server_name == CODEX_APPS_MCP_SERVER_NAME && let Some(connector_name) = connector_name { format!("{}__{}", server_name, sanitize_name(connector_name)) } else { server_name.to_string() } } pub(crate) fn write_cached_codex_apps_tools_if_needed( server_name: &str, cache_context: Option<&CodexAppsToolsCacheContext>, server_info: &McpServerInfo, tools: &[ToolInfo], ) { if server_name != CODEX_APPS_MCP_SERVER_NAME { return; } if let Some(cache_context) = cache_context { let cache_write_start = Instant::now(); write_cached_codex_apps_tools(cache_context, tools); if let Err(err) = write_cached_codex_apps_server_info(cache_context, server_info) { tracing::warn!("failed to write Codex Apps server info cache: {err:#}"); } emit_duration( MCP_TOOLS_CACHE_WRITE_DURATION_METRIC, cache_write_start.elapsed(), &[], ); } } pub(crate) fn load_startup_cached_codex_apps_tools_snapshot( server_name: &str, cache_context: Option<&CodexAppsToolsCacheContext>, ) -> Option> { if server_name != CODEX_APPS_MCP_SERVER_NAME { return None; } let cache_context = cache_context?; match load_cached_codex_apps_tools(cache_context) { CachedCodexAppsToolsLoad::Hit(tools) => Some(tools), CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None, } } pub(crate) fn load_startup_cached_codex_apps_server_info( server_name: &str, cache_context: Option<&CodexAppsToolsCacheContext>, ) -> Option { if server_name != CODEX_APPS_MCP_SERVER_NAME { return None; } load_cached_codex_apps_server_info(cache_context?) } #[cfg(test)] pub(crate) fn read_cached_codex_apps_tools( cache_context: &CodexAppsToolsCacheContext, ) -> Option> { match load_cached_codex_apps_tools(cache_context) { CachedCodexAppsToolsLoad::Hit(tools) => Some(tools), CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None, } } pub(crate) fn load_cached_codex_apps_tools( cache_context: &CodexAppsToolsCacheContext, ) -> CachedCodexAppsToolsLoad { let cache_path = cache_context.tools_cache_path(); let bytes = match std::fs::read(cache_path) { Ok(bytes) => bytes, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { return CachedCodexAppsToolsLoad::Missing; } Err(_) => return CachedCodexAppsToolsLoad::Invalid, }; let cache: CodexAppsToolsDiskCache = match serde_json::from_slice(&bytes) { Ok(cache) => cache, Err(_) => return CachedCodexAppsToolsLoad::Invalid, }; if cache.schema_version != CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION { return CachedCodexAppsToolsLoad::Invalid; } CachedCodexAppsToolsLoad::Hit(filter_disallowed_codex_apps_tools(cache.tools)) } pub(crate) fn write_cached_codex_apps_tools( cache_context: &CodexAppsToolsCacheContext, tools: &[ToolInfo], ) { let cache_path = cache_context.tools_cache_path(); if let Some(parent) = cache_path.parent() && std::fs::create_dir_all(parent).is_err() { return; } let tools = filter_disallowed_codex_apps_tools(tools.to_vec()); let Ok(bytes) = serde_json::to_vec_pretty(&CodexAppsToolsDiskCache { schema_version: CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION, tools, }) else { return; }; let _ = std::fs::write(cache_path, bytes); } pub(crate) fn load_cached_codex_apps_server_info( cache_context: &CodexAppsToolsCacheContext, ) -> Option { let bytes = std::fs::read(cache_context.server_info_cache_path()).ok()?; let cache: CodexAppsServerInfoDiskCache = serde_json::from_slice(&bytes).ok()?; (cache.schema_version == CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION) .then_some(cache.server_info) } fn write_cached_codex_apps_server_info( cache_context: &CodexAppsToolsCacheContext, server_info: &McpServerInfo, ) -> anyhow::Result<()> { let cache_path = cache_context.server_info_cache_path(); if let Some(parent) = cache_path.parent() { std::fs::create_dir_all(parent).with_context(|| { format!( "failed to create Codex Apps server info cache directory `{}`", parent.display() ) })?; } let bytes = serde_json::to_vec_pretty(&CodexAppsServerInfoDiskCache { schema_version: CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION, server_info: server_info.clone(), }) .context("failed to serialize Codex Apps server info cache")?; std::fs::write(&cache_path, bytes).with_context(|| { format!( "failed to write Codex Apps server info cache `{}`", cache_path.display() ) })?; Ok(()) } pub(crate) fn filter_disallowed_codex_apps_tools(tools: Vec) -> Vec { tools .into_iter() .filter(|tool| { tool.connector_id .as_deref() .is_none_or(is_connector_id_allowed) }) .collect() } #[derive(Debug, Clone, Serialize, Deserialize)] struct CodexAppsToolsDiskCache { schema_version: u8, tools: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] struct CodexAppsServerInfoDiskCache { schema_version: u8, server_info: McpServerInfo, } const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools"; pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 3; const CODEX_APPS_SERVER_INFO_CACHE_DIR: &str = "cache/codex_apps_server_info"; const CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION: u8 = 1; fn sha1_hex(s: &str) -> String { let mut hasher = Sha1::new(); hasher.update(s.as_bytes()); let sha1 = hasher.finalize(); format!("{sha1:x}") }