Codex Handbook
codex-mcp/src/codex_apps.rs 316 lines
//! 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<String>,    pub(crate) chatgpt_user_id: Option<String>,    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<ToolInfo>),    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<Vec<ToolInfo>> {    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<McpServerInfo> {    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<Vec<ToolInfo>> {    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<McpServerInfo> {    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<ToolInfo>) -> Vec<ToolInfo> {    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<ToolInfo>,}#[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}")}