Files
llmx/codex-rs/cli/src/mcp_cmd.rs
Gabriel Peal a43ae86b6c [MCP] Add support for streamable http servers with codex mcp add and replace bearer token handling (#4904)
1. You can now add streamable http servers via the CLI
2. As part of this, I'm also changing the existing bearer_token plain
text config field with ane env var

```
mcp add github --url https://api.githubcopilot.com/mcp/ --bearer-token-env-var=GITHUB_PAT
```
2025-10-07 23:21:37 -04:00

615 lines
19 KiB
Rust

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<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,
}
#[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<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,
}
}
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::<Vec<_>>()
.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!(
"{:<name_w$} {:<cmd_w$} {:<args_w$} {:<env_w$}",
"Name",
"Command",
"Args",
"Env",
name_w = widths[0],
cmd_w = widths[1],
args_w = widths[2],
env_w = widths[3],
);
for row in &stdio_rows {
println!(
"{:<name_w$} {:<cmd_w$} {:<args_w$} {:<env_w$}",
row[0],
row[1],
row[2],
row[3],
name_w = widths[0],
cmd_w = widths[1],
args_w = widths[2],
env_w = widths[3],
);
}
}
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()];
for row in &http_rows {
for (i, cell) in row.iter().enumerate() {
widths[i] = widths[i].max(cell.len());
}
}
println!(
"{:<name_w$} {:<url_w$} {:<token_w$}",
"Name",
"Url",
"Bearer Token Env Var",
name_w = widths[0],
url_w = widths[1],
token_w = widths[2],
);
for row in &http_rows {
println!(
"{:<name_w$} {:<url_w$} {:<token_w$}",
row[0],
row[1],
row[2],
name_w = widths[0],
url_w = widths[1],
token_w = widths[2],
);
}
}
Ok(())
}
async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> 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::<Vec<_>>()
.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, '-', '_')");
}
}