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_manager_for_config; use codex_exec_server::EnvironmentManager; use codex_exec_server::HttpClient; use codex_exec_server::RouteAwareHttpClient; use codex_login::AuthManager; use codex_mcp::McpOAuthLoginSupport; use codex_mcp::McpRuntimeContext; use codex_mcp::apply_http_headers_helper; use codex_mcp::compute_auth_statuses; use codex_mcp::discover_supported_scopes; use codex_mcp::oauth_login_support; use codex_mcp::resolve_oauth_callback; use codex_mcp::resolve_oauth_scopes; use codex_protocol::protocol::McpAuthStatus; use codex_rmcp_client::McpOAuthCallbackMode; use codex_rmcp_client::McpOAuthClientRegistration; use codex_rmcp_client::OAuthDiscoveryTimeout; use codex_rmcp_client::StreamableHttpRedirectMode; use codex_rmcp_client::delete_oauth_tokens; use codex_rmcp_client::resolve_mcp_oauth_callback_url; use codex_utils_cli::CliConfigOverrides; use codex_utils_cli::format_env_display; use crate::cloud_config; use crate::mcp_login::McpLoginMode; use crate::mcp_login::perform_oauth_login_retry_without_scopes; use crate::plugin_cmd::load_cli_auth_manager; /// 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] (--url | -- ...)")] 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, #[command(flatten)] pub streamable_http: Option, } #[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, /// 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, /// 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, /// OAuth client-registration strategy for the immediate login only. #[arg( long = "oauth-client-registration", value_enum, value_name = "AUTO|CIMD|DCR", requires = "url" )] pub oauth_client_registration: Option, /// Optional OAuth resource parameter to include during MCP login. #[arg(long = "oauth-resource", value_name = "RESOURCE", requires = "url")] pub oauth_resource: Option, } #[derive(Debug, Clone, Copy, clap::ValueEnum)] pub enum McpOAuthClientRegistrationArg { Auto, Cimd, Dcr, } impl From for McpOAuthClientRegistration { fn from(value: McpOAuthClientRegistrationArg) -> Self { match value { McpOAuthClientRegistrationArg::Auto => Self::Auto, McpOAuthClientRegistrationArg::Cimd => Self::Cimd, McpOAuthClientRegistrationArg::Dcr => Self::Dcr, } } } #[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, /// Print the authorization URL and accept the callback URL without opening a browser. #[arg(long)] pub no_browser: bool, /// Comma-separated list of OAuth scopes to request. #[arg(long, value_delimiter = ',', value_name = "SCOPE,SCOPE")] pub scopes: Vec, /// OAuth client-registration strategy for this login only. #[arg( long = "oauth-client-registration", value_enum, value_name = "AUTO|CIMD|DCR" )] pub oauth_client_registration: Option, } #[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.clone()).await?; } match subcommand { McpSubcommand::List(args) => { let config = cloud_config::load_config(&config_overrides, loader_overrides).await?; run_list(&config, args).await?; } McpSubcommand::Get(args) => { let config = cloud_config::load_config(&config_overrides, loader_overrides).await?; run_get(&config, args).await?; } McpSubcommand::Add(args) => { run_add(&config_overrides, args).await?; } McpSubcommand::Remove(args) => { run_remove(&config_overrides, args).await?; } McpSubcommand::Login(args) => { let config = cloud_config::load_config(&config_overrides, loader_overrides).await?; run_login(&config, args).await?; } McpSubcommand::Logout(args) => { let config = cloud_config::load_config(&config_overrides, loader_overrides).await?; run_logout(&config, args).await?; } } Ok(()) } } 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 (transport, oauth_client_id, client_registration, 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 = command_parts.collect(); let env_map = if stdio.env.is_empty() { None } else { Some(stdio.env.into_iter().collect::>()) }; ( McpServerTransportConfig::Stdio { command: command_bin, args: command_args, env: env_map, env_vars: Vec::new(), cwd: None, }, None, McpOAuthClientRegistration::Auto, None, ) } AddMcpTransportArgs { streamable_http: Some(AddMcpStreamableHttpArgs { url, bearer_token_env_var, oauth_client_id, oauth_client_registration, oauth_resource, }), .. } => ( McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, http_headers: None, env_http_headers: None, http_headers_helper: None, }, oauth_client_id, oauth_client_registration .map(McpOAuthClientRegistration::from) .unwrap_or_default(), oauth_resource, ), AddMcpTransportArgs { .. } => bail!("exactly one of --command or --url must be provided"), }; // Discover once before saving so a new registered client keeps the exact // callback its provider expects, including issuer-bound stable callbacks. let http_client: Arc = Arc::new( RouteAwareHttpClient::new(config.http_client_factory()).with_tls_backend_fallback(), ); let login_support = oauth_login_support( &transport, Arc::clone(&http_client), OAuthDiscoveryTimeout::LOCAL, StreamableHttpRedirectMode::Legacy, ) .await; // A stable callback requires confirmed issuer support. Discovery failures // and servers without support both retain the callback-ID mix-up defense. let callback_mode = match &login_support { McpOAuthLoginSupport::Supported(oauth_config) => oauth_config.callback_mode, McpOAuthLoginSupport::Unsupported | McpOAuthLoginSupport::Unknown(_) => { McpOAuthCallbackMode::CallbackSpecific } }; // DCR and CIMD register their callbacks during login; only an existing // client ID has a redirect that must be persisted with the MCP server. let callback_url = oauth_client_id .as_ref() .map(|_| { let McpServerTransportConfig::StreamableHttp { url, .. } = &transport else { bail!("OAuth client IDs require a streamable HTTP MCP server"); }; resolve_mcp_oauth_callback_url( url, config.mcp_oauth_callback_url.as_deref(), callback_mode, ) }) .transpose()?; let new_entry = McpServerConfig { auth: Default::default(), transport: transport.clone(), environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, 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), callback_url: callback_url.clone(), callback_port: None, ..Default::default() }), oauth_resource: oauth_resource.clone(), tools: HashMap::new(), }; let mut servers = load_global_mcp_servers(&codex_home) .await .with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?; let credential_name = new_entry.oauth_credential_name(&name); 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}'."); if let Some(callback_url) = &callback_url { println!("OAuth callback URL: {callback_url}"); } match login_support { 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( credential_name.as_ref(), &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(), client_registration, oauth_resource.as_deref(), config.mcp_oauth_callback_port, callback_url .as_deref() .or(config.mcp_oauth_callback_url.as_deref()), config.mcp_oauth_callback_url.as_deref(), http_client, McpLoginMode::Browser, ) .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 load_mcp_manager(config: &Config) -> Result { let plugins_manager = Arc::new(plugins_manager_for_config( config, load_cli_auth_manager(config).await?, )); Ok(McpManager::new(plugins_manager)) } async fn run_login(config: &Config, login_args: LoginArgs) -> Result<()> { let mcp_manager = load_mcp_manager(config).await?; let mcp_servers = mcp_manager.configured_servers(config).await; let LoginArgs { name, no_browser, scopes, oauth_client_registration, } = login_args; let client_registration = oauth_client_registration .map(McpOAuthClientRegistration::from) .unwrap_or_default(); let Some(server) = mcp_servers.get(&name) else { bail!("No MCP server named '{name}' found."); }; if matches!(server.auth, codex_config::types::McpServerAuth::EmaAuth) { bail!( "Enterprise MCP authorization is managed by Codex account sign-in. Open Codex to sign in." ); } 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."), }; // Standalone `mcp login` runs OAuth from the local CLI process; execution // environment routing belongs to app-server and session MCP flows. let http_client: Arc = Arc::new( RouteAwareHttpClient::new(config.http_client_factory()).with_tls_backend_fallback(), ); let http_client = apply_http_headers_helper(http_client, server, config.cwd.to_path_buf()) .map_err(anyhow::Error::msg)?; 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, Arc::clone(&http_client), OAuthDiscoveryTimeout::LOCAL, StreamableHttpRedirectMode::Legacy, ) .await } else { None }; let resolved_scopes = resolve_oauth_scopes(explicit_scopes, server.scopes.clone(), discovered_scopes); let credential_name = server.oauth_credential_name(&name); let callback_url = resolve_oauth_callback(server, &url, config.mcp_oauth_callback_url.as_deref())?; perform_oauth_login_retry_without_scopes( credential_name.as_ref(), &url, config.mcp_oauth_credentials_store_mode, config.auth_keyring_backend_kind(), http_headers, env_http_headers, &resolved_scopes, server.oauth_client_id(), client_registration, server.oauth_resource.as_deref(), server.oauth_callback_port(config.mcp_oauth_callback_port), callback_url.as_deref(), config.mcp_oauth_callback_url.as_deref(), http_client, if no_browser { McpLoginMode::PasteCallback } else { McpLoginMode::Browser }, ) .await?; println!("Successfully logged in to MCP server '{name}'."); Ok(()) } async fn run_logout(config: &Config, logout_args: LogoutArgs) -> Result<()> { let mcp_manager = load_mcp_manager(config).await?; 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."), }; let credential_name = server.oauth_credential_name(&name); match delete_oauth_tokens( credential_name.as_ref(), &url, config.mcp_oauth_credentials_store_mode, config.auth_keyring_backend_kind(), ) .await { 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: &Config, list_args: ListArgs) -> Result<()> { let mcp_manager = load_mcp_manager(config).await?; let auth_manager = AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await?; let auth = auth_manager.auth().await; let mcp_servers = mcp_manager.configured_servers(config).await; let effective_mcp_servers = mcp_manager.effective_servers(config, auth.as_ref()).await; let mut entries: Vec<_> = mcp_servers.iter().collect(); entries.sort_by_key(|(name, _)| *name); // Standalone `mcp list` only discovers auth through local HTTP; it has // never initialized or routed through remote execution environments. let runtime_context = McpRuntimeContext::new( Arc::new(EnvironmentManager::without_environments( config.http_client_factory(), )), config.cwd.to_path_buf(), ); let auth_statuses = compute_auth_statuses( effective_mcp_servers.iter(), config.mcp_oauth_credentials_store_mode, config.auth_keyring_backend_kind(), auth.as_ref(), &runtime_context, ) .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| McpAuthStatus::from(entry.auth_state)) .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, http_headers_helper, } => { 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, "http_headers_helper": http_headers_helper .as_ref() .map(|_| ""), }) } }; 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(ToString::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| McpAuthStatus::from(entry.auth_state)) .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| McpAuthStatus::from(entry.auth_state)) .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: Result<()> { let mcp_manager = load_mcp_manager(config).await?; 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, http_headers_helper, } => 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, "http_headers_helper": http_headers_helper .as_ref() .map(|_| ""), }), }; 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>| -> 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(ToString::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, http_headers_helper, } => { 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::>() .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::>() .join(", ") } _ => "-".to_string(), }; println!(" env_http_headers: {env_headers_display}"); let helper_display = http_headers_helper.as_ref().map_or("-", |_| ""); println!(" http_headers_helper: {helper_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::Writes => "writes", 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() || matches!(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() } }