Skip to content
2 changes: 2 additions & 0 deletions codex-rs/codex-mcp/src/connection_manager_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1418,6 +1418,7 @@ fn server_metadata_preserves_tool_approval_policy() {
let mut config = crate::codex_apps_mcp_server_config(
"https://docs.example",
/*apps_mcp_product_sku*/ None,
/*originator*/ None,
);
config.environment_id = "remote".to_string();
config.default_tools_approval_mode = Some(AppToolApproval::Prompt);
Expand Down Expand Up @@ -1462,6 +1463,7 @@ fn host_owned_codex_apps_matches_reserved_name_with_server_metadata() {
let server = EffectiveMcpServer::configured(crate::codex_apps_mcp_server_config(
"https://chatgpt.com",
/*apps_mcp_product_sku*/ None,
/*originator*/ None,
));
manager.server_metadata.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
Expand Down
16 changes: 11 additions & 5 deletions codex-rs/codex-mcp/src/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,10 +493,12 @@ fn codex_apps_mcp_url_for_base_url(base_url: &str) -> String {
pub fn codex_apps_mcp_server_config(
chatgpt_base_url: &str,
apps_mcp_product_sku: Option<&str>,
originator: Option<&str>,
) -> McpServerConfig {
mcp_server_config_for_url(
codex_apps_mcp_url_for_base_url(chatgpt_base_url),
apps_mcp_product_sku,
originator,
McpServerAuth::ChatGpt,
)
}
Expand All @@ -505,6 +507,7 @@ pub fn codex_apps_mcp_server_config(
pub fn hosted_plugin_runtime_mcp_server_config(
chatgpt_base_url: &str,
apps_mcp_product_sku: Option<&str>,
originator: Option<&str>,
) -> McpServerConfig {
let base_url = normalize_codex_apps_base_url(chatgpt_base_url);
let base_url = if base_url.contains("/backend-api") || base_url.contains("/api/codex") {
Expand All @@ -515,26 +518,29 @@ pub fn hosted_plugin_runtime_mcp_server_config(
mcp_server_config_for_url(
format!("{base_url}/ps/mcp"),
apps_mcp_product_sku,
originator,
McpServerAuth::ChatGpt,
)
}

fn mcp_server_config_for_url(
url: String,
apps_mcp_product_sku: Option<&str>,
originator: Option<&str>,
auth_mode: McpServerAuth,
) -> McpServerConfig {
let product_sku = apps_mcp_product_sku.unwrap_or(DEFAULT_CODEX_APPS_MCP_PRODUCT_SKU);
let http_headers = Some(HashMap::from([(
"X-OpenAI-Product-Sku".to_string(),
product_sku.to_string(),
)]));
let mut http_headers =
HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.to_string())]);
if let Some(originator) = originator {
http_headers.insert("originator".to_string(), originator.to_string());
}

McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url,
bearer_token_env_var: codex_apps_mcp_bearer_token_env_var(),
http_headers,
http_headers: Some(http_headers),
env_http_headers: None,
},
auth: auth_mode,
Expand Down
80 changes: 75 additions & 5 deletions codex-rs/codex-mcp/src/mcp/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,11 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() {
"alpha".to_string(),
McpPluginAttribution::new("alpha@test".to_string(), "alpha-plugin".to_string()),
/*plugin_order*/ 0,
codex_apps_mcp_server_config("https://alpha.example", /*apps_mcp_product_sku*/ None),
codex_apps_mcp_server_config(
"https://alpha.example",
/*apps_mcp_product_sku*/ None,
/*originator*/ None,
),
));
config.mcp_server_catalog = catalog.build();
config.connector_snapshot =
Expand Down Expand Up @@ -197,7 +201,11 @@ fn selected_mcp_attribution_does_not_join_an_unrelated_local_summary() {
"Executor GitHub".to_string(),
),
/*selection_order*/ 0,
codex_apps_mcp_server_config("https://github.example", /*apps_mcp_product_sku*/ None),
codex_apps_mcp_server_config(
"https://github.example",
/*apps_mcp_product_sku*/ None,
/*originator*/ None,
),
));
config.mcp_server_catalog = catalog.build();
config.connector_snapshot =
Expand Down Expand Up @@ -252,8 +260,11 @@ fn codex_apps_mcp_url_for_base_url_keeps_existing_paths() {

#[test]
fn codex_apps_server_config_uses_legacy_codex_apps_path() {
let config =
codex_apps_mcp_server_config("https://chatgpt.com", /*apps_mcp_product_sku*/ None);
let config = codex_apps_mcp_server_config(
"https://chatgpt.com",
/*apps_mcp_product_sku*/ None,
/*originator*/ None,
);
let url = match &config.transport {
McpServerTransportConfig::StreamableHttp { url, .. } => url,
_ => panic!("expected streamable http transport for codex apps"),
Expand All @@ -262,10 +273,41 @@ fn codex_apps_server_config_uses_legacy_codex_apps_path() {
assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps");
}

#[test]
fn codex_apps_server_config_forwards_thread_originator_header() {
let config = codex_apps_mcp_server_config(
"https://chatgpt.com",
/*apps_mcp_product_sku*/ None,
Some("thread_originator"),
);

match &config.transport {
McpServerTransportConfig::StreamableHttp {
http_headers,
env_http_headers,
..
} => {
assert_eq!(
http_headers,
&Some(HashMap::from([
("originator".to_string(), "thread_originator".to_string()),
("X-OpenAI-Product-Sku".to_string(), "codex".to_string()),
]))
);
assert!(env_http_headers.is_none());
}
other => panic!("expected streamable http transport, got {other:?}"),
}
}

#[test]
fn codex_apps_server_config_sets_product_sku_header() {
for (configured_product_sku, expected_product_sku) in [(None, "codex"), (Some("tpp"), "tpp")] {
let config = codex_apps_mcp_server_config("https://chatgpt.com", configured_product_sku);
let config = codex_apps_mcp_server_config(
"https://chatgpt.com",
configured_product_sku,
/*originator*/ None,
);

match &config.transport {
McpServerTransportConfig::StreamableHttp {
Expand All @@ -287,6 +329,33 @@ fn codex_apps_server_config_sets_product_sku_header() {
}
}

#[test]
fn codex_apps_server_config_forwards_originator_and_configured_product_sku_headers() {
let config = codex_apps_mcp_server_config(
"https://chatgpt.com",
Some("tpp"),
Some("thread_originator"),
);

match &config.transport {
McpServerTransportConfig::StreamableHttp {
http_headers,
env_http_headers,
..
} => {
assert_eq!(
http_headers,
&Some(HashMap::from([
("originator".to_string(), "thread_originator".to_string()),
("X-OpenAI-Product-Sku".to_string(), "tpp".to_string()),
]))
);
assert!(env_http_headers.is_none());
}
other => panic!("expected streamable http transport, got {other:?}"),
}
}

#[tokio::test]
async fn effective_mcp_servers_preserve_runtime_servers() {
let codex_home = tempfile::tempdir().expect("tempdir");
Expand Down Expand Up @@ -352,6 +421,7 @@ async fn effective_mcp_servers_preserve_runtime_servers() {
codex_apps_mcp_server_config(
&config.chatgpt_base_url,
config.apps_mcp_product_sku.as_deref(),
/*originator*/ None,
),
));
config.mcp_server_catalog = catalog.build();
Expand Down
31 changes: 22 additions & 9 deletions codex-rs/core/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,30 +79,42 @@ impl McpManager {
/// Returns the MCP config after applying compatibility built-ins and
/// runtime-only extension overlays.
pub async fn runtime_config(&self, config: &Config) -> McpConfig {
self.runtime_config_with_context(McpServerContributionContext::global(config))
.await
.config
self.runtime_config_with_context(
McpServerContributionContext::global(config),
// Threadless discovery and control-plane paths have no effective thread
// originator; active-thread tool calls use runtime_config_for_step below.
/*originator*/
None,
)
.await
.config
}

pub(crate) async fn runtime_config_for_step(
&self,
config: &Config,
thread_init: &ExtensionDataInit,
thread_store: &ExtensionData,
originator: &str,
available_environment_ids: &[String],
) -> McpRuntimeProjection {
self.runtime_config_with_context(McpServerContributionContext::for_step(
config,
thread_init,
thread_store,
available_environment_ids,
))
self.runtime_config_with_context(
McpServerContributionContext::for_step(
config,
thread_init,
thread_store,
originator,
available_environment_ids,
),
Some(originator),
)
.await
}

async fn runtime_config_with_context(
&self,
context: McpServerContributionContext<'_, Config>,
originator: Option<&str>,
) -> McpRuntimeProjection {
let config = context.config();
let mut selected_plugin_available = false;
Expand Down Expand Up @@ -181,6 +193,7 @@ impl McpManager {
codex_apps_mcp_server_config(
&mcp_config.chatgpt_base_url,
mcp_config.apps_mcp_product_sku.as_deref(),
originator,
),
));
} else {
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/src/mcp_tool_call_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1435,6 +1435,7 @@ async fn host_owned_codex_apps_manager(
codex_mcp::EffectiveMcpServer::configured(codex_mcp::codex_apps_mcp_server_config(
"https://chatgpt.com",
/*apps_mcp_product_sku*/ None,
Some(&turn_context.originator),
)),
)]);
let manager = codex_mcp::McpConnectionManager::new(
Expand Down
5 changes: 5 additions & 0 deletions codex-rs/core/src/session/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ impl ElicitationReviewer for GuardianMcpElicitationReviewer {

impl Session {
pub(crate) async fn runtime_mcp_config(&self, config: &Config) -> McpConfig {
let originator = self.originator().await;
let environments = self.services.turn_environments.snapshot().await;
let selected_capability_roots = self
.resolve_selected_capability_roots_for_step(&environments)
Expand All @@ -90,6 +91,7 @@ impl Session {
config,
&self.services.mcp_thread_init,
&self.services.thread_extension_data,
&originator,
&available_environment_ids,
)
.await
Expand Down Expand Up @@ -132,6 +134,7 @@ impl Session {
&turn_context.config,
&self.services.mcp_thread_init,
&self.services.thread_extension_data,
&turn_context.originator,
&available_environment_ids,
)
.await;
Expand Down Expand Up @@ -475,6 +478,7 @@ impl Session {
&refresh_config,
&self.services.mcp_thread_init,
&self.services.thread_extension_data,
&turn_context.originator,
&available_environment_ids,
)
.await;
Expand Down Expand Up @@ -543,6 +547,7 @@ impl Session {
refresh_config,
&self.services.mcp_thread_init,
&self.services.thread_extension_data,
&turn_context.originator,
&available_environment_ids,
)
.await;
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/core/src/session/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,7 @@ impl Session {
let mcp_manager_for_mcp = Arc::clone(&mcp_manager);
let mcp_thread_init_for_startup = &mcp_thread_init;
let thread_extension_data_for_mcp = &thread_extension_data;
let mcp_originator = session_configuration.originator.clone();
let mcp_runtime_cwd = session_configuration
.environment_selections()
.first()
Expand All @@ -688,6 +689,7 @@ impl Session {
&config_for_mcp,
mcp_thread_init_for_startup,
thread_extension_data_for_mcp,
&mcp_originator,
/*available_environment_ids*/ &[],
)
.await;
Expand Down
26 changes: 25 additions & 1 deletion codex-rs/core/src/thread_manager_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors()
let mut server = codex_mcp::codex_apps_mcp_server_config(
"https://selected.invalid",
/*apps_mcp_product_sku*/ None,
/*originator*/ None,
);
let CapabilityRootLocation::Environment { environment_id, .. } =
&selected_root.location;
Expand All @@ -574,6 +575,10 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors()
let mut config = test_config().await;
config.codex_home = temp_dir.path().join("codex-home").abs();
config.cwd = config.codex_home.abs();
config
.features
.enable(Feature::Apps)
.expect("test config should allow apps");
std::fs::create_dir_all(&config.codex_home).expect("create codex home");

let lifecycle_observed = Arc::new(std::sync::Mutex::new(Vec::new()));
Expand Down Expand Up @@ -620,7 +625,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors()
session_source: None,
thread_source: None,
dynamic_tools: Vec::new(),
metrics_service_name: None,
metrics_service_name: Some("codex_work_desktop".to_string()),
parent_trace: None,
environments: Vec::new(),
thread_extension_init: selected_root_init("selected-a", "env-a"),
Expand All @@ -646,24 +651,28 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors()
.await
.expect("start second thread");
let first_session = &first_thread.thread.codex.session;
let first_originator = first_session.originator().await;
let first_resolved = first_session
.services
.mcp_manager
.runtime_config_for_step(
&config,
&first_session.services.mcp_thread_init,
&first_session.services.thread_extension_data,
&first_originator,
/*available_environment_ids*/ &[],
)
.await;
let second_session = &second_thread.thread.codex.session;
let second_originator = second_session.originator().await;
let second_resolved = second_session
.services
.mcp_manager
.runtime_config_for_step(
&config,
&second_session.services.mcp_thread_init,
&second_session.services.thread_extension_data,
&second_originator,
/*available_environment_ids*/ &[],
)
.await;
Expand Down Expand Up @@ -706,6 +715,21 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors()
selected_servers(&second_resolved.config),
std::collections::BTreeMap::from([("selected-b".to_string(), "env-b".to_string())])
);
let codex_apps_server = codex_mcp::configured_mcp_servers(&first_resolved.config)
.remove(codex_mcp::CODEX_APPS_MCP_SERVER_NAME)
.expect("Codex Apps server should be configured");
let codex_apps_headers = match codex_apps_server.transport {
codex_config::McpServerTransportConfig::StreamableHttp { http_headers, .. } => http_headers,
codex_config::McpServerTransportConfig::Stdio { .. } => {
panic!("Codex Apps server should use streamable HTTP")
}
};
assert_eq!(
codex_apps_headers
.expect("Codex Apps headers should be configured")
.get("originator"),
Some(&"codex_work_desktop".to_string())
);
}

#[tokio::test]
Expand Down
Loading
Loading