Files
codex/codex-rs/cli/tests/mcp_add_remove.rs
Celia Chen 9ea975a2dc Route MCP OAuth through configured HTTP clients (#35806)
## What changed

- Pass resolved, route-aware HTTP clients through MCP OAuth discovery and login so CLI commands, plugin installation, and skill dependency setup honor configured proxies and execution environments.
- Apply per-plugin MCP server configuration and requirements before starting OAuth during installation, and skip disabled servers or servers assigned to unowned environments.
- Preserve configured MCP server policies when merging remotely installed plugin metadata.

## Testing

- Cover proxy-routed OAuth for `codex mcp add`, `codex mcp login`, plugin installation, and skill MCP dependencies.
- Cover plugin-install OAuth filtering for disabled servers, plugin requirements, and unowned environments.

GitOrigin-RevId: f84c88820e24a627faa78d6bed1b371682ecdc2f
2026-07-28 18:25:29 +00:00

394 lines
12 KiB
Rust

use std::path::Path;
use anyhow::Result;
use codex_config::types::McpServerTransportConfig;
use codex_core::config::load_global_mcp_servers;
use predicates::str::contains;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home);
Ok(cmd)
}
#[tokio::test]
async fn add_and_remove_server_updates_global_config() -> Result<()> {
let codex_home = TempDir::new()?;
let mut add_cmd = codex_command(codex_home.path())?;
add_cmd
.args(["mcp", "add", "docs", "--", "echo", "hello"])
.assert()
.success()
.stdout(contains("Added global MCP server 'docs'."));
let servers = load_global_mcp_servers(codex_home.path()).await?;
assert_eq!(servers.len(), 1);
let docs = servers.get("docs").expect("server should exist");
match &docs.transport {
McpServerTransportConfig::Stdio {
command,
args,
env,
env_vars,
cwd,
} => {
assert_eq!(command, "echo");
assert_eq!(args, &vec!["hello".to_string()]);
assert!(env.is_none());
assert!(env_vars.is_empty());
assert!(cwd.is_none());
}
other => panic!("unexpected transport: {other:?}"),
}
assert!(docs.enabled);
let mut remove_cmd = codex_command(codex_home.path())?;
remove_cmd
.args(["mcp", "remove", "docs"])
.assert()
.success()
.stdout(contains("Removed global MCP server 'docs'."));
let servers = load_global_mcp_servers(codex_home.path()).await?;
assert!(servers.is_empty());
let mut remove_again_cmd = codex_command(codex_home.path())?;
remove_again_cmd
.args(["mcp", "remove", "docs"])
.assert()
.success()
.stdout(contains("No MCP server named 'docs' found."));
let servers = load_global_mcp_servers(codex_home.path()).await?;
assert!(servers.is_empty());
Ok(())
}
#[tokio::test]
async fn add_and_login_discover_oauth_through_configured_http_proxy() -> Result<()> {
let codex_home = TempDir::new()?;
let proxy = MockServer::start().await;
let resource_url = "http://cli-mcp.invalid";
let challenge = "Bearer resource_metadata=\"http://cli-mcp.invalid/oauth-resource\"";
Mock::given(method("GET"))
.and(path("/mcp"))
.respond_with(ResponseTemplate::new(401).insert_header("WWW-Authenticate", challenge))
.mount(&proxy)
.await;
Mock::given(method("GET"))
.and(path("/oauth-resource"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"resource": format!("{resource_url}/mcp"),
"authorization_servers": [resource_url],
})))
.mount(&proxy)
.await;
Mock::given(method("GET"))
.and(path("/.well-known/oauth-authorization-server"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"authorization_endpoint": format!("{resource_url}/oauth/authorize"),
"token_endpoint": format!("{resource_url}/oauth/token"),
"registration_endpoint": format!("{resource_url}/oauth/register"),
"response_types_supported": ["code"],
"code_challenge_methods_supported": ["S256"],
})))
.mount(&proxy)
.await;
Mock::given(method("POST"))
.and(path("/oauth/register"))
.respond_with(ResponseTemplate::new(400))
.mount(&proxy)
.await;
let mut add = codex_command(codex_home.path())?;
add.env("HTTP_PROXY", proxy.uri())
.env("http_proxy", proxy.uri())
.env_remove("HTTPS_PROXY")
.env_remove("https_proxy")
.env_remove("ALL_PROXY")
.env_remove("all_proxy")
.env_remove("NO_PROXY")
.env_remove("no_proxy")
.args([
"-c",
"mcp_oauth_credentials_store=\"file\"",
"mcp",
"add",
"oauth",
"--url",
"http://cli-mcp.invalid/mcp",
]);
let add_output = tokio::task::spawn_blocking(move || add.output()).await??;
assert!(
!add_output.status.success(),
"mock OAuth registration should terminate the automatic login"
);
assert!(
load_global_mcp_servers(codex_home.path())
.await?
.contains_key("oauth")
);
// Local OAuth login does not require the execution-environment registry.
std::fs::write(codex_home.path().join("environments.toml"), "invalid = [")?;
let mut login = codex_command(codex_home.path())?;
login
.env("HTTP_PROXY", proxy.uri())
.env("http_proxy", proxy.uri())
.env_remove("HTTPS_PROXY")
.env_remove("https_proxy")
.env_remove("ALL_PROXY")
.env_remove("all_proxy")
.env_remove("NO_PROXY")
.env_remove("no_proxy")
.args([
"-c",
"mcp_oauth_credentials_store=\"file\"",
"mcp",
"login",
"oauth",
]);
let login_output = tokio::task::spawn_blocking(move || login.output()).await??;
assert!(
!login_output.status.success(),
"mock OAuth registration should terminate the explicit login"
);
let registrations = proxy
.received_requests()
.await
.expect("mock proxy should record OAuth requests")
.iter()
.filter(|request| request.method == "POST" && request.url.path() == "/oauth/register")
.count();
assert_eq!(registrations, 2);
Ok(())
}
#[tokio::test]
async fn profile_mcp_reports_legacy_profile_migration() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"[profiles.work]
model = "gpt-5"
"#,
)?;
let mut list_cmd = codex_command(codex_home.path())?;
list_cmd
.args(["--profile", "work", "mcp", "list"])
.assert()
.failure()
.stderr(contains("--profile `work` cannot be used"))
.stderr(contains("[profiles.work]"))
.stderr(contains("work.config.toml"));
Ok(())
}
#[tokio::test]
async fn add_with_env_preserves_key_order_and_values() -> Result<()> {
let codex_home = TempDir::new()?;
let mut add_cmd = codex_command(codex_home.path())?;
add_cmd
.args([
"mcp",
"add",
"envy",
"--env",
"FOO=bar",
"--env",
"ALPHA=beta",
"--",
"python",
"server.py",
])
.assert()
.success();
let servers = load_global_mcp_servers(codex_home.path()).await?;
let envy = servers.get("envy").expect("server should exist");
let env = match &envy.transport {
McpServerTransportConfig::Stdio { env: Some(env), .. } => env,
other => panic!("unexpected transport: {other:?}"),
};
assert_eq!(env.len(), 2);
assert_eq!(env.get("FOO"), Some(&"bar".to_string()));
assert_eq!(env.get("ALPHA"), Some(&"beta".to_string()));
assert!(envy.enabled);
Ok(())
}
#[tokio::test]
async fn add_streamable_http_without_manual_token() -> Result<()> {
let codex_home = TempDir::new()?;
let mut add_cmd = codex_command(codex_home.path())?;
add_cmd
.args(["mcp", "add", "github", "--url", "https://example.com/mcp"])
.assert()
.success();
let servers = load_global_mcp_servers(codex_home.path()).await?;
let github = servers.get("github").expect("github server should exist");
match &github.transport {
McpServerTransportConfig::StreamableHttp {
url,
bearer_token_env_var,
http_headers,
env_http_headers,
} => {
assert_eq!(url, "https://example.com/mcp");
assert!(bearer_token_env_var.is_none());
assert!(http_headers.is_none());
assert!(env_http_headers.is_none());
}
other => panic!("unexpected transport: {other:?}"),
}
assert!(github.enabled);
assert!(!codex_home.path().join(".credentials.json").exists());
assert!(!codex_home.path().join(".env").exists());
Ok(())
}
#[tokio::test]
async fn add_streamable_http_with_custom_env_var() -> Result<()> {
let codex_home = TempDir::new()?;
let mut add_cmd = codex_command(codex_home.path())?;
add_cmd
.args([
"mcp",
"add",
"issues",
"--url",
"https://example.com/issues",
"--bearer-token-env-var",
"GITHUB_TOKEN",
])
.assert()
.success();
let servers = load_global_mcp_servers(codex_home.path()).await?;
let issues = servers.get("issues").expect("issues server should exist");
match &issues.transport {
McpServerTransportConfig::StreamableHttp {
url,
bearer_token_env_var,
http_headers,
env_http_headers,
} => {
assert_eq!(url, "https://example.com/issues");
assert_eq!(bearer_token_env_var.as_deref(), Some("GITHUB_TOKEN"));
assert!(http_headers.is_none());
assert!(env_http_headers.is_none());
}
other => panic!("unexpected transport: {other:?}"),
}
assert!(issues.enabled);
Ok(())
}
#[tokio::test]
async fn add_streamable_http_with_oauth_options() -> Result<()> {
let codex_home = TempDir::new()?;
let mut add_cmd = codex_command(codex_home.path())?;
add_cmd
.args([
"mcp",
"add",
"oauth-server",
"--url",
"https://example.com/mcp",
"--oauth-client-id",
"eci-prd-pub-codex-123",
"--oauth-resource",
"https://resource.example.com",
])
.assert()
.success();
let servers = load_global_mcp_servers(codex_home.path()).await?;
let oauth_server = servers
.get("oauth-server")
.expect("oauth server should exist");
assert_eq!(
oauth_server.oauth_client_id(),
Some("eci-prd-pub-codex-123")
);
assert_eq!(
oauth_server.oauth_resource.as_deref(),
Some("https://resource.example.com")
);
Ok(())
}
#[tokio::test]
async fn add_streamable_http_rejects_removed_flag() -> Result<()> {
let codex_home = TempDir::new()?;
let mut add_cmd = codex_command(codex_home.path())?;
add_cmd
.args([
"mcp",
"add",
"github",
"--url",
"https://example.com/mcp",
"--with-bearer-token",
])
.assert()
.failure()
.stderr(contains("--with-bearer-token"));
let servers = load_global_mcp_servers(codex_home.path()).await?;
assert!(servers.is_empty());
Ok(())
}
#[tokio::test]
async fn add_cant_add_command_and_url() -> Result<()> {
let codex_home = TempDir::new()?;
let mut add_cmd = codex_command(codex_home.path())?;
add_cmd
.args([
"mcp",
"add",
"github",
"--url",
"https://example.com/mcp",
"--command",
"--",
"echo",
"hello",
])
.assert()
.failure()
.stderr(contains("unexpected argument '--command' found"));
let servers = load_global_mcp_servers(codex_home.path()).await?;
assert!(servers.is_empty());
Ok(())
}