| 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; |
|
|
| |
| |
| |
| |
| |
| |
| |
| #[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 { |
| |
| #[arg(long)] |
| pub json: bool, |
| } |
|
|
| #[derive(Debug, clap::Parser)] |
| pub struct GetArgs { |
| |
| pub name: String, |
|
|
| |
| #[arg(long)] |
| pub json: bool, |
| } |
|
|
| #[derive(Debug, clap::Parser)] |
| #[command(override_usage = "codex mcp add [OPTIONS] <NAME> (--url <URL> | -- <COMMAND>...)")] |
| pub struct AddArgs { |
| |
| 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 { |
| |
| |
| #[arg( |
| trailing_var_arg = true, |
| num_args = 0.., |
| )] |
| pub command: Vec<String>, |
|
|
| |
| |
| #[arg( |
| long, |
| value_parser = parse_env_pair, |
| value_name = "KEY=VALUE", |
| )] |
| pub env: Vec<(String, String)>, |
| } |
|
|
| #[derive(Debug, clap::Args)] |
| pub struct AddMcpStreamableHttpArgs { |
| |
| #[arg(long)] |
| pub url: String, |
|
|
| |
| |
| #[arg( |
| long = "bearer-token-env-var", |
| value_name = "ENV_VAR", |
| requires = "url" |
| )] |
| pub bearer_token_env_var: Option<String>, |
|
|
| |
| #[arg(long = "oauth-client-id", value_name = "CLIENT_ID", requires = "url")] |
| pub oauth_client_id: Option<String>, |
|
|
| |
| #[arg( |
| long = "oauth-client-registration", |
| value_enum, |
| value_name = "AUTO|CIMD|DCR", |
| requires = "url" |
| )] |
| pub oauth_client_registration: Option<McpOAuthClientRegistrationArg>, |
|
|
| |
| #[arg(long = "oauth-resource", value_name = "RESOURCE", requires = "url")] |
| pub oauth_resource: Option<String>, |
| } |
|
|
| #[derive(Debug, Clone, Copy, clap::ValueEnum)] |
| pub enum McpOAuthClientRegistrationArg { |
| Auto, |
| Cimd, |
| Dcr, |
| } |
|
|
| impl From<McpOAuthClientRegistrationArg> 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 { |
| |
| pub name: String, |
| } |
|
|
| #[derive(Debug, clap::Parser)] |
| pub struct LoginArgs { |
| |
| pub name: String, |
|
|
| |
| #[arg(long)] |
| pub no_browser: bool, |
|
|
| |
| #[arg(long, value_delimiter = ',', value_name = "SCOPE,SCOPE")] |
| pub scopes: Vec<String>, |
|
|
| |
| #[arg( |
| long = "oauth-client-registration", |
| value_enum, |
| value_name = "AUTO|CIMD|DCR" |
| )] |
| pub oauth_client_registration: Option<McpOAuthClientRegistrationArg>, |
| } |
|
|
| #[derive(Debug, clap::Parser)] |
| pub struct LogoutArgs { |
| |
| 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<()> { |
| |
| 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<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, |
| 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"), |
| }; |
|
|
| |
| |
| let http_client: Arc<dyn HttpClient> = 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; |
| |
| |
| let callback_mode = match &login_support { |
| McpOAuthLoginSupport::Supported(oauth_config) => oauth_config.callback_mode, |
| McpOAuthLoginSupport::Unsupported | McpOAuthLoginSupport::Unknown(_) => { |
| McpOAuthCallbackMode::CallbackSpecific |
| } |
| }; |
|
|
| |
| |
| 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( |
| None, |
| 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<McpManager> { |
| 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."), |
| }; |
|
|
| |
| |
| let http_client: Arc<dyn HttpClient> = 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, 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); |
| |
| |
| 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(|_| "<redacted>"), |
| }) |
| } |
| }; |
|
|
| 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:<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: &Config, get_args: GetArgs) -> 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(|_| "<redacted>"), |
| }), |
| }; |
| 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(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::<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}"); |
| let helper_display = http_headers_helper.as_ref().map_or("-", |_| "<redacted>"); |
| 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() |
| } |
| } |
|
|