use std::collections::HashMap;use std::collections::HashSet;use codex_config::ConfigEditsBuilder;use codex_config::McpServerConfig;use codex_config::McpServerTransportConfig;use codex_config::load_global_mcp_servers;use codex_login::default_client::is_first_party_originator;use codex_login::default_client::originator;use codex_protocol::request_user_input::RequestUserInputArgs;use codex_protocol::request_user_input::RequestUserInputQuestion;use codex_protocol::request_user_input::RequestUserInputQuestionOption;use codex_protocol::request_user_input::RequestUserInputResponse;use codex_rmcp_client::perform_oauth_login;use tokio_util::sync::CancellationToken;use tracing::warn;use crate::SkillMetadata;use crate::session::session::Session;use crate::session::turn_context::TurnContext;use crate::skills::model::SkillToolDependency;use codex_mcp::ElicitationReviewerHandle;use codex_mcp::McpOAuthLoginSupport;use codex_mcp::McpPermissionPromptAutoApproveContext;use codex_mcp::mcp_permission_prompt_is_auto_approved;use codex_mcp::oauth_login_support;use codex_mcp::resolve_oauth_scopes;use codex_mcp::should_retry_without_scopes;const SKILL_MCP_DEPENDENCY_PROMPT_ID: &str = "skill_mcp_dependency_install";const MCP_DEPENDENCY_OPTION_INSTALL: &str = "Install";const MCP_DEPENDENCY_OPTION_SKIP: &str = "Continue anyway";pub(crate) async fn maybe_prompt_and_install_mcp_dependencies( sess: &Session, turn_context: &TurnContext, cancellation_token: &CancellationToken, mentioned_skills: &[SkillMetadata], elicitation_reviewer: Option<ElicitationReviewerHandle>,) { let originator_value = originator().value; if !is_first_party_originator(originator_value.as_str()) { // Only support first-party clients for now. return; } let config = turn_context.config.clone(); if mentioned_skills.is_empty() || !config .features .enabled(codex_features::Feature::SkillMcpDependencyInstall) { return; } let installed = sess.runtime_mcp_servers(config.as_ref()).await; let missing = collect_missing_mcp_dependencies(mentioned_skills, &installed); if missing.is_empty() { return; } let unprompted_missing = filter_prompted_mcp_dependencies(sess, &missing).await; if unprompted_missing.is_empty() { return; } if should_install_mcp_dependencies(sess, turn_context, &unprompted_missing, cancellation_token) .await { maybe_install_mcp_dependencies( sess, turn_context, config.as_ref(), mentioned_skills, elicitation_reviewer, ) .await; }}pub(crate) async fn maybe_install_mcp_dependencies( sess: &Session, turn_context: &TurnContext, config: &crate::config::Config, mentioned_skills: &[SkillMetadata], elicitation_reviewer: Option<ElicitationReviewerHandle>,) { if mentioned_skills.is_empty() || !config .features .enabled(codex_features::Feature::SkillMcpDependencyInstall) { return; } let codex_home = config.codex_home.clone(); let installed = sess.runtime_mcp_servers(config).await; let missing = collect_missing_mcp_dependencies(mentioned_skills, &installed); if missing.is_empty() { return; } let mut servers = match load_global_mcp_servers(&codex_home).await { Ok(servers) => servers, Err(err) => { warn!("failed to load MCP servers while installing skill dependencies: {err}"); return; } }; let mut updated = false; let mut added = Vec::new(); for (name, config) in missing { if servers.contains_key(&name) { continue; } servers.insert(name.clone(), config.clone()); added.push((name, config)); updated = true; } if !updated { return; } if let Err(err) = ConfigEditsBuilder::new(&codex_home) .replace_mcp_servers(&servers) .apply() .await { warn!("failed to persist MCP dependencies for mentioned skills: {err}"); return; } for (name, server_config) in added { let oauth_config = match oauth_login_support(&server_config.transport).await { McpOAuthLoginSupport::Supported(config) => config, McpOAuthLoginSupport::Unsupported => continue, McpOAuthLoginSupport::Unknown(err) => { warn!("MCP server may or may not require login for dependency {name}: {err}"); continue; } }; let resolved_scopes = resolve_oauth_scopes( /*explicit_scopes*/ None, server_config.scopes.clone(), oauth_config.discovered_scopes.clone(), ); let oauth_client_id = server_config.oauth_client_id(); let first_attempt = perform_oauth_login( &name, &oauth_config.url, config.mcp_oauth_credentials_store_mode, config.auth_keyring_backend_kind(), oauth_config.http_headers.clone(), oauth_config.env_http_headers.clone(), &resolved_scopes.scopes, oauth_client_id, server_config.oauth_resource.as_deref(), config.mcp_oauth_callback_port, config.mcp_oauth_callback_url.as_deref(), ) .await; if let Err(err) = first_attempt { if should_retry_without_scopes(&resolved_scopes, &err) { if let Err(err) = perform_oauth_login( &name, &oauth_config.url, config.mcp_oauth_credentials_store_mode, config.auth_keyring_backend_kind(), oauth_config.http_headers, oauth_config.env_http_headers, &[], oauth_client_id, server_config.oauth_resource.as_deref(), config.mcp_oauth_callback_port, config.mcp_oauth_callback_url.as_deref(), ) .await { warn!("failed to login to MCP dependency {name}: {err}"); } } else { warn!("failed to login to MCP dependency {name}: {err}"); } } } let mut refresh_config = config.clone(); let mut configured_servers = config.mcp_servers.get().clone(); for (name, server_config) in &servers { configured_servers .entry(name.clone()) .or_insert_with(|| server_config.clone()); } if let Err(err) = refresh_config.mcp_servers.set(configured_servers) { warn!("failed to refresh MCP dependencies for mentioned skills: {err}"); return; } let refresh_servers = sess.runtime_mcp_servers(&refresh_config).await; sess.refresh_mcp_servers_now( turn_context, refresh_servers, config.mcp_oauth_credentials_store_mode, config.auth_keyring_backend_kind(), elicitation_reviewer, ) .await;}async fn should_install_mcp_dependencies( sess: &Session, turn_context: &TurnContext, missing: &HashMap<String, McpServerConfig>, cancellation_token: &CancellationToken,) -> bool { if mcp_permission_prompt_is_auto_approved( turn_context.approval_policy.value(), &turn_context.permission_profile(), McpPermissionPromptAutoApproveContext::default(), ) { return true; } let server_list = format_missing_mcp_dependencies(missing); let question = RequestUserInputQuestion { id: SKILL_MCP_DEPENDENCY_PROMPT_ID.to_string(), header: "Install MCP servers?".to_string(), question: format!( "The following MCP servers are required by the selected skills but are not installed yet: {server_list}. Install them now?" ), is_other: false, is_secret: false, options: Some(vec![ RequestUserInputQuestionOption { label: MCP_DEPENDENCY_OPTION_INSTALL.to_string(), description: "Install and enable the missing MCP servers in your global config." .to_string(), }, RequestUserInputQuestionOption { label: MCP_DEPENDENCY_OPTION_SKIP.to_string(), description: "Skip installation for now and do not show again for these MCP servers in this session." .to_string(), }, ]), }; let args = RequestUserInputArgs { questions: vec![question], auto_resolution_ms: None, }; let sub_id = &turn_context.sub_id; let call_id = format!("mcp-deps-{sub_id}"); let response_fut = sess.request_user_input(turn_context, call_id, args); let response = tokio::select! { biased; _ = cancellation_token.cancelled() => { let empty = RequestUserInputResponse { answers: HashMap::new(), }; sess.notify_user_input_response(sub_id, empty.clone()).await; empty } response = response_fut => response.unwrap_or_else(|| RequestUserInputResponse { answers: HashMap::new(), }), }; let install = response .answers .get(SKILL_MCP_DEPENDENCY_PROMPT_ID) .is_some_and(|answer| { answer .answers .iter() .any(|entry| entry == MCP_DEPENDENCY_OPTION_INSTALL) }); let prompted_keys = missing .iter() .map(|(name, config)| canonical_mcp_server_key(name, config)); sess.record_mcp_dependency_prompted(prompted_keys).await; install}async fn filter_prompted_mcp_dependencies( sess: &Session, missing: &HashMap<String, McpServerConfig>,) -> HashMap<String, McpServerConfig> { let prompted = sess.mcp_dependency_prompted().await; if prompted.is_empty() { return missing.clone(); } missing .iter() .filter(|(name, config)| !prompted.contains(&canonical_mcp_server_key(name, config))) .map(|(name, config)| (name.clone(), config.clone())) .collect()}fn format_missing_mcp_dependencies(missing: &HashMap<String, McpServerConfig>) -> String { let mut names = missing.keys().cloned().collect::<Vec<_>>(); names.sort(); names.join(", ")}fn canonical_mcp_key(transport: &str, identifier: &str, fallback: &str) -> String { let identifier = identifier.trim(); if identifier.is_empty() { fallback.to_string() } else { format!("mcp__{transport}__{identifier}") }}fn canonical_mcp_server_key(name: &str, config: &McpServerConfig) -> String { match &config.transport { McpServerTransportConfig::Stdio { command, .. } => { canonical_mcp_key("stdio", command, name) } McpServerTransportConfig::StreamableHttp { url, .. } => { canonical_mcp_key("streamable_http", url, name) } }}fn canonical_mcp_dependency_key(dependency: &SkillToolDependency) -> Result<String, String> { let transport = dependency.transport.as_deref().unwrap_or("streamable_http"); if transport.eq_ignore_ascii_case("streamable_http") { let url = dependency .url .as_ref() .ok_or_else(|| "missing url for streamable_http dependency".to_string())?; return Ok(canonical_mcp_key("streamable_http", url, &dependency.value)); } if transport.eq_ignore_ascii_case("stdio") { let command = dependency .command .as_ref() .ok_or_else(|| "missing command for stdio dependency".to_string())?; return Ok(canonical_mcp_key("stdio", command, &dependency.value)); } Err(format!("unsupported transport {transport}"))}fn mcp_dependency_to_server_config( dependency: &SkillToolDependency,) -> Result<McpServerConfig, String> { let transport = dependency.transport.as_deref().unwrap_or("streamable_http"); if transport.eq_ignore_ascii_case("streamable_http") { let url = dependency .url .as_ref() .ok_or_else(|| "missing url for streamable_http dependency".to_string())?; return Ok(McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url: url.clone(), bearer_token_env_var: None, http_headers: None, 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: None, 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(), }); } if transport.eq_ignore_ascii_case("stdio") { let command = dependency .command .as_ref() .ok_or_else(|| "missing command for stdio dependency".to_string())?; return Ok(McpServerConfig { transport: McpServerTransportConfig::Stdio { command: command.clone(), args: Vec::new(), env: None, env_vars: Vec::new(), cwd: 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: None, 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(), }); } Err(format!("unsupported transport {transport}"))}fn collect_missing_mcp_dependencies( mentioned_skills: &[SkillMetadata], installed: &HashMap<String, McpServerConfig>,) -> HashMap<String, McpServerConfig> { let mut missing = HashMap::new(); let installed_keys: HashSet<String> = installed .iter() .map(|(name, config)| canonical_mcp_server_key(name, config)) .collect(); let mut seen_canonical_keys = HashSet::new(); for skill in mentioned_skills { let Some(dependencies) = skill.dependencies.as_ref() else { continue; }; for tool in &dependencies.tools { if !tool.r#type.eq_ignore_ascii_case("mcp") { continue; } let dependency_key = match canonical_mcp_dependency_key(tool) { Ok(key) => key, Err(err) => { let dependency = tool.value.as_str(); let skill_name = skill.name.as_str(); warn!( "unable to auto-install MCP dependency {dependency} for skill {skill_name}: {err}", ); continue; } }; if installed_keys.contains(&dependency_key) || seen_canonical_keys.contains(&dependency_key) { continue; } let config = match mcp_dependency_to_server_config(tool) { Ok(config) => config, Err(err) => { let dependency = dependency_key.as_str(); let skill_name = skill.name.as_str(); warn!( "unable to auto-install MCP dependency {dependency} for skill {skill_name}: {err}", ); continue; } }; missing.insert(tool.value.clone(), config); seen_canonical_keys.insert(dependency_key); } } missing}