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:
xl-openai
2026-08-12 20:25:24 +00:00
committed by copyberry
parent 8d4d57387a
commit 379cb68444
38 changed files with 1285 additions and 72 deletions

View File

@@ -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 {

View File

@@ -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"));

View File

@@ -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()?;