mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
Add dynamic HTTP header helpers for MCP servers (#38245)
## 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
This commit is contained in:
@@ -25,6 +25,7 @@ use codex_login::AuthManager;
|
||||
use codex_mcp::McpOAuthLoginSupport;
|
||||
use codex_mcp::McpRuntimeContext;
|
||||
use codex_mcp::ResolvedMcpOAuthScopes;
|
||||
use codex_mcp::apply_http_headers_helper;
|
||||
use codex_mcp::compute_auth_statuses;
|
||||
use codex_mcp::discover_supported_scopes;
|
||||
use codex_mcp::oauth_login_support;
|
||||
@@ -398,6 +399,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
|
||||
bearer_token_env_var,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
http_headers_helper: None,
|
||||
},
|
||||
oauth_client_id,
|
||||
oauth_client_registration
|
||||
@@ -558,6 +560,8 @@ async fn run_login(config: &Config, login_args: LoginArgs) -> Result<()> {
|
||||
// environment routing belongs to app-server and session MCP flows.
|
||||
let http_client: Arc<dyn HttpClient> =
|
||||
Arc::new(RouteAwareHttpClient::new(config.http_client_factory()));
|
||||
let http_client = apply_http_headers_helper(http_client, server, config.cwd.to_path_buf())
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let explicit_scopes = (!scopes.is_empty()).then_some(scopes);
|
||||
let discovered_scopes = if explicit_scopes.is_none() && server.scopes.is_none() {
|
||||
discover_supported_scopes(
|
||||
@@ -679,6 +683,7 @@ async fn run_list(config: &Config, list_args: ListArgs) -> Result<()> {
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
http_headers_helper,
|
||||
} => {
|
||||
serde_json::json!({
|
||||
"type": "streamable_http",
|
||||
@@ -686,6 +691,9 @@ async fn run_list(config: &Config, list_args: ListArgs) -> Result<()> {
|
||||
"bearer_token_env_var": bearer_token_env_var,
|
||||
"http_headers": http_headers,
|
||||
"env_http_headers": env_http_headers,
|
||||
"http_headers_helper": http_headers_helper
|
||||
.as_ref()
|
||||
.map(|_| "<redacted>"),
|
||||
})
|
||||
}
|
||||
};
|
||||
@@ -914,12 +922,16 @@ async fn run_get(config: &Config, get_args: GetArgs) -> Result<()> {
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
http_headers_helper,
|
||||
} => 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,
|
||||
"http_headers_helper": http_headers_helper
|
||||
.as_ref()
|
||||
.map(|_| "<redacted>"),
|
||||
}),
|
||||
};
|
||||
let output = serde_json::to_string_pretty(&serde_json::json!({
|
||||
@@ -996,6 +1008,7 @@ async fn run_get(config: &Config, get_args: GetArgs) -> Result<()> {
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
http_headers_helper,
|
||||
} => {
|
||||
println!(" transport: streamable_http");
|
||||
println!(" url: {url}");
|
||||
@@ -1027,6 +1040,8 @@ async fn run_get(config: &Config, get_args: GetArgs) -> Result<()> {
|
||||
_ => "-".to_string(),
|
||||
};
|
||||
println!(" env_http_headers: {env_headers_display}");
|
||||
let helper_display = http_headers_helper.as_ref().map_or("-", |_| "<redacted>");
|
||||
println!(" http_headers_helper: {helper_display}");
|
||||
}
|
||||
}
|
||||
if let Some(timeout) = server.startup_timeout_sec {
|
||||
|
||||
@@ -137,6 +137,18 @@ async fn add_and_login_discover_oauth_through_configured_http_proxy() -> Result<
|
||||
.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 = [")?;
|
||||
@@ -164,14 +176,22 @@ async fn add_and_login_discover_oauth_through_configured_http_proxy() -> Result<
|
||||
"mock OAuth registration should terminate the explicit login"
|
||||
);
|
||||
|
||||
let registrations = proxy
|
||||
let requests = proxy
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("mock proxy should record OAuth requests")
|
||||
.expect("mock proxy should record OAuth requests");
|
||||
let registrations: Vec<_> = requests
|
||||
.iter()
|
||||
.filter(|request| request.method == "POST" && request.url.path() == "/oauth/register")
|
||||
.count();
|
||||
assert_eq!(registrations, 2);
|
||||
.collect();
|
||||
assert_eq!(registrations.len(), 2);
|
||||
assert_eq!(
|
||||
registrations
|
||||
.iter()
|
||||
.filter(|request| request.headers.get("x-gateway").is_some())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -259,6 +279,7 @@ async fn add_streamable_http_without_manual_token() -> Result<()> {
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(url, "https://example.com/mcp");
|
||||
assert!(bearer_token_env_var.is_none());
|
||||
@@ -305,6 +326,7 @@ async fn add_streamable_http_with_custom_env_var() -> Result<()> {
|
||||
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"));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::net::TcpListener;
|
||||
@@ -419,6 +420,69 @@ async fn list_and_get_render_expected_output() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_and_get_redact_http_headers_helper() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let marker = codex_home.path().join("helper-ran");
|
||||
let helper = toml::Value::String(format!("echo invoked > \"{}\"", marker.display()));
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!(
|
||||
"[mcp_servers.docs]\n\
|
||||
url = \"https://example.com/mcp\"\n\
|
||||
http_headers_helper = {helper}\n\
|
||||
[mcp_servers.authenticated]\n\
|
||||
url = \"https://example.com/mcp\"\n\
|
||||
http_headers = {{ Authorization = \"Bearer static\" }}\n\
|
||||
http_headers_helper = {helper}\n"
|
||||
),
|
||||
)?;
|
||||
|
||||
let list_output = codex_command(codex_home.path())?
|
||||
.args(["mcp", "list", "--json"])
|
||||
.output()?;
|
||||
assert!(list_output.status.success());
|
||||
let stdout = String::from_utf8(list_output.stdout)?;
|
||||
assert!(stdout.contains("http_headers_helper"));
|
||||
assert!(stdout.contains("<redacted>"));
|
||||
let entries: Vec<JsonValue> = serde_json::from_str(&stdout)?;
|
||||
let auth_statuses = entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
(
|
||||
entry["name"].as_str().expect("server name").to_string(),
|
||||
entry["auth_status"]
|
||||
.as_str()
|
||||
.expect("auth status")
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
assert_eq!(
|
||||
auth_statuses,
|
||||
BTreeMap::from([
|
||||
("authenticated".to_string(), "bearer_token".to_string()),
|
||||
("docs".to_string(), "unknown".to_string()),
|
||||
])
|
||||
);
|
||||
assert!(!marker.exists());
|
||||
|
||||
for args in [
|
||||
&["mcp", "get", "docs", "--json"][..],
|
||||
&["mcp", "get", "docs"][..],
|
||||
] {
|
||||
let output = codex_command(codex_home.path())?.args(args).output()?;
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
assert!(stdout.contains("http_headers_helper"));
|
||||
assert!(stdout.contains("<redacted>"));
|
||||
assert!(!stdout.contains("helper-ran"));
|
||||
assert!(!marker.exists());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_disabled_server_shows_single_line() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
Reference in New Issue
Block a user