Fixes [#8889](https://github.com/openai/codex/issues/8889). ## Summary - Discover and use advertised MCP OAuth `scopes_supported` when no explicit or configured scopes are present. - Apply the same scope precedence across `mcp add`, `mcp login`, skill dependency auto-login, and app-server MCP OAuth login. - Keep discovered scopes ephemeral and non-persistent. - Retry once without scopes for CLI and skill auto-login flows if the OAuth provider rejects discovered scopes. ## Motivation Some MCP servers advertise the scopes they expect clients to request during OAuth, but Codex was ignoring that metadata and typically starting OAuth with no scopes unless the user manually passed `--scopes` or configured `server.scopes`. That made compliant MCP servers harder to use out of the box and is the behavior described in [#8889](https://github.com/openai/codex/issues/8889). This change also brings our behavior in line with the MCP authorization spec's scope selection guidance: https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-selection-strategy ## Behavior Scope selection now follows this order everywhere: 1. Explicit request scopes / CLI `--scopes` 2. Configured `server.scopes` 3. Discovered `scopes_supported` 4. Legacy empty-scope behavior Compatibility notes: - Existing working setups keep the same behavior because explicit and configured scopes still win. - Discovered scopes are never written back into config or token storage. - If discovery is missing, malformed, or empty, behavior falls back to the previous empty-scope path. - App-server login gets the same precedence rules, but does not add a transparent retry path in this change. ## Implementation - Extend streamable HTTP OAuth discovery to parse and normalize `scopes_supported`. - Add a shared MCP scope resolver in `core` so all login entrypoints use the same precedence rules. - Preserve provider callback errors from the OAuth flow so CLI/skill flows can safely distinguish provider rejections from other failures. - Reuse discovered scopes from the existing OAuth support check where possible instead of persisting new config.
909 lines
30 KiB
Rust
909 lines
30 KiB
Rust
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_core::config::Config;
|
|
use codex_core::config::edit::ConfigEditsBuilder;
|
|
use codex_core::config::find_codex_home;
|
|
use codex_core::config::load_global_mcp_servers;
|
|
use codex_core::config::types::McpServerConfig;
|
|
use codex_core::config::types::McpServerTransportConfig;
|
|
use codex_core::mcp::McpManager;
|
|
use codex_core::mcp::auth::McpOAuthLoginSupport;
|
|
use codex_core::mcp::auth::ResolvedMcpOAuthScopes;
|
|
use codex_core::mcp::auth::compute_auth_statuses;
|
|
use codex_core::mcp::auth::discover_supported_scopes;
|
|
use codex_core::mcp::auth::oauth_login_support;
|
|
use codex_core::mcp::auth::resolve_oauth_scopes;
|
|
use codex_core::mcp::auth::should_retry_without_scopes;
|
|
use codex_core::plugins::PluginsManager;
|
|
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::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>,
|
|
}
|
|
|
|
#[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) -> 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(())
|
|
}
|
|
}
|
|
|
|
/// 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_rmcp_client::OAuthCredentialsStoreMode,
|
|
http_headers: Option<HashMap<String, String>>,
|
|
env_http_headers: Option<HashMap<String, String>>,
|
|
resolved_scopes: &ResolvedMcpOAuthScopes,
|
|
oauth_resource: Option<&str>,
|
|
callback_port: Option<u16>,
|
|
callback_url: Option<&str>,
|
|
) -> Result<()> {
|
|
match perform_oauth_login(
|
|
name,
|
|
url,
|
|
store_mode,
|
|
http_headers.clone(),
|
|
env_http_headers.clone(),
|
|
&resolved_scopes.scopes,
|
|
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,
|
|
http_headers,
|
|
env_http_headers,
|
|
&[],
|
|
oauth_resource,
|
|
callback_port,
|
|
callback_url,
|
|
)
|
|
.await
|
|
}
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|
|
|
|
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 = 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,
|
|
}
|
|
}
|
|
AddMcpTransportArgs {
|
|
streamable_http:
|
|
Some(AddMcpStreamableHttpArgs {
|
|
url,
|
|
bearer_token_env_var,
|
|
}),
|
|
..
|
|
} => McpServerTransportConfig::StreamableHttp {
|
|
url,
|
|
bearer_token_env_var,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
},
|
|
AddMcpTransportArgs { .. } => bail!("exactly one of --command or --url must be provided"),
|
|
};
|
|
|
|
let new_entry = McpServerConfig {
|
|
transport: transport.clone(),
|
|
enabled: true,
|
|
required: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: None,
|
|
tool_timeout_sec: None,
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
oauth_resource: None,
|
|
};
|
|
|
|
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(None, None, oauth_config.discovered_scopes.clone());
|
|
perform_oauth_login_retry_without_scopes(
|
|
&name,
|
|
&oauth_config.url,
|
|
config.mcp_oauth_credentials_store_mode,
|
|
oauth_config.http_headers,
|
|
oauth_config.env_http_headers,
|
|
&resolved_scopes,
|
|
None,
|
|
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.clone())));
|
|
let mcp_servers = mcp_manager.effective_servers(&config, None);
|
|
|
|
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,
|
|
http_headers,
|
|
env_http_headers,
|
|
&resolved_scopes,
|
|
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.clone())));
|
|
let mcp_servers = mcp_manager.effective_servers(&config, None);
|
|
|
|
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) {
|
|
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.clone())));
|
|
let mcp_servers = mcp_manager.effective_servers(&config, None);
|
|
|
|
let mut entries: Vec<_> = mcp_servers.iter().collect();
|
|
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
let auth_statuses =
|
|
compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode).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.clone())));
|
|
let mcp_servers = mcp_manager.effective_servers(&config, None);
|
|
|
|
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(|(a, _), (b, _)| a.cmp(b));
|
|
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(|(a, _), (b, _)| a.cmp(b));
|
|
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());
|
|
}
|
|
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()
|
|
}
|
|
}
|