diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index e7fe145987..3cbed012c6 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -5,6 +5,7 @@ use crate::types::RateLimitStatusPayload; use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; use codex_client::build_reqwest_client_with_custom_ca; +use codex_client::log_http_request; use codex_core::auth::CodexAuth; use codex_core::default_client::get_codex_user_agent; use codex_protocol::account::PlanType as AccountPlanType; @@ -259,7 +260,9 @@ impl Client { PathStyle::CodexApi => format!("{}/api/codex/usage", self.base_url), PathStyle::ChatGptApi => format!("{}/wham/usage", self.base_url), }; - let req = self.http.get(&url).headers(self.headers()); + let headers = self.headers(); + log_http_request("GET", &url, &headers); + let req = self.http.get(&url).headers(headers); let (body, ct) = self.exec_request(req, "GET", &url).await?; let payload: RateLimitStatusPayload = self.decode_json(&url, &ct, &body)?; Ok(Self::rate_limit_snapshots_from_payload(payload)) @@ -276,7 +279,9 @@ impl Client { PathStyle::CodexApi => format!("{}/api/codex/tasks/list", self.base_url), PathStyle::ChatGptApi => format!("{}/wham/tasks/list", self.base_url), }; - let req = self.http.get(&url).headers(self.headers()); + let headers = self.headers(); + log_http_request("GET", &url, &headers); + let req = self.http.get(&url).headers(headers); let req = if let Some(lim) = limit { req.query(&[("limit", lim)]) } else { @@ -314,7 +319,9 @@ impl Client { PathStyle::CodexApi => format!("{}/api/codex/tasks/{}", self.base_url, task_id), PathStyle::ChatGptApi => format!("{}/wham/tasks/{}", self.base_url, task_id), }; - let req = self.http.get(&url).headers(self.headers()); + let headers = self.headers(); + log_http_request("GET", &url, &headers); + let req = self.http.get(&url).headers(headers); let (body, ct) = self.exec_request(req, "GET", &url).await?; let parsed: CodeTaskDetailsResponse = self.decode_json(&url, &ct, &body)?; Ok((parsed, body, ct)) @@ -335,7 +342,9 @@ impl Client { self.base_url, task_id, turn_id ), }; - let req = self.http.get(&url).headers(self.headers()); + let headers = self.headers(); + log_http_request("GET", &url, &headers); + let req = self.http.get(&url).headers(headers); let (body, ct) = self.exec_request(req, "GET", &url).await?; self.decode_json::(&url, &ct, &body) } @@ -351,7 +360,9 @@ impl Client { PathStyle::CodexApi => format!("{}/api/codex/config/requirements", self.base_url), PathStyle::ChatGptApi => format!("{}/wham/config/requirements", self.base_url), }; - let req = self.http.get(&url).headers(self.headers()); + let headers = self.headers(); + log_http_request("GET", &url, &headers); + let req = self.http.get(&url).headers(headers); let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; self.decode_json::(&url, &ct, &body) .map_err(RequestError::from) @@ -364,12 +375,10 @@ impl Client { PathStyle::CodexApi => format!("{}/api/codex/tasks", self.base_url), PathStyle::ChatGptApi => format!("{}/wham/tasks", self.base_url), }; - let req = self - .http - .post(&url) - .headers(self.headers()) - .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) - .json(&request_body); + let mut headers = self.headers(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + log_http_request("POST", &url, &headers); + let req = self.http.post(&url).headers(headers).json(&request_body); let (body, ct) = self.exec_request(req, "POST", &url).await?; // Extract id from JSON: prefer `task.id`; fallback to top-level `id` when present. match serde_json::from_str::(&body) { diff --git a/codex-rs/codex-client/src/default_client.rs b/codex-rs/codex-client/src/default_client.rs index 56b3ce4b16..8b31752992 100644 --- a/codex-rs/codex-client/src/default_client.rs +++ b/codex-rs/codex-client/src/default_client.rs @@ -8,6 +8,7 @@ use reqwest::IntoUrl; use reqwest::Method; use reqwest::Response; use serde::Serialize; +use std::collections::BTreeMap; use std::fmt::Display; use std::time::Duration; use tracing::Span; @@ -18,6 +19,11 @@ pub struct CodexHttpClient { inner: reqwest::Client, } +const CODEX_TRACE_HTTP_HEADERS_ENV: &str = "CODEX_TRACE_HTTP_HEADERS"; +const CODEX_TRACE_HTTP_HEADERS_INCLUDE_SENSITIVE_ENV: &str = + "CODEX_TRACE_HTTP_HEADERS_INCLUDE_SENSITIVE"; +const REDACTED_HEADER_VALUE: &str = ""; + impl CodexHttpClient { pub fn new(inner: reqwest::Client) -> Self { Self { inner } @@ -111,9 +117,18 @@ impl CodexRequestBuilder { } pub async fn send(self) -> Result { - let headers = trace_headers(); + let builder = self.builder.headers(trace_headers()); + if let Some(request_builder) = builder.try_clone() + && let Ok(request) = request_builder.build() + { + log_http_request( + self.method.as_str(), + request.url().as_str(), + request.headers(), + ); + } - match self.builder.headers(headers).send().await { + match builder.send().await { Ok(response) => { tracing::debug!( method = %self.method, @@ -165,6 +180,63 @@ fn trace_headers() -> HeaderMap { headers } +pub fn log_http_request(method: &str, url: &str, headers: &HeaderMap) { + if !http_trace_headers_enabled() { + return; + } + + tracing::info!( + method, + url, + headers = ?format_headers_for_log(headers), + "Outbound HTTP request" + ); +} + +pub fn format_headers_for_log(headers: &HeaderMap) -> BTreeMap { + let include_sensitive = http_trace_include_sensitive_headers(); + headers + .iter() + .map(|(name, value)| { + let name_str = name.as_str().to_ascii_lowercase(); + let value_str = if include_sensitive || !is_sensitive_header(&name_str) { + value.to_str().unwrap_or("").to_string() + } else { + REDACTED_HEADER_VALUE.to_string() + }; + (name_str, value_str) + }) + .collect() +} + +fn http_trace_headers_enabled() -> bool { + env_flag_enabled(CODEX_TRACE_HTTP_HEADERS_ENV) +} + +fn http_trace_include_sensitive_headers() -> bool { + env_flag_enabled(CODEX_TRACE_HTTP_HEADERS_INCLUDE_SENSITIVE_ENV) +} + +fn env_flag_enabled(name: &str) -> bool { + std::env::var(name) + .map(|value| { + let normalized = value.trim().to_ascii_lowercase(); + !normalized.is_empty() + && normalized != "0" + && normalized != "false" + && normalized != "no" + && normalized != "off" + }) + .unwrap_or(false) +} + +fn is_sensitive_header(name: &str) -> bool { + matches!( + name, + "authorization" | "proxy-authorization" | "cookie" | "set-cookie" | "x-api-key" | "api-key" + ) +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index 93dd81506f..d43efbece1 100644 --- a/codex-rs/codex-client/src/lib.rs +++ b/codex-rs/codex-client/src/lib.rs @@ -19,6 +19,8 @@ pub use crate::custom_ca::build_reqwest_client_with_custom_ca; pub use crate::custom_ca::maybe_build_rustls_client_config_with_custom_ca; pub use crate::default_client::CodexHttpClient; pub use crate::default_client::CodexRequestBuilder; +pub use crate::default_client::format_headers_for_log; +pub use crate::default_client::log_http_request; pub use crate::error::StreamError; pub use crate::error::TransportError; pub use crate::request::Request; diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 939affa9df..3063c55258 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -74,6 +74,7 @@ use tokio::sync::Mutex; use tokio::sync::oneshot; use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; +use tracing::info; use tracing::instrument; use tracing::warn; use url::Url; @@ -1440,6 +1441,11 @@ async fn start_server_task( .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); + log_startup_request_headers( + &server_name, + "initialize", + initialize_request_headers.as_ref(), + ); let initialize_result = client .initialize( params, @@ -1456,6 +1462,7 @@ async fn start_server_task( .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); + log_startup_request_headers(&server_name, "tools/list", list_request_headers.as_ref()); let tools = list_tools_for_client_uncached( &server_name, &client, @@ -1666,6 +1673,48 @@ fn transport_origin(transport: &McpServerTransportConfig) -> Option { } } +fn log_startup_request_headers( + server_name: &str, + phase: &str, + headers: Option<&reqwest::header::HeaderMap>, +) { + if std::env::var_os("CODEX_TRACE_HTTP_HEADERS").is_none() { + return; + } + + let session_id = headers.and_then(|headers| { + headers + .get("session_id") + .and_then(|value| value.to_str().ok()) + .map(str::to_string) + }); + let client_request_id = headers.and_then(|headers| { + headers + .get("x-client-request-id") + .and_then(|value| value.to_str().ok()) + .map(str::to_string) + }); + let turn_metadata = headers.and_then(|headers| { + headers + .get(crate::X_CODEX_TURN_METADATA_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) + }); + + info!( + server = server_name, + phase, + has_headers = headers.is_some(), + has_session_id = session_id.is_some(), + session_id, + has_client_request_id = client_request_id.is_some(), + client_request_id, + has_turn_metadata = turn_metadata.is_some(), + turn_metadata, + "MCP startup request headers snapshot" + ); +} + async fn list_tools_for_client_uncached( server_name: &str, client: &Arc, diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 42aeb149ad..632d6d8e31 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -11,6 +11,8 @@ use std::time::Duration; use anyhow::Result; use anyhow::anyhow; use codex_client::build_reqwest_client_with_custom_ca; +use codex_client::format_headers_for_log; +use codex_client::log_http_request; use futures::FutureExt; use futures::StreamExt; use futures::future::BoxFuture; @@ -20,6 +22,7 @@ use reqwest::header::ACCEPT; use reqwest::header::AUTHORIZATION; use reqwest::header::CONTENT_TYPE; use reqwest::header::HeaderMap; +use reqwest::header::HeaderValue; use reqwest::header::WWW_AUTHENTICATE; use rmcp::model::CallToolRequestParams; use rmcp::model::CallToolResult; @@ -83,6 +86,81 @@ const JSON_MIME_TYPE: &str = "application/json"; const HEADER_LAST_EVENT_ID: &str = "Last-Event-Id"; const HEADER_SESSION_ID: &str = "Mcp-Session-Id"; const NON_JSON_RESPONSE_BODY_PREVIEW_BYTES: usize = 8_192; +const CODEX_TRACE_HTTP_HEADERS_ENV: &str = "CODEX_TRACE_HTTP_HEADERS"; +const CODEX_TRACE_HTTP_BODIES_ENV: &str = "CODEX_TRACE_HTTP_BODIES"; + +fn http_trace_headers_enabled() -> bool { + std::env::var_os(CODEX_TRACE_HTTP_HEADERS_ENV).is_some_and(|value| value != "0") +} + +fn http_trace_bodies_enabled() -> bool { + std::env::var_os(CODEX_TRACE_HTTP_BODIES_ENV).is_some_and(|value| value != "0") +} + +fn log_mcp_http_request( + method: &str, + url: &str, + headers: &HeaderMap, + message: &rmcp::model::ClientJsonRpcMessage, +) { + log_http_request(method, url, headers); + if !http_trace_headers_enabled() && !http_trace_bodies_enabled() { + return; + } + + let body = if http_trace_bodies_enabled() { + serde_json::to_string(message).ok() + } else { + None + }; + + match message { + rmcp::model::JsonRpcMessage::Request(request) => { + tracing::info!( + method, + url, + rpc_method = request.request.method(), + rpc_id = ?request.id, + headers = ?format_headers_for_log(headers), + body, + "Outbound MCP HTTP request" + ); + } + rmcp::model::JsonRpcMessage::Notification(_) => { + tracing::info!( + method, + url, + rpc_kind = "notification", + headers = ?format_headers_for_log(headers), + body, + "Outbound MCP HTTP request" + ); + } + rmcp::model::JsonRpcMessage::Response(response) => { + tracing::info!( + method, + url, + rpc_kind = "response", + rpc_id = ?response.id, + headers = ?format_headers_for_log(headers), + body, + "Outbound MCP HTTP request" + ); + } + rmcp::model::JsonRpcMessage::Error(error) => { + tracing::info!( + method, + url, + rpc_kind = "error", + rpc_id = ?error.id, + headers = ?format_headers_for_log(headers), + body, + "Outbound MCP HTTP request" + ); + } + } +} + fn apply_request_scoped_headers( mut request: reqwest::RequestBuilder, request_headers_state: &Arc>>, @@ -102,16 +180,19 @@ fn apply_request_scoped_headers( #[derive(Clone)] struct StreamableHttpResponseClient { inner: reqwest::Client, + default_headers: HeaderMap, request_headers_state: Arc>>, } impl StreamableHttpResponseClient { fn new( inner: reqwest::Client, + default_headers: HeaderMap, request_headers_state: Arc>>, ) -> Self { Self { inner, + default_headers, request_headers_state, } } @@ -150,13 +231,25 @@ impl StreamableHttpClient for StreamableHttpResponseClient { .inner .post(uri.as_ref()) .header(ACCEPT, [EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE].join(", ")); + let mut request_headers = self.default_headers.clone(); + request_headers.insert( + ACCEPT, + HeaderValue::from_static("text/event-stream, application/json"), + ); if let Some(auth_header) = auth_token { + if let Ok(value) = HeaderValue::from_str(&format!("Bearer {auth_header}")) { + request_headers.insert(AUTHORIZATION, value); + } request = request.bearer_auth(auth_header); } if let Some(session_id_value) = session_id.as_ref() { + if let Ok(value) = HeaderValue::from_str(session_id_value.as_ref()) { + request_headers.insert(HEADER_SESSION_ID, value); + } request = request.header(HEADER_SESSION_ID, session_id_value.as_ref()); } request = apply_request_scoped_headers(request, &self.request_headers_state); + log_mcp_http_request("POST", uri.as_ref(), &request_headers, &message); let response = request .json(&message) @@ -249,11 +342,19 @@ impl StreamableHttpClient for StreamableHttpResponseClient { auth_token: Option, ) -> std::result::Result<(), StreamableHttpError> { let mut request_builder = self.inner.delete(uri.as_ref()); + let mut request_headers = self.default_headers.clone(); if let Some(auth_header) = auth_token { + if let Ok(value) = HeaderValue::from_str(&format!("Bearer {auth_header}")) { + request_headers.insert(AUTHORIZATION, value); + } request_builder = request_builder.bearer_auth(auth_header); } + if let Ok(value) = HeaderValue::from_str(session.as_ref()) { + request_headers.insert(HEADER_SESSION_ID, value); + } request_builder = apply_request_scoped_headers(request_builder, &self.request_headers_state); + log_http_request("DELETE", uri.as_ref(), &request_headers); let response = request_builder .header(HEADER_SESSION_ID, session.as_ref()) .send() @@ -285,14 +386,29 @@ impl StreamableHttpClient for StreamableHttpResponseClient { .get(uri.as_ref()) .header(ACCEPT, [EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE].join(", ")) .header(HEADER_SESSION_ID, session_id.as_ref()); + let mut request_headers = self.default_headers.clone(); + request_headers.insert( + ACCEPT, + HeaderValue::from_static("text/event-stream, application/json"), + ); + if let Ok(value) = HeaderValue::from_str(session_id.as_ref()) { + request_headers.insert(HEADER_SESSION_ID, value); + } if let Some(last_event_id) = last_event_id { + if let Ok(value) = HeaderValue::from_str(&last_event_id) { + request_headers.insert(HEADER_LAST_EVENT_ID, value); + } request_builder = request_builder.header(HEADER_LAST_EVENT_ID, last_event_id); } if let Some(auth_header) = auth_token { + if let Ok(value) = HeaderValue::from_str(&format!("Bearer {auth_header}")) { + request_headers.insert(AUTHORIZATION, value); + } request_builder = request_builder.bearer_auth(auth_header); } request_builder = apply_request_scoped_headers(request_builder, &self.request_headers_state); + log_http_request("GET", uri.as_ref(), &request_headers); let response = request_builder .send() @@ -1023,6 +1139,7 @@ impl RmcpClient { let transport = StreamableHttpClientTransport::with_client( StreamableHttpResponseClient::new( http_client, + default_headers.clone(), request_headers .clone() .unwrap_or_else(|| Arc::new(StdMutex::new(None))), @@ -1045,6 +1162,7 @@ impl RmcpClient { let transport = StreamableHttpClientTransport::with_client( StreamableHttpResponseClient::new( http_client, + default_headers.clone(), request_headers .clone() .unwrap_or_else(|| Arc::new(StdMutex::new(None))), @@ -1254,7 +1372,11 @@ async fn create_oauth_transport_and_runtime( }; let auth_client = AuthClient::new( - StreamableHttpResponseClient::new(http_client, Arc::new(StdMutex::new(None))), + StreamableHttpResponseClient::new( + http_client, + default_headers.clone(), + Arc::new(StdMutex::new(None)), + ), manager, ); let auth_manager = auth_client.auth_manager.clone();