Codex Handbook
core/src/mcp_skill_dependencies.rs 471 lines
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}