diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index a105dbbac1..3dc9f86cd5 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -77,12 +77,20 @@ pub struct AddArgs { /// Name for the MCP server configuration. pub name: String, - /// Environment variables to set when launching the server. - #[arg(long, value_parser = parse_env_pair, value_name = "KEY=VALUE")] + /// Environment variables to set when launching a stdio server. + #[arg(long, value_parser = parse_env_pair, value_name = "KEY=VALUE", conflicts_with = "url")] pub env: Vec<(String, String)>, - /// Command to launch the MCP server. - #[arg(trailing_var_arg = true, num_args = 1..)] + /// URL for a streamable HTTP MCP server. + #[arg(long, value_name = "URL")] + pub url: Option, + + /// Optional bearer token for streamable HTTP servers. + #[arg(long, value_name = "TOKEN", requires = "url")] + pub bearer_token: Option, + + /// Command to launch a stdio MCP server. + #[arg(trailing_var_arg = true)] pub command: Vec, } @@ -140,39 +148,57 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re // Validate any provided overrides even though they are not currently applied. config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; - let AddArgs { name, env, command } = add_args; + let AddArgs { + name, + env, + url, + bearer_token, + command, + } = add_args; validate_server_name(&name)?; - let mut command_parts = 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 env.is_empty() { - None - } else { - let mut map = HashMap::new(); - for (key, value) in env { - map.insert(key, value); - } - Some(map) - }; - 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 new_entry = McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command: command_bin, - args: command_args, - env: env_map, - }, - startup_timeout_sec: None, - tool_timeout_sec: None, + let new_entry = if let Some(url) = url { + if !command.is_empty() { + bail!("command arguments are not supported when --url is provided"); + } + + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { url, bearer_token }, + startup_timeout_sec: None, + tool_timeout_sec: None, + } + } else { + let mut command_parts = command.into_iter(); + let command_bin = command_parts + .next() + .ok_or_else(|| anyhow!("command is required when --url is not provided"))?; + let command_args: Vec = command_parts.collect(); + + let env_map = if env.is_empty() { + None + } else { + let mut map = HashMap::new(); + for (key, value) in env { + map.insert(key, value); + } + Some(map) + }; + + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: command_bin, + args: command_args, + env: env_map, + }, + startup_timeout_sec: None, + tool_timeout_sec: None, + } }; servers.insert(name.clone(), new_entry); diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs index 6530760e4f..e9d278a7f2 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -93,3 +93,64 @@ async fn add_with_env_preserves_key_order_and_values() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn add_streamable_http_server_updates_global_config() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add_cmd = codex_command(codex_home.path())?; + add_cmd + .args([ + "mcp", + "add", + "remote", + "--url", + "http://127.0.0.1:1234/mcp", + "--bearer-token", + "token-123", + ]) + .assert() + .success() + .stdout(contains("Added global MCP server 'remote'.")); + + let servers = load_global_mcp_servers(codex_home.path()).await?; + assert_eq!(servers.len(), 1); + let remote = servers.get("remote").expect("server should exist"); + match &remote.transport { + McpServerTransportConfig::StreamableHttp { url, bearer_token } => { + assert_eq!(url, "http://127.0.0.1:1234/mcp"); + assert_eq!(bearer_token.as_deref(), Some("token-123")); + } + other => panic!("unexpected transport: {other:?}"), + } + + Ok(()) +} + +#[tokio::test] +async fn add_streamable_http_server_rejects_command_args() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add_cmd = codex_command(codex_home.path())?; + add_cmd + .args([ + "mcp", + "add", + "broken", + "--url", + "http://127.0.0.1:3845/mcp", + "--", + "python", + "server.py", + ]) + .assert() + .failure() + .stderr(contains( + "command arguments are not supported when --url is provided", + )); + + let servers = load_global_mcp_servers(codex_home.path()).await?; + assert!(servers.is_empty()); + + Ok(()) +} diff --git a/docs/config.md b/docs/config.md index f079adda6d..b4629b4d12 100644 --- a/docs/config.md +++ b/docs/config.md @@ -392,9 +392,12 @@ experimental_use_rmcp_client = true ### MCP CLI commands ```shell -# Add a server (env can be repeated; `--` separates the launcher command) +# Add a stdio server (env can be repeated; `--` separates the launcher command) codex mcp add docs -- docs-server --port 4000 +# Add a streamable HTTP server (requires experimental_use_rmcp_client = true) +codex mcp add figma --url http://127.0.0.1:3845/mcp --bearer-token token + # List configured servers (pretty table or JSON) codex mcp list codex mcp list --json