pub mod auth; mod skill_dependencies; pub(crate) use skill_dependencies::maybe_prompt_and_install_mcp_dependencies; use std::collections::HashMap; use std::env; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use async_channel::unbounded; use codex_protocol::mcp::Resource; use codex_protocol::mcp::ResourceTemplate; use codex_protocol::mcp::Tool; use codex_protocol::protocol::McpListToolsResponseEvent; use codex_protocol::protocol::SandboxPolicy; use serde_json::Value; use crate::AuthManager; use crate::CodexAuth; use crate::config::Config; use crate::config::types::McpServerConfig; use crate::config::types::McpServerTransportConfig; use crate::features::Feature; use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_connection_manager::SandboxState; use crate::mcp_connection_manager::codex_apps_tools_cache_key; use crate::plugins::PluginCapabilitySummary; use crate::plugins::PluginsManager; const MCP_TOOL_NAME_PREFIX: &str = "mcp"; const MCP_TOOL_NAME_DELIMITER: &str = "__"; pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps"; const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN"; const OPENAI_CONNECTORS_MCP_BASE_URL: &str = "https://api.openai.com"; const OPENAI_CONNECTORS_MCP_PATH: &str = "/v1/connectors/gateways/flat/mcp"; #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ToolPluginProvenance { plugin_display_names_by_connector_id: HashMap>, plugin_display_names_by_mcp_server_name: HashMap>, } 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(&[]) } fn from_capability_summaries(capability_summaries: &[PluginCapabilitySummary]) -> Self { let mut tool_plugin_provenance = Self::default(); for plugin in 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 in &plugin.mcp_server_names { tool_plugin_provenance .plugin_display_names_by_mcp_server_name .entry(server_name.clone()) .or_default() .push(plugin.display_name.clone()); } } 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 } } // Legacy vs new MCP gateway #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CodexAppsMcpGateway { LegacyMCPGateway, MCPGateway, } fn codex_apps_mcp_bearer_token_env_var() -> Option { 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 codex_apps_mcp_bearer_token(auth: Option<&CodexAuth>) -> Option { let token = auth.and_then(|auth| auth.get_token().ok())?; let token = token.trim(); if token.is_empty() { None } else { Some(token.to_string()) } } fn codex_apps_mcp_http_headers(auth: Option<&CodexAuth>) -> Option> { let mut headers = HashMap::new(); if let Some(token) = codex_apps_mcp_bearer_token(auth) { headers.insert("Authorization".to_string(), format!("Bearer {token}")); } if let Some(account_id) = auth.and_then(CodexAuth::get_account_id) { headers.insert("ChatGPT-Account-ID".to_string(), account_id); } if headers.is_empty() { None } else { Some(headers) } } fn selected_config_codex_apps_mcp_gateway(config: &Config) -> CodexAppsMcpGateway { if config.features.enabled(Feature::AppsMcpGateway) { CodexAppsMcpGateway::MCPGateway } else { CodexAppsMcpGateway::LegacyMCPGateway } } 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_gateway(base_url: &str, gateway: CodexAppsMcpGateway) -> String { if gateway == CodexAppsMcpGateway::MCPGateway { return format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}"); } let base_url = normalize_codex_apps_base_url(base_url); if base_url.contains("/backend-api") { format!("{base_url}/wham/apps") } else if base_url.contains("/api/codex") { format!("{base_url}/apps") } else { format!("{base_url}/api/codex/apps") } } pub(crate) fn codex_apps_mcp_url(config: &Config) -> String { codex_apps_mcp_url_for_gateway( &config.chatgpt_base_url, selected_config_codex_apps_mcp_gateway(config), ) } fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> McpServerConfig { let bearer_token_env_var = codex_apps_mcp_bearer_token_env_var(); let http_headers = if bearer_token_env_var.is_some() { None } else { codex_apps_mcp_http_headers(auth) }; let url = codex_apps_mcp_url(config); McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, http_headers, env_http_headers: None, }, enabled: true, required: false, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(30)), tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth_resource: None, } } pub(crate) fn with_codex_apps_mcp( mut servers: HashMap, connectors_enabled: bool, auth: Option<&CodexAuth>, config: &Config, ) -> HashMap { if connectors_enabled { servers.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), codex_apps_mcp_server_config(config, auth), ); } else { servers.remove(CODEX_APPS_MCP_SERVER_NAME); } servers } pub struct McpManager { plugins_manager: Arc, } impl McpManager { pub fn new(plugins_manager: Arc) -> Self { Self { plugins_manager } } pub fn configured_servers(&self, config: &Config) -> HashMap { configured_mcp_servers(config, self.plugins_manager.as_ref()) } pub fn effective_servers( &self, config: &Config, auth: Option<&CodexAuth>, ) -> HashMap { effective_mcp_servers(config, auth, self.plugins_manager.as_ref()) } pub fn tool_plugin_provenance(&self, config: &Config) -> ToolPluginProvenance { let loaded_plugins = self.plugins_manager.plugins_for_config(config); ToolPluginProvenance::from_capability_summaries(loaded_plugins.capability_summaries()) } } fn configured_mcp_servers( config: &Config, plugins_manager: &PluginsManager, ) -> HashMap { let loaded_plugins = plugins_manager.plugins_for_config(config); let mut servers = config.mcp_servers.get().clone(); for (name, plugin_server) in loaded_plugins.effective_mcp_servers() { servers.entry(name).or_insert(plugin_server); } servers } fn effective_mcp_servers( config: &Config, auth: Option<&CodexAuth>, plugins_manager: &PluginsManager, ) -> HashMap { let servers = configured_mcp_servers(config, plugins_manager); with_codex_apps_mcp( servers, config.features.apps_enabled_for_auth(auth), auth, config, ) } pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent { let auth_manager = AuthManager::shared( config.codex_home.clone(), false, config.cli_auth_credentials_store_mode, ); let auth = auth_manager.auth().await; let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone()))); let mcp_servers = mcp_manager.effective_servers(config, auth.as_ref()); let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config); if mcp_servers.is_empty() { return McpListToolsResponseEvent { tools: HashMap::new(), resources: HashMap::new(), resource_templates: HashMap::new(), auth_statuses: HashMap::new(), }; } let auth_status_entries = compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode).await; let (tx_event, rx_event) = unbounded(); drop(rx_event); // Use ReadOnly sandbox policy for MCP snapshot collection (safest default) let sandbox_state = SandboxState { sandbox_policy: SandboxPolicy::new_read_only_policy(), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), sandbox_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")), use_linux_sandbox_bwrap: config.features.enabled(Feature::UseLinuxSandboxBwrap), }; let (mcp_connection_manager, cancel_token) = McpConnectionManager::new( &mcp_servers, config.mcp_oauth_credentials_store_mode, auth_status_entries.clone(), &config.permissions.approval_policy, tx_event, sandbox_state, config.codex_home.clone(), codex_apps_tools_cache_key(auth.as_ref()), tool_plugin_provenance, ) .await; let snapshot = collect_mcp_snapshot_from_manager(&mcp_connection_manager, auth_status_entries).await; cancel_token.cancel(); snapshot } pub fn split_qualified_tool_name(qualified_name: &str) -> Option<(String, String)> { let mut parts = qualified_name.split(MCP_TOOL_NAME_DELIMITER); let prefix = parts.next()?; if prefix != MCP_TOOL_NAME_PREFIX { return None; } let server_name = parts.next()?; let tool_name: String = parts.collect::>().join(MCP_TOOL_NAME_DELIMITER); if tool_name.is_empty() { return None; } Some((server_name.to_string(), tool_name)) } pub fn group_tools_by_server( tools: &HashMap, ) -> HashMap> { let mut grouped = HashMap::new(); for (qualified_name, tool) in tools { if let Some((server_name, tool_name)) = split_qualified_tool_name(qualified_name) { grouped .entry(server_name) .or_insert_with(HashMap::new) .insert(tool_name, tool.clone()); } } grouped } pub(crate) async fn collect_mcp_snapshot_from_manager( mcp_connection_manager: &McpConnectionManager, auth_status_entries: HashMap, ) -> McpListToolsResponseEvent { let (tools, resources, resource_templates) = tokio::join!( mcp_connection_manager.list_all_tools(), mcp_connection_manager.list_all_resources(), mcp_connection_manager.list_all_resource_templates(), ); let auth_statuses = auth_status_entries .iter() .map(|(name, entry)| (name.clone(), entry.auth_status)) .collect(); let tools = tools .into_iter() .filter_map(|(name, tool)| match serde_json::to_value(tool.tool) { Ok(value) => match Tool::from_mcp_value(value) { Ok(tool) => Some((name, 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 } }) .collect(); let resources = 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::>(); (name, resources) }) .collect(); let resource_templates = 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::>(); (name, templates) }) .collect(); McpListToolsResponseEvent { tools, resources, resource_templates, auth_statuses, } } #[cfg(test)] mod tests { use super::*; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::plugins::AppConnectorId; use crate::plugins::PluginCapabilitySummary; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; use toml::Value; fn write_file(path: &Path, contents: &str) { fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); fs::write(path, contents).unwrap(); } fn plugin_config_toml() -> String { let mut root = toml::map::Map::new(); let mut features = toml::map::Map::new(); features.insert("plugins".to_string(), Value::Boolean(true)); root.insert("features".to_string(), Value::Table(features)); let mut plugin = toml::map::Map::new(); plugin.insert("enabled".to_string(), Value::Boolean(true)); let mut plugins = toml::map::Map::new(); plugins.insert("sample@test".to_string(), Value::Table(plugin)); root.insert("plugins".to_string(), Value::Table(plugins)); toml::to_string(&Value::Table(root)).expect("plugin test config should serialize") } fn make_tool(name: &str) -> Tool { Tool { name: name.to_string(), title: None, description: None, input_schema: serde_json::json!({"type": "object", "properties": {}}), output_schema: None, annotations: None, icons: None, meta: None, } } #[test] fn split_qualified_tool_name_returns_server_and_tool() { assert_eq!( split_qualified_tool_name("mcp__alpha__do_thing"), Some(("alpha".to_string(), "do_thing".to_string())) ); } #[test] fn split_qualified_tool_name_rejects_invalid_names() { assert_eq!(split_qualified_tool_name("other__alpha__do_thing"), None); assert_eq!(split_qualified_tool_name("mcp__alpha__"), None); } #[test] fn group_tools_by_server_strips_prefix_and_groups() { let mut tools = HashMap::new(); tools.insert("mcp__alpha__do_thing".to_string(), make_tool("do_thing")); tools.insert( "mcp__alpha__nested__op".to_string(), make_tool("nested__op"), ); tools.insert("mcp__beta__do_other".to_string(), make_tool("do_other")); let mut expected_alpha = HashMap::new(); expected_alpha.insert("do_thing".to_string(), make_tool("do_thing")); expected_alpha.insert("nested__op".to_string(), make_tool("nested__op")); let mut expected_beta = HashMap::new(); expected_beta.insert("do_other".to_string(), make_tool("do_other")); let mut expected = HashMap::new(); expected.insert("alpha".to_string(), expected_alpha); expected.insert("beta".to_string(), expected_beta); assert_eq!(group_tools_by_server(&tools), expected); } #[test] fn tool_plugin_provenance_collects_app_and_mcp_sources() { let provenance = ToolPluginProvenance::from_capability_summaries(&[ PluginCapabilitySummary { display_name: "alpha-plugin".to_string(), app_connector_ids: vec![AppConnectorId("connector_example".to_string())], mcp_server_names: vec!["alpha".to_string()], ..PluginCapabilitySummary::default() }, PluginCapabilitySummary { display_name: "beta-plugin".to_string(), app_connector_ids: vec![ AppConnectorId("connector_example".to_string()), AppConnectorId("connector_gmail".to_string()), ], mcp_server_names: vec!["beta".to_string()], ..PluginCapabilitySummary::default() }, ]); assert_eq!( provenance, ToolPluginProvenance { plugin_display_names_by_connector_id: HashMap::from([ ( "connector_example".to_string(), vec!["alpha-plugin".to_string(), "beta-plugin".to_string()], ), ( "connector_gmail".to_string(), vec!["beta-plugin".to_string()], ), ]), plugin_display_names_by_mcp_server_name: HashMap::from([ ("alpha".to_string(), vec!["alpha-plugin".to_string()]), ("beta".to_string(), vec!["beta-plugin".to_string()]), ]), } ); } #[test] fn codex_apps_mcp_url_for_default_gateway_keeps_existing_paths() { assert_eq!( codex_apps_mcp_url_for_gateway( "https://chatgpt.com/backend-api", CodexAppsMcpGateway::LegacyMCPGateway ), "https://chatgpt.com/backend-api/wham/apps" ); assert_eq!( codex_apps_mcp_url_for_gateway( "https://chat.openai.com", CodexAppsMcpGateway::LegacyMCPGateway ), "https://chat.openai.com/backend-api/wham/apps" ); assert_eq!( codex_apps_mcp_url_for_gateway( "http://localhost:8080/api/codex", CodexAppsMcpGateway::LegacyMCPGateway ), "http://localhost:8080/api/codex/apps" ); assert_eq!( codex_apps_mcp_url_for_gateway( "http://localhost:8080", CodexAppsMcpGateway::LegacyMCPGateway ), "http://localhost:8080/api/codex/apps" ); } #[test] fn codex_apps_mcp_url_for_gateway_uses_openai_connectors_gateway() { let expected_url = format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}"); assert_eq!( codex_apps_mcp_url_for_gateway( "https://chatgpt.com/backend-api", CodexAppsMcpGateway::MCPGateway ), expected_url.as_str() ); assert_eq!( codex_apps_mcp_url_for_gateway( "https://chat.openai.com", CodexAppsMcpGateway::MCPGateway ), expected_url.as_str() ); assert_eq!( codex_apps_mcp_url_for_gateway( "http://localhost:8080/api/codex", CodexAppsMcpGateway::MCPGateway ), expected_url.as_str() ); assert_eq!( codex_apps_mcp_url_for_gateway( "http://localhost:8080", CodexAppsMcpGateway::MCPGateway ), expected_url.as_str() ); } #[test] fn codex_apps_mcp_url_uses_default_gateway_when_feature_is_disabled() { let mut config = crate::config::test_config(); config.chatgpt_base_url = "https://chatgpt.com".to_string(); assert_eq!( codex_apps_mcp_url(&config), "https://chatgpt.com/backend-api/wham/apps" ); } #[test] fn codex_apps_mcp_url_uses_openai_connectors_gateway_when_feature_is_enabled() { let mut config = crate::config::test_config(); config.chatgpt_base_url = "https://chatgpt.com".to_string(); config .features .enable(Feature::AppsMcpGateway) .expect("test config should allow apps gateway"); assert_eq!( codex_apps_mcp_url(&config), format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}") ); } #[test] fn codex_apps_server_config_switches_gateway_with_flags() { let mut config = crate::config::test_config(); config.chatgpt_base_url = "https://chatgpt.com".to_string(); let mut servers = with_codex_apps_mcp(HashMap::new(), false, None, &config); assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME)); config .features .enable(Feature::Apps) .expect("test config should allow apps"); servers = with_codex_apps_mcp(servers, true, None, &config); let server = servers .get(CODEX_APPS_MCP_SERVER_NAME) .expect("codex apps should be present when apps is enabled"); let url = match &server.transport { McpServerTransportConfig::StreamableHttp { url, .. } => url, _ => panic!("expected streamable http transport for codex apps"), }; assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps"); config .features .enable(Feature::AppsMcpGateway) .expect("test config should allow apps gateway"); servers = with_codex_apps_mcp(servers, true, None, &config); let server = servers .get(CODEX_APPS_MCP_SERVER_NAME) .expect("codex apps should remain present when apps stays enabled"); let url = match &server.transport { McpServerTransportConfig::StreamableHttp { url, .. } => url, _ => panic!("expected streamable http transport for codex apps"), }; let expected_url = format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}"); assert_eq!(url, &expected_url); } #[tokio::test] async fn effective_mcp_servers_include_plugins_without_overriding_user_config() { let codex_home = tempfile::tempdir().expect("tempdir"); let plugin_root = codex_home .path() .join("plugins/cache") .join("test/sample/local"); write_file( &plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample"}"#, ); write_file( &plugin_root.join(".mcp.json"), r#"{ "mcpServers": { "sample": { "type": "http", "url": "https://plugin.example/mcp" }, "docs": { "type": "http", "url": "https://docs.example/mcp" } } }"#, ); write_file( &codex_home.path().join(CONFIG_TOML_FILE), &plugin_config_toml(), ); let mut config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .build() .await .expect("config should load"); let mut configured_servers = config.mcp_servers.get().clone(); configured_servers.insert( "sample".to_string(), McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url: "https://user.example/mcp".to_string(), bearer_token_env_var: None, http_headers: None, env_http_headers: None, }, enabled: true, required: false, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth_resource: None, }, ); config .mcp_servers .set(configured_servers) .expect("test config should accept MCP servers"); let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone()))); let effective = mcp_manager.effective_servers(&config, None); let sample = effective.get("sample").expect("user server should exist"); let docs = effective.get("docs").expect("plugin server should exist"); match &sample.transport { McpServerTransportConfig::StreamableHttp { url, .. } => { assert_eq!(url, "https://user.example/mcp"); } other => panic!("expected streamable http transport, got {other:?}"), } match &docs.transport { McpServerTransportConfig::StreamableHttp { url, .. } => { assert_eq!(url, "https://docs.example/mcp"); } other => panic!("expected streamable http transport, got {other:?}"), } } }