Codex Handbook
cli/src/mcp_cmd.rs 1000 lines
use std::collections::HashMap;use std::sync::Arc;use anyhow::Context;use anyhow::Result;use anyhow::anyhow;use anyhow::bail;use clap::ArgGroup;use codex_config::types::AppToolApproval;use codex_config::types::McpServerConfig;use codex_config::types::McpServerOAuthConfig;use codex_config::types::McpServerTransportConfig;use codex_core::McpManager;use codex_core::config::Config;use codex_core::config::ConfigBuilder;use codex_core::config::LoaderOverrides;use codex_core::config::edit::ConfigEditsBuilder;use codex_core::config::find_codex_home;use codex_core::config::load_global_mcp_servers;use codex_core_plugins::PluginsManager;use codex_mcp::McpOAuthLoginSupport;use codex_mcp::ResolvedMcpOAuthScopes;use codex_mcp::compute_auth_statuses;use codex_mcp::discover_supported_scopes;use codex_mcp::oauth_login_support;use codex_mcp::resolve_oauth_scopes;use codex_mcp::should_retry_without_scopes;use codex_protocol::protocol::McpAuthStatus;use codex_rmcp_client::delete_oauth_tokens;use codex_rmcp_client::perform_oauth_login;use codex_utils_cli::CliConfigOverrides;use codex_utils_cli::format_env_display;/// Subcommands:/// - `list`   — list configured servers (with `--json`)/// - `get`    — show a single server (with `--json`)/// - `add`    — add a server launcher entry to `~/.codex/config.toml`/// - `remove` — delete a server entry/// - `login`  — authenticate with MCP server using OAuth/// - `logout` — remove OAuth credentials for MCP server#[derive(Debug, clap::Parser)]pub struct McpCli {    #[clap(flatten)]    pub config_overrides: CliConfigOverrides,    #[command(subcommand)]    pub subcommand: McpSubcommand,}#[derive(Debug, clap::Subcommand)]pub enum McpSubcommand {    List(ListArgs),    Get(GetArgs),    Add(AddArgs),    Remove(RemoveArgs),    Login(LoginArgs),    Logout(LogoutArgs),}#[derive(Debug, clap::Parser)]pub struct ListArgs {    /// Output the configured servers as JSON.    #[arg(long)]    pub json: bool,}#[derive(Debug, clap::Parser)]pub struct GetArgs {    /// Name of the MCP server to display.    pub name: String,    /// Output the server configuration as JSON.    #[arg(long)]    pub json: bool,}#[derive(Debug, clap::Parser)]#[command(override_usage = "codex mcp add [OPTIONS] <NAME> (--url <URL> | -- <COMMAND>...)")]pub struct AddArgs {    /// Name for the MCP server configuration.    pub name: String,    #[command(flatten)]    pub transport_args: AddMcpTransportArgs,}#[derive(Debug, clap::Args)]#[command(    group(        ArgGroup::new("transport")            .args(["command", "url"])            .required(true)            .multiple(false)    ))]pub struct AddMcpTransportArgs {    #[command(flatten)]    pub stdio: Option<AddMcpStdioArgs>,    #[command(flatten)]    pub streamable_http: Option<AddMcpStreamableHttpArgs>,}#[derive(Debug, clap::Args)]pub struct AddMcpStdioArgs {    /// Command to launch the MCP server.    /// Use --url for a streamable HTTP server.    #[arg(            trailing_var_arg = true,            num_args = 0..,        )]    pub command: Vec<String>,    /// Environment variables to set when launching the server.    /// Only valid with stdio servers.    #[arg(        long,        value_parser = parse_env_pair,        value_name = "KEY=VALUE",    )]    pub env: Vec<(String, String)>,}#[derive(Debug, clap::Args)]pub struct AddMcpStreamableHttpArgs {    /// URL for a streamable HTTP MCP server.    #[arg(long)]    pub url: String,    /// Optional environment variable to read for a bearer token.    /// Only valid with streamable HTTP servers.    #[arg(        long = "bearer-token-env-var",        value_name = "ENV_VAR",        requires = "url"    )]    pub bearer_token_env_var: Option<String>,    /// Optional OAuth client identifier to use for this MCP server.    #[arg(long = "oauth-client-id", value_name = "CLIENT_ID", requires = "url")]    pub oauth_client_id: Option<String>,    /// Optional OAuth resource parameter to include during MCP login.    #[arg(long = "oauth-resource", value_name = "RESOURCE", requires = "url")]    pub oauth_resource: Option<String>,}#[derive(Debug, clap::Parser)]pub struct RemoveArgs {    /// Name of the MCP server configuration to remove.    pub name: String,}#[derive(Debug, clap::Parser)]pub struct LoginArgs {    /// Name of the MCP server to authenticate with oauth.    pub name: String,    /// Comma-separated list of OAuth scopes to request.    #[arg(long, value_delimiter = ',', value_name = "SCOPE,SCOPE")]    pub scopes: Vec<String>,}#[derive(Debug, clap::Parser)]pub struct LogoutArgs {    /// Name of the MCP server to deauthenticate.    pub name: String,}impl McpCli {    pub async fn run(self, loader_overrides: LoaderOverrides) -> Result<()> {        let McpCli {            config_overrides,            subcommand,        } = self;        if loader_overrides.user_config_profile.is_some() {            validate_profile_v2_migration(&config_overrides, loader_overrides).await?;        }        match subcommand {            McpSubcommand::List(args) => {                run_list(&config_overrides, args).await?;            }            McpSubcommand::Get(args) => {                run_get(&config_overrides, args).await?;            }            McpSubcommand::Add(args) => {                run_add(&config_overrides, args).await?;            }            McpSubcommand::Remove(args) => {                run_remove(&config_overrides, args).await?;            }            McpSubcommand::Login(args) => {                run_login(&config_overrides, args).await?;            }            McpSubcommand::Logout(args) => {                run_logout(&config_overrides, args).await?;            }        }        Ok(())    }}/// Preserve compatibility with servers that still expect the legacy empty-scope/// OAuth request. If a discovered-scope request is rejected by the provider,/// retry the login flow once without scopes.#[allow(clippy::too_many_arguments)]async fn perform_oauth_login_retry_without_scopes(    name: &str,    url: &str,    store_mode: codex_config::types::OAuthCredentialsStoreMode,    keyring_backend_kind: codex_config::types::AuthKeyringBackendKind,    http_headers: Option<HashMap<String, String>>,    env_http_headers: Option<HashMap<String, String>>,    resolved_scopes: &ResolvedMcpOAuthScopes,    oauth_client_id: Option<&str>,    oauth_resource: Option<&str>,    callback_port: Option<u16>,    callback_url: Option<&str>,) -> Result<()> {    match perform_oauth_login(        name,        url,        store_mode,        keyring_backend_kind,        http_headers.clone(),        env_http_headers.clone(),        &resolved_scopes.scopes,        oauth_client_id,        oauth_resource,        callback_port,        callback_url,    )    .await    {        Ok(()) => Ok(()),        Err(err) if should_retry_without_scopes(resolved_scopes, &err) => {            println!("OAuth provider rejected discovered scopes. Retrying without scopes…");            perform_oauth_login(                name,                url,                store_mode,                keyring_backend_kind,                http_headers,                env_http_headers,                &[],                oauth_client_id,                oauth_resource,                callback_port,                callback_url,            )            .await        }        Err(err) => Err(err),    }}async fn validate_profile_v2_migration(    config_overrides: &CliConfigOverrides,    loader_overrides: LoaderOverrides,) -> Result<()> {    let overrides = config_overrides        .parse_overrides()        .map_err(anyhow::Error::msg)?;    ConfigBuilder::default()        .cli_overrides(overrides)        .loader_overrides(loader_overrides)        .build()        .await        .context("failed to load configuration")?;    Ok(())}async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<()> {    // Validate any provided overrides even though they are not currently applied.    let overrides = config_overrides        .parse_overrides()        .map_err(anyhow::Error::msg)?;    let config = Config::load_with_cli_overrides(overrides)        .await        .context("failed to load configuration")?;    let AddArgs {        name,        transport_args,    } = add_args;    validate_server_name(&name)?;    let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;    let mut servers = load_global_mcp_servers(&codex_home)        .await        .with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?;    let (transport, oauth_client_id, oauth_resource) = match transport_args {        AddMcpTransportArgs {            stdio: Some(stdio), ..        } => {            let mut command_parts = stdio.command.into_iter();            let command_bin = command_parts                .next()                .ok_or_else(|| anyhow!("command is required"))?;            let command_args: Vec<String> = command_parts.collect();            let env_map = if stdio.env.is_empty() {                None            } else {                Some(stdio.env.into_iter().collect::<HashMap<_, _>>())            };            (                McpServerTransportConfig::Stdio {                    command: command_bin,                    args: command_args,                    env: env_map,                    env_vars: Vec::new(),                    cwd: None,                },                None,                None,            )        }        AddMcpTransportArgs {            streamable_http:                Some(AddMcpStreamableHttpArgs {                    url,                    bearer_token_env_var,                    oauth_client_id,                    oauth_resource,                }),            ..        } => (            McpServerTransportConfig::StreamableHttp {                url,                bearer_token_env_var,                http_headers: None,                env_http_headers: None,            },            oauth_client_id,            oauth_resource,        ),        AddMcpTransportArgs { .. } => bail!("exactly one of --command or --url must be provided"),    };    let new_entry = McpServerConfig {        transport: transport.clone(),        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: oauth_client_id            .clone()            .map(|client_id| McpServerOAuthConfig {                client_id: Some(client_id),            }),        oauth_resource: oauth_resource.clone(),        tools: HashMap::new(),    };    servers.insert(name.clone(), new_entry);    ConfigEditsBuilder::new(&codex_home)        .replace_mcp_servers(&servers)        .apply()        .await        .with_context(|| format!("failed to write MCP servers to {}", codex_home.display()))?;    println!("Added global MCP server '{name}'.");    match oauth_login_support(&transport).await {        McpOAuthLoginSupport::Supported(oauth_config) => {            println!("Detected OAuth support. Starting OAuth flow…");            let resolved_scopes = resolve_oauth_scopes(                /*explicit_scopes*/ None,                /*configured_scopes*/ None,                oauth_config.discovered_scopes.clone(),            );            perform_oauth_login_retry_without_scopes(                &name,                &oauth_config.url,                config.mcp_oauth_credentials_store_mode,                config.auth_keyring_backend_kind(),                oauth_config.http_headers,                oauth_config.env_http_headers,                &resolved_scopes,                oauth_client_id.as_deref(),                oauth_resource.as_deref(),                config.mcp_oauth_callback_port,                config.mcp_oauth_callback_url.as_deref(),            )            .await?;            println!("Successfully logged in.");        }        McpOAuthLoginSupport::Unsupported => {}        McpOAuthLoginSupport::Unknown(_) => println!(            "MCP server may or may not require login. Run `codex mcp login {name}` to login."        ),    }    Ok(())}async fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> Result<()> {    config_overrides        .parse_overrides()        .map_err(anyhow::Error::msg)?;    let RemoveArgs { name } = remove_args;    validate_server_name(&name)?;    let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;    let mut servers = load_global_mcp_servers(&codex_home)        .await        .with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?;    let removed = servers.remove(&name).is_some();    if removed {        ConfigEditsBuilder::new(&codex_home)            .replace_mcp_servers(&servers)            .apply()            .await            .with_context(|| format!("failed to write MCP servers to {}", codex_home.display()))?;    }    if removed {        println!("Removed global MCP server '{name}'.");    } else {        println!("No MCP server named '{name}' found.");    }    Ok(())}async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) -> Result<()> {    let overrides = config_overrides        .parse_overrides()        .map_err(anyhow::Error::msg)?;    let config = Config::load_with_cli_overrides(overrides)        .await        .context("failed to load configuration")?;    let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(        config.codex_home.to_path_buf(),    )));    let mcp_servers = mcp_manager.configured_servers(&config).await;    let LoginArgs { name, scopes } = login_args;    let Some(server) = mcp_servers.get(&name) else {        bail!("No MCP server named '{name}' found.");    };    let (url, http_headers, env_http_headers) = match &server.transport {        McpServerTransportConfig::StreamableHttp {            url,            http_headers,            env_http_headers,            ..        } => (url.clone(), http_headers.clone(), env_http_headers.clone()),        _ => bail!("OAuth login is only supported for streamable HTTP servers."),    };    let explicit_scopes = (!scopes.is_empty()).then_some(scopes);    let discovered_scopes = if explicit_scopes.is_none() && server.scopes.is_none() {        discover_supported_scopes(&server.transport).await    } else {        None    };    let resolved_scopes =        resolve_oauth_scopes(explicit_scopes, server.scopes.clone(), discovered_scopes);    perform_oauth_login_retry_without_scopes(        &name,        &url,        config.mcp_oauth_credentials_store_mode,        config.auth_keyring_backend_kind(),        http_headers,        env_http_headers,        &resolved_scopes,        server.oauth_client_id(),        server.oauth_resource.as_deref(),        config.mcp_oauth_callback_port,        config.mcp_oauth_callback_url.as_deref(),    )    .await?;    println!("Successfully logged in to MCP server '{name}'.");    Ok(())}async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutArgs) -> Result<()> {    let overrides = config_overrides        .parse_overrides()        .map_err(anyhow::Error::msg)?;    let config = Config::load_with_cli_overrides(overrides)        .await        .context("failed to load configuration")?;    let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(        config.codex_home.to_path_buf(),    )));    let mcp_servers = mcp_manager.configured_servers(&config).await;    let LogoutArgs { name } = logout_args;    let server = mcp_servers        .get(&name)        .ok_or_else(|| anyhow!("No MCP server named '{name}' found in configuration."))?;    let url = match &server.transport {        McpServerTransportConfig::StreamableHttp { url, .. } => url.clone(),        _ => bail!("OAuth logout is only supported for streamable_http transports."),    };    match delete_oauth_tokens(        &name,        &url,        config.mcp_oauth_credentials_store_mode,        config.auth_keyring_backend_kind(),    ) {        Ok(true) => println!("Removed OAuth credentials for '{name}'."),        Ok(false) => println!("No OAuth credentials stored for '{name}'."),        Err(err) => return Err(anyhow!("failed to delete OAuth credentials: {err}")),    }    Ok(())}async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Result<()> {    let overrides = config_overrides        .parse_overrides()        .map_err(anyhow::Error::msg)?;    let config = Config::load_with_cli_overrides(overrides)        .await        .context("failed to load configuration")?;    let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(        config.codex_home.to_path_buf(),    )));    let mcp_servers = mcp_manager.configured_servers(&config).await;    let effective_mcp_servers = mcp_manager.effective_servers(&config, /*auth*/ None).await;    let mut entries: Vec<_> = mcp_servers.iter().collect();    entries.sort_by_key(|(name, _)| *name);    let auth_statuses = compute_auth_statuses(        effective_mcp_servers.iter(),        config.mcp_oauth_credentials_store_mode,        config.auth_keyring_backend_kind(),        /*auth*/ None,    )    .await;    if list_args.json {        let json_entries: Vec<_> = entries            .into_iter()            .map(|(name, cfg)| {                let auth_status = auth_statuses                    .get(name.as_str())                    .map(|entry| entry.auth_status)                    .unwrap_or(McpAuthStatus::Unsupported);                let transport = match &cfg.transport {                    McpServerTransportConfig::Stdio {                        command,                        args,                        env,                        env_vars,                        cwd,                    } => serde_json::json!({                        "type": "stdio",                        "command": command,                        "args": args,                        "env": env,                        "env_vars": env_vars,                        "cwd": cwd,                    }),                    McpServerTransportConfig::StreamableHttp {                        url,                        bearer_token_env_var,                        http_headers,                        env_http_headers,                    } => {                        serde_json::json!({                            "type": "streamable_http",                            "url": url,                            "bearer_token_env_var": bearer_token_env_var,                            "http_headers": http_headers,                            "env_http_headers": env_http_headers,                        })                    }                };                serde_json::json!({                    "name": name,                    "enabled": cfg.enabled,                    "disabled_reason": cfg.disabled_reason.as_ref().map(ToString::to_string),                    "transport": transport,                    "startup_timeout_sec": cfg                        .startup_timeout_sec                        .map(|timeout| timeout.as_secs_f64()),                    "tool_timeout_sec": cfg                        .tool_timeout_sec                        .map(|timeout| timeout.as_secs_f64()),                    "auth_status": auth_status,                })            })            .collect();        let output = serde_json::to_string_pretty(&json_entries)?;        println!("{output}");        return Ok(());    }    if entries.is_empty() {        println!("No MCP servers configured yet. Try `codex mcp add my-tool -- my-command`.");        return Ok(());    }    let mut stdio_rows: Vec<[String; 7]> = Vec::new();    let mut http_rows: Vec<[String; 5]> = Vec::new();    for (name, cfg) in entries {        match &cfg.transport {            McpServerTransportConfig::Stdio {                command,                args,                env,                env_vars,                cwd,            } => {                let args_display = if args.is_empty() {                    "-".to_string()                } else {                    args.join(" ")                };                let env_display = format_env_display(env.as_ref(), env_vars);                let cwd_display = cwd                    .as_ref()                    .map(|path| path.display().to_string())                    .filter(|value| !value.is_empty())                    .unwrap_or_else(|| "-".to_string());                let status = format_mcp_status(cfg);                let auth_status = auth_statuses                    .get(name.as_str())                    .map(|entry| entry.auth_status)                    .unwrap_or(McpAuthStatus::Unsupported)                    .to_string();                stdio_rows.push([                    name.clone(),                    command.clone(),                    args_display,                    env_display,                    cwd_display,                    status,                    auth_status,                ]);            }            McpServerTransportConfig::StreamableHttp {                url,                bearer_token_env_var,                ..            } => {                let status = format_mcp_status(cfg);                let auth_status = auth_statuses                    .get(name.as_str())                    .map(|entry| entry.auth_status)                    .unwrap_or(McpAuthStatus::Unsupported)                    .to_string();                let bearer_token_display =                    bearer_token_env_var.as_deref().unwrap_or("-").to_string();                http_rows.push([                    name.clone(),                    url.clone(),                    bearer_token_display,                    status,                    auth_status,                ]);            }        }    }    if !stdio_rows.is_empty() {        let mut widths = [            "Name".len(),            "Command".len(),            "Args".len(),            "Env".len(),            "Cwd".len(),            "Status".len(),            "Auth".len(),        ];        for row in &stdio_rows {            for (i, cell) in row.iter().enumerate() {                widths[i] = widths[i].max(cell.len());            }        }        println!(            "{name:<name_w$}  {command:<cmd_w$}  {args:<args_w$}  {env:<env_w$}  {cwd:<cwd_w$}  {status:<status_w$}  {auth:<auth_w$}",            name = "Name",            command = "Command",            args = "Args",            env = "Env",            cwd = "Cwd",            status = "Status",            auth = "Auth",            name_w = widths[0],            cmd_w = widths[1],            args_w = widths[2],            env_w = widths[3],            cwd_w = widths[4],            status_w = widths[5],            auth_w = widths[6],        );        for row in &stdio_rows {            println!(                "{name:<name_w$}  {command:<cmd_w$}  {args:<args_w$}  {env:<env_w$}  {cwd:<cwd_w$}  {status:<status_w$}  {auth:<auth_w$}",                name = row[0].as_str(),                command = row[1].as_str(),                args = row[2].as_str(),                env = row[3].as_str(),                cwd = row[4].as_str(),                status = row[5].as_str(),                auth = row[6].as_str(),                name_w = widths[0],                cmd_w = widths[1],                args_w = widths[2],                env_w = widths[3],                cwd_w = widths[4],                status_w = widths[5],                auth_w = widths[6],            );        }    }    if !stdio_rows.is_empty() && !http_rows.is_empty() {        println!();    }    if !http_rows.is_empty() {        let mut widths = [            "Name".len(),            "Url".len(),            "Bearer Token Env Var".len(),            "Status".len(),            "Auth".len(),        ];        for row in &http_rows {            for (i, cell) in row.iter().enumerate() {                widths[i] = widths[i].max(cell.len());            }        }        println!(            "{name:<name_w$}  {url:<url_w$}  {token:<token_w$}  {status:<status_w$}  {auth:<auth_w$}",            name = "Name",            url = "Url",            token = "Bearer Token Env Var",            status = "Status",            auth = "Auth",            name_w = widths[0],            url_w = widths[1],            token_w = widths[2],            status_w = widths[3],            auth_w = widths[4],        );        for row in &http_rows {            println!(                "{name:<name_w$}  {url:<url_w$}  {token:<token_w$}  {status:<status_w$}  {auth:<auth_w$}",                name = row[0].as_str(),                url = row[1].as_str(),                token = row[2].as_str(),                status = row[3].as_str(),                auth = row[4].as_str(),                name_w = widths[0],                url_w = widths[1],                token_w = widths[2],                status_w = widths[3],                auth_w = widths[4],            );        }    }    Ok(())}async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Result<()> {    let overrides = config_overrides        .parse_overrides()        .map_err(anyhow::Error::msg)?;    let config = Config::load_with_cli_overrides(overrides)        .await        .context("failed to load configuration")?;    let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(        config.codex_home.to_path_buf(),    )));    let mcp_servers = mcp_manager.configured_servers(&config).await;    let Some(server) = mcp_servers.get(&get_args.name) else {        bail!("No MCP server named '{name}' found.", name = get_args.name);    };    if get_args.json {        let transport = match &server.transport {            McpServerTransportConfig::Stdio {                command,                args,                env,                env_vars,                cwd,            } => serde_json::json!({                "type": "stdio",                "command": command,                "args": args,                "env": env,                "env_vars": env_vars,                "cwd": cwd,            }),            McpServerTransportConfig::StreamableHttp {                url,                bearer_token_env_var,                http_headers,                env_http_headers,            } => serde_json::json!({                "type": "streamable_http",                "url": url,                "bearer_token_env_var": bearer_token_env_var,                "http_headers": http_headers,                "env_http_headers": env_http_headers,            }),        };        let output = serde_json::to_string_pretty(&serde_json::json!({            "name": get_args.name,            "enabled": server.enabled,            "disabled_reason": server.disabled_reason.as_ref().map(ToString::to_string),            "transport": transport,            "enabled_tools": server.enabled_tools.clone(),            "disabled_tools": server.disabled_tools.clone(),            "startup_timeout_sec": server                .startup_timeout_sec                .map(|timeout| timeout.as_secs_f64()),            "tool_timeout_sec": server                .tool_timeout_sec                .map(|timeout| timeout.as_secs_f64()),        }))?;        println!("{output}");        return Ok(());    }    if !server.enabled {        if let Some(reason) = server.disabled_reason.as_ref() {            println!("{name} (disabled: {reason})", name = get_args.name);        } else {            println!("{name} (disabled)", name = get_args.name);        }        return Ok(());    }    println!("{}", get_args.name);    println!("  enabled: {}", server.enabled);    let format_tool_list = |tools: &Option<Vec<String>>| -> String {        match tools {            Some(list) if list.is_empty() => "[]".to_string(),            Some(list) => list.join(", "),            None => "-".to_string(),        }    };    if server.enabled_tools.is_some() {        let enabled_tools_display = format_tool_list(&server.enabled_tools);        println!("  enabled_tools: {enabled_tools_display}");    }    if server.disabled_tools.is_some() {        let disabled_tools_display = format_tool_list(&server.disabled_tools);        println!("  disabled_tools: {disabled_tools_display}");    }    match &server.transport {        McpServerTransportConfig::Stdio {            command,            args,            env,            env_vars,            cwd,        } => {            println!("  transport: stdio");            println!("  command: {command}");            let args_display = if args.is_empty() {                "-".to_string()            } else {                args.join(" ")            };            println!("  args: {args_display}");            let cwd_display = cwd                .as_ref()                .map(|path| path.display().to_string())                .filter(|value| !value.is_empty())                .unwrap_or_else(|| "-".to_string());            println!("  cwd: {cwd_display}");            let env_display = format_env_display(env.as_ref(), env_vars);            println!("  env: {env_display}");        }        McpServerTransportConfig::StreamableHttp {            url,            bearer_token_env_var,            http_headers,            env_http_headers,        } => {            println!("  transport: streamable_http");            println!("  url: {url}");            let bearer_token_display = bearer_token_env_var.as_deref().unwrap_or("-");            println!("  bearer_token_env_var: {bearer_token_display}");            let headers_display = match http_headers {                Some(map) if !map.is_empty() => {                    let mut pairs: Vec<_> = map.iter().collect();                    pairs.sort_by_key(|(name, _)| *name);                    pairs                        .into_iter()                        .map(|(k, _)| format!("{k}=*****"))                        .collect::<Vec<_>>()                        .join(", ")                }                _ => "-".to_string(),            };            println!("  http_headers: {headers_display}");            let env_headers_display = match env_http_headers {                Some(map) if !map.is_empty() => {                    let mut pairs: Vec<_> = map.iter().collect();                    pairs.sort_by_key(|(name, _)| *name);                    pairs                        .into_iter()                        .map(|(k, var)| format!("{k}={var}"))                        .collect::<Vec<_>>()                        .join(", ")                }                _ => "-".to_string(),            };            println!("  env_http_headers: {env_headers_display}");        }    }    if let Some(timeout) = server.startup_timeout_sec {        println!("  startup_timeout_sec: {}", timeout.as_secs_f64());    }    if let Some(timeout) = server.tool_timeout_sec {        println!("  tool_timeout_sec: {}", timeout.as_secs_f64());    }    if let Some(approval_mode) = server.default_tools_approval_mode {        let approval_mode = match approval_mode {            AppToolApproval::Auto => "auto",            AppToolApproval::Prompt => "prompt",            AppToolApproval::Approve => "approve",        };        println!("  default_tools_approval_mode: {approval_mode}");    }    println!("  remove: codex mcp remove {}", get_args.name);    Ok(())}fn parse_env_pair(raw: &str) -> Result<(String, String), String> {    let mut parts = raw.splitn(2, '=');    let key = parts        .next()        .map(str::trim)        .filter(|s| !s.is_empty())        .ok_or_else(|| "environment entries must be in KEY=VALUE form".to_string())?;    let value = parts        .next()        .map(str::to_string)        .ok_or_else(|| "environment entries must be in KEY=VALUE form".to_string())?;    Ok((key.to_string(), value))}fn validate_server_name(name: &str) -> Result<()> {    let is_valid = !name.is_empty()        && name            .chars()            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');    if is_valid {        Ok(())    } else {        bail!("invalid server name '{name}' (use letters, numbers, '-', '_')");    }}fn format_mcp_status(config: &McpServerConfig) -> String {    if config.enabled {        "enabled".to_string()    } else if let Some(reason) = config.disabled_reason.as_ref() {        format!("disabled: {reason}")    } else {        "disabled".to_string()    }}