mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
## What changed - Add `http_headers_helper` configuration for local streamable HTTP MCP servers. The configured shell command runs once per connection and returns a JSON object of headers that is cached across requests. - Apply helper headers to MCP startup and OAuth flows while restricting them to the server origin, stopping redirects, rejecting reserved or duplicate headers, and enforcing output and execution limits. - Reject helpers for remote or managed-disabled servers, use the local environment working directory, and redact helper commands from `codex mcp list` and `codex mcp get` output. ## Testing - Cover configuration validation, helper lifecycle and output parsing, origin isolation, OAuth discovery and token refresh, managed requirements, environment selection, and CLI redaction. GitOrigin-RevId: 84e0e26ce75520b0869d37c72b1678e033bd6818
428 lines
13 KiB
Rust
428 lines
13 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")
|
|
);
|
|
let helper_command = if cfg!(windows) {
|
|
r#"echo {"X-Gateway":"gateway-token"}"#
|
|
} else {
|
|
r#"printf '{"X-Gateway":"gateway-token"}'"#
|
|
};
|
|
let config_path = codex_home.path().join("config.toml");
|
|
let mut config = std::fs::read_to_string(&config_path)?;
|
|
config.push_str(&format!(
|
|
"http_headers_helper = {}\n",
|
|
toml::Value::String(helper_command.to_string())
|
|
));
|
|
std::fs::write(config_path, config)?;
|
|
|
|
// 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 requests = proxy
|
|
.received_requests()
|
|
.await
|
|
.expect("mock proxy should record OAuth requests");
|
|
let registrations: Vec<_> = requests
|
|
.iter()
|
|
.filter(|request| request.method == "POST" && request.url.path() == "/oauth/register")
|
|
.collect();
|
|
assert_eq!(registrations.len(), 2);
|
|
assert_eq!(
|
|
registrations
|
|
.iter()
|
|
.filter(|request| request.headers.get("x-gateway").is_some())
|
|
.count(),
|
|
1
|
|
);
|
|
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",
|
|
"--oauth-client-registration",
|
|
"dcr",
|
|
])
|
|
.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_eq!(github.oauth, None);
|
|
|
|
assert!(!codex_home.path().join(".credentials.json").exists());
|
|
assert!(!codex_home.path().join(".env").exists());
|
|
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
|
assert!(!config.contains("client_registration"));
|
|
assert!(!config.contains("[mcp_servers.github.oauth]"));
|
|
|
|
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(())
|
|
}
|