Codex Handbook
codex-mcp/src/mcp/mod.rs 660 lines
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;