use std::collections::HashMap; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use anyhow::bail; use clap::ArgGroup; use codex_common::CliConfigOverrides; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::config::load_global_mcp_servers; use codex_core::config::write_global_mcp_servers; use codex_core::config_types::McpServerConfig; use codex_core::config_types::McpServerTransportConfig; use codex_rmcp_client::delete_oauth_tokens; use codex_rmcp_client::perform_oauth_login; /// [experimental] Launch Codex as an MCP server or manage configured MCP servers. /// /// Subcommands: /// - `serve` — run the MCP server on stdio /// - `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 #[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 { /// [experimental] List configured MCP servers. List(ListArgs), /// [experimental] Show details for a configured MCP server. Get(GetArgs), /// [experimental] Add a global MCP server entry. Add(AddArgs), /// [experimental] Remove a global MCP server entry. Remove(RemoveArgs), /// [experimental] Authenticate with a configured MCP server via OAuth. /// Requires experimental_use_rmcp_client = true in config.toml. Login(LoginArgs), /// [experimental] Remove stored OAuth credentials for a server. /// Requires experimental_use_rmcp_client = true in config.toml. 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)] 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, } #[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, } #[derive(Debug, clap::Parser)] pub struct LogoutArgs { /// Name of the MCP server to deauthenticate. pub name: String, } impl McpCli { pub async fn run(self) -> Result<()> { let McpCli { config_overrides, subcommand, } = self; 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(()) } } async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<()> { // Validate any provided overrides even though they are not currently applied. config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; 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 = 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, } } AddMcpTransportArgs { streamable_http: Some(streamable_http), .. } => McpServerTransportConfig::StreamableHttp { url: streamable_http.url, bearer_token_env_var: streamable_http.bearer_token_env_var, }, AddMcpTransportArgs { .. } => bail!("exactly one of --command or --url must be provided"), }; let new_entry = McpServerConfig { transport, startup_timeout_sec: None, tool_timeout_sec: None, }; servers.insert(name.clone(), new_entry); write_global_mcp_servers(&codex_home, &servers) .with_context(|| format!("failed to write MCP servers to {}", codex_home.display()))?; println!("Added global MCP server '{name}'."); Ok(()) } async fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> Result<()> { config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; 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 { write_global_mcp_servers(&codex_home, &servers) .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(|e| anyhow!(e))?; let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) .await .context("failed to load configuration")?; if !config.use_experimental_use_rmcp_client { bail!( "OAuth login is only supported when experimental_use_rmcp_client is true in config.toml." ); } let LoginArgs { name } = login_args; let Some(server) = config.mcp_servers.get(&name) else { bail!("No MCP server named '{name}' found."); }; let url = match &server.transport { McpServerTransportConfig::StreamableHttp { url, .. } => url.clone(), _ => bail!("OAuth login is only supported for streamable HTTP servers."), }; perform_oauth_login(&name, &url, config.mcp_oauth_credentials_store_mode).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(|e| anyhow!(e))?; let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) .await .context("failed to load configuration")?; let LogoutArgs { name } = logout_args; let server = config .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) { 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(|e| anyhow!(e))?; let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) .await .context("failed to load configuration")?; let mut entries: Vec<_> = config.mcp_servers.iter().collect(); entries.sort_by(|(a, _), (b, _)| a.cmp(b)); if list_args.json { let json_entries: Vec<_> = entries .into_iter() .map(|(name, cfg)| { let transport = match &cfg.transport { McpServerTransportConfig::Stdio { command, args, env } => serde_json::json!({ "type": "stdio", "command": command, "args": args, "env": env, }), McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, } => { serde_json::json!({ "type": "streamable_http", "url": url, "bearer_token_env_var": bearer_token_env_var, }) } }; serde_json::json!({ "name": name, "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()), }) }) .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; 4]> = Vec::new(); let mut http_rows: Vec<[String; 3]> = Vec::new(); for (name, cfg) in entries { match &cfg.transport { McpServerTransportConfig::Stdio { command, args, env } => { let args_display = if args.is_empty() { "-".to_string() } else { args.join(" ") }; let env_display = match env.as_ref() { None => "-".to_string(), Some(map) if map.is_empty() => "-".to_string(), Some(map) => { let mut pairs: Vec<_> = map.iter().collect(); pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); pairs .into_iter() .map(|(k, v)| format!("{k}={v}")) .collect::>() .join(", ") } }; stdio_rows.push([name.clone(), command.clone(), args_display, env_display]); } McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, } => { http_rows.push([ name.clone(), url.clone(), bearer_token_env_var.clone().unwrap_or("-".to_string()), ]); } } } if !stdio_rows.is_empty() { let mut widths = ["Name".len(), "Command".len(), "Args".len(), "Env".len()]; for row in &stdio_rows { for (i, cell) in row.iter().enumerate() { widths[i] = widths[i].max(cell.len()); } } println!( "{: Result<()> { let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) .await .context("failed to load configuration")?; let Some(server) = config.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 } => serde_json::json!({ "type": "stdio", "command": command, "args": args, "env": env, }), McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, } => serde_json::json!({ "type": "streamable_http", "url": url, "bearer_token_env_var": bearer_token_env_var, }), }; let output = serde_json::to_string_pretty(&serde_json::json!({ "name": get_args.name, "transport": transport, "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(()); } println!("{}", get_args.name); match &server.transport { McpServerTransportConfig::Stdio { command, args, env } => { println!(" transport: stdio"); println!(" command: {command}"); let args_display = if args.is_empty() { "-".to_string() } else { args.join(" ") }; println!(" args: {args_display}"); let env_display = match env.as_ref() { None => "-".to_string(), Some(map) if map.is_empty() => "-".to_string(), Some(map) => { let mut pairs: Vec<_> = map.iter().collect(); pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); pairs .into_iter() .map(|(k, v)| format!("{k}={v}")) .collect::>() .join(", ") } }; println!(" env: {env_display}"); } McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, } => { println!(" transport: streamable_http"); println!(" url: {url}"); let env_var = bearer_token_env_var.as_deref().unwrap_or("-"); println!(" bearer_token_env_var: {env_var}"); } } 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()); } 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, '-', '_')"); } }