Allow ChatGPT-hosted MCP servers to use session auth (#29733)

## Why

ChatGPT session authentication was inferred from the reserved Codex Apps
server name. That couples credential routing to Codex Apps-specific
behavior and prevents other MCP endpoints hosted by ChatGPT from
explicitly using the current session.

The opt-in also needs a clear security boundary: an arbitrary MCP
configuration must not be able to redirect ChatGPT credentials to
another origin.

## What changed

- Add `use_chatgpt_auth` to HTTP MCP server configuration, defaulting to
`false`.
- Honor the setting only when the parsed server URL has the same HTTP(S)
origin as the configured `chatgpt_base_url`; otherwise remove the
capability before startup.
- Resolve bearer tokens and static or environment-backed authorization
headers before selecting authentication, with configured authorization
taking precedence over ChatGPT session auth.
- Enable the setting for the built-in Codex Apps and hosted plugin
runtime endpoints while keeping Codex Apps caching and tool
normalization scoped to the reserved server.
- Persist the setting through MCP config rewrite paths and expose it in
the generated config schema.
- Load the current login state for `codex mcp list` so reported auth
status matches runtime behavior.

## Verification

Core integration coverage exercises the complete streamable HTTP MCP
startup path and verifies that:

- a same-origin opted-in server receives the current ChatGPT access
token;
- an explicitly configured authorization header takes precedence;
- a different-origin server completes MCP initialization and tool
listing without receiving any ChatGPT authorization header.
This commit is contained in:
Ahmed Ibrahim
2026-06-24 19:21:28 -07:00
committed by GitHub
Unverified
parent a0d5fd772e
commit 4c0706e24a
29 changed files with 313 additions and 59 deletions
+7 -2
View File
@@ -18,6 +18,7 @@ use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::find_codex_home;
use codex_core::config::load_global_mcp_servers;
use codex_core_plugins::PluginsManager;
use codex_login::AuthManager;
use codex_mcp::McpOAuthLoginSupport;
use codex_mcp::ResolvedMcpOAuthScopes;
use codex_mcp::compute_auth_statuses;
@@ -344,6 +345,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
};
let new_entry = McpServerConfig {
use_chatgpt_auth: false,
transport: transport.clone(),
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
@@ -543,8 +545,11 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
config.codex_home.to_path_buf(),
)));
let auth_manager =
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true).await;
let auth = auth_manager.auth().await;
let mcp_servers = mcp_manager.configured_servers(&config).await;
let effective_mcp_servers = mcp_manager.effective_servers(&config, /*auth*/ None).await;
let effective_mcp_servers = mcp_manager.effective_servers(&config, auth.as_ref()).await;
let mut entries: Vec<_> = mcp_servers.iter().collect();
entries.sort_by_key(|(name, _)| *name);
@@ -552,7 +557,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
effective_mcp_servers.iter(),
config.mcp_oauth_credentials_store_mode,
config.auth_keyring_backend_kind(),
/*auth*/ None,
auth.as_ref(),
)
.await;
+1
View File
@@ -17,6 +17,7 @@ use super::ResolvedMcpCatalog;
fn server(url: &str) -> McpServerConfig {
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: url.to_string(),
bearer_token_env_var: None,
+33 -50
View File
@@ -154,7 +154,7 @@ impl McpConnectionManager {
);
let tool_plugin_provenance = Arc::new(tool_plugin_provenance);
let startup_submit_id = submit_id.clone();
let codex_apps_auth_provider = auth
let chatgpt_auth_provider = auth
.filter(|auth| auth.uses_codex_backend())
.map(codex_model_provider::auth_provider_from_auth);
let mcp_servers = mcp_servers.clone();
@@ -173,17 +173,13 @@ impl McpConnectionManager {
},
)
.await;
let (codex_apps_tools_cache_context, runtime_auth_provider) =
if server_name == CODEX_APPS_MCP_SERVER_NAME {
codex_apps_cache_context_and_auth_provider(
&server,
&codex_home,
&codex_apps_tools_cache_key,
codex_apps_auth_provider.clone(),
)
} else {
regular_mcp_cache_context_and_auth_provider()
};
let codex_apps_tools_cache_context = if server_name == CODEX_APPS_MCP_SERVER_NAME {
codex_apps_tools_cache_context(&codex_home, &codex_apps_tools_cache_key)
} else {
regular_mcp_tools_cache_context()
};
let runtime_auth_provider =
chatgpt_auth_provider_for_server(&server, chatgpt_auth_provider.clone());
let async_managed_client = AsyncManagedClient::new(
server_name.clone(),
server,
@@ -852,48 +848,35 @@ impl Drop for McpConnectionManager {
}
}
/// Creates the host-owned state used only by the Codex Apps server.
///
/// The tools cache is scoped to the authenticated user. Runtime authentication is supplied only
/// when the server is not already configured to read a bearer token from the environment.
fn codex_apps_cache_context_and_auth_provider(
server: &EffectiveMcpServer,
/// Creates the per-user tools cache context used only by the Codex Apps server.
fn codex_apps_tools_cache_context(
codex_home: &Path,
codex_apps_tools_cache_key: &CodexAppsToolsCacheKey,
codex_apps_auth_provider: Option<SharedAuthProvider>,
) -> (
Option<CodexAppsToolsCacheContext>,
Option<SharedAuthProvider>,
) {
let uses_env_bearer_token =
server
.configured_config()
.is_some_and(|config| match &config.transport {
McpServerTransportConfig::StreamableHttp {
bearer_token_env_var,
..
} => bearer_token_env_var.is_some(),
McpServerTransportConfig::Stdio { .. } => false,
});
(
Some(CodexAppsToolsCacheContext {
codex_home: codex_home.to_path_buf(),
user_key: codex_apps_tools_cache_key.clone(),
}),
if uses_env_bearer_token {
None
} else {
codex_apps_auth_provider
},
)
) -> Option<CodexAppsToolsCacheContext> {
Some(CodexAppsToolsCacheContext {
codex_home: codex_home.to_path_buf(),
user_key: codex_apps_tools_cache_key.clone(),
})
}
/// Keeps regular MCP servers isolated from the host-owned Codex Apps cache and auth provider.
fn regular_mcp_cache_context_and_auth_provider() -> (
Option<CodexAppsToolsCacheContext>,
Option<SharedAuthProvider>,
) {
(None, None)
/// Keeps regular MCP servers isolated from the Codex Apps tools cache.
fn regular_mcp_tools_cache_context() -> Option<CodexAppsToolsCacheContext> {
None
}
/// Makes ChatGPT authentication available to servers that explicitly opt in.
/// The HTTP transport applies it only when no configured authorization resolves.
fn chatgpt_auth_provider_for_server(
server: &EffectiveMcpServer,
chatgpt_auth_provider: Option<SharedAuthProvider>,
) -> Option<SharedAuthProvider> {
if !server
.configured_config()
.is_some_and(|config| config.use_chatgpt_auth)
{
return None;
}
chatgpt_auth_provider
}
async fn emit_update(
@@ -1272,6 +1272,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
(
"stdio".to_string(),
EffectiveMcpServer::configured(McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "echo".to_string(),
args: Vec::new(),
@@ -1298,6 +1299,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
(
"http".to_string(),
EffectiveMcpServer::configured(McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "http://127.0.0.1:1".to_string(),
bearer_token_env_var: None,
@@ -1405,6 +1407,7 @@ fn mcp_init_error_display_prompts_for_github_pat() {
let server_name = "github";
let entry = McpAuthStatusEntry {
config: Some(McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "https://api.githubcopilot.com/mcp/".to_string(),
bearer_token_env_var: None,
@@ -1458,6 +1461,7 @@ fn mcp_init_error_display_reports_generic_errors() {
let server_name = "custom";
let entry = McpAuthStatusEntry {
config: Some(McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com".to_string(),
bearer_token_env_var: Some("TOKEN".to_string()),
+3 -3
View File
@@ -16,8 +16,6 @@ use tracing::warn;
use crate::server::EffectiveMcpServer;
use super::CODEX_APPS_MCP_SERVER_NAME;
#[derive(Debug, Clone)]
pub struct McpOAuthLoginConfig {
pub url: String,
@@ -141,7 +139,9 @@ where
let futures = servers.into_iter().map(|(name, server)| {
let name = name.clone();
let config = server.configured_config().cloned();
let has_runtime_auth = name == CODEX_APPS_MCP_SERVER_NAME
let has_runtime_auth = config
.as_ref()
.is_some_and(|config| config.use_chatgpt_auth)
&& auth.is_some_and(CodexAuth::uses_codex_backend)
&& config.as_ref().is_some_and(|config| {
matches!(
+30 -3
View File
@@ -257,9 +257,26 @@ pub fn effective_mcp_servers_from_configured(
config: &McpConfig,
auth: Option<&CodexAuth>,
) -> HashMap<String, EffectiveMcpServer> {
let chatgpt_origin = url::Url::parse(&config.chatgpt_base_url)
.ok()
.filter(|url| matches!(url.scheme(), "http" | "https"))
.map(|url| url.origin());
let mut servers = configured_servers
.into_iter()
.map(|(name, server)| (name, EffectiveMcpServer::configured(server)))
.map(|(name, mut server)| {
if server.use_chatgpt_auth {
let server_origin = match &server.transport {
McpServerTransportConfig::StreamableHttp { url, .. } => url::Url::parse(url)
.ok()
.filter(|url| matches!(url.scheme(), "http" | "https"))
.map(|url| url.origin()),
McpServerTransportConfig::Stdio { .. } => None,
};
server.use_chatgpt_auth =
server_origin.is_some() && server_origin.as_ref() == chatgpt_origin.as_ref();
}
(name, EffectiveMcpServer::configured(server))
})
.collect::<HashMap<_, _>>();
if !host_owned_codex_apps_enabled(config, auth) {
servers.remove(CODEX_APPS_MCP_SERVER_NAME);
@@ -457,6 +474,7 @@ pub fn codex_apps_mcp_server_config(
mcp_server_config_for_url(
codex_apps_mcp_url_for_base_url(chatgpt_base_url),
apps_mcp_product_sku,
/*use_chatgpt_auth*/ true,
)
}
@@ -471,10 +489,18 @@ pub fn hosted_plugin_runtime_mcp_server_config(
} else {
format!("{base_url}/api/codex")
};
mcp_server_config_for_url(format!("{base_url}/ps/mcp"), apps_mcp_product_sku)
mcp_server_config_for_url(
format!("{base_url}/ps/mcp"),
apps_mcp_product_sku,
/*use_chatgpt_auth*/ true,
)
}
fn mcp_server_config_for_url(url: String, apps_mcp_product_sku: Option<&str>) -> McpServerConfig {
fn mcp_server_config_for_url(
url: String,
apps_mcp_product_sku: Option<&str>,
use_chatgpt_auth: bool,
) -> McpServerConfig {
let http_headers = apps_mcp_product_sku.map(|product_sku| {
HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.to_string())])
});
@@ -486,6 +512,7 @@ fn mcp_server_config_for_url(url: String, apps_mcp_product_sku: Option<&str>) ->
http_headers,
env_http_headers: None,
},
use_chatgpt_auth,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
+2
View File
@@ -296,6 +296,7 @@ async fn effective_mcp_servers_preserve_runtime_servers() {
catalog.register(McpServerRegistration::from_config(
"sample".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "https://user.example/mcp".to_string(),
bearer_token_env_var: None,
@@ -321,6 +322,7 @@ async fn effective_mcp_servers_preserve_runtime_servers() {
catalog.register(McpServerRegistration::from_config(
"docs".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "https://docs.example/mcp".to_string(),
bearer_token_env_var: None,
@@ -32,6 +32,7 @@ fn stdio_server(
env_vars: Vec<McpServerEnvVar>,
) -> McpServerConfig {
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: command.to_string(),
args: Vec::new(),
@@ -66,6 +67,7 @@ fn declared_placement_preserves_local_plugin_normalization() {
Vec::new(),
);
let expected_http = McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com/mcp".to_string(),
bearer_token_env_var: None,
+2
View File
@@ -104,6 +104,7 @@ mod tests {
fn stdio_server(environment_id: &str) -> McpServerConfig {
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "echo".to_string(),
args: Vec::new(),
@@ -130,6 +131,7 @@ mod tests {
fn http_server(environment_id: &str) -> McpServerConfig {
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "http://127.0.0.1:1".to_string(),
bearer_token_env_var: None,
+3
View File
@@ -172,6 +172,9 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
}
}
if config.use_chatgpt_auth {
entry["use_chatgpt_auth"] = value(true);
}
if !config.enabled {
entry["enabled"] = value(false);
}
+2
View File
@@ -16,6 +16,7 @@ async fn replace_mcp_servers_serializes_per_tool_approval_overrides() -> anyhow:
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
@@ -95,6 +96,7 @@ async fn replace_mcp_servers_serializes_oauth_client_id() -> anyhow::Result<()>
let servers = BTreeMap::from([(
"maas_outlook".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com/mcp".to_string(),
bearer_token_env_var: None,
+11
View File
@@ -131,6 +131,12 @@ pub struct McpServerConfig {
#[serde(flatten)]
pub transport: McpServerTransportConfig,
/// When `true`, request authentication with the current ChatGPT session. Codex honors this
/// only when the server URL has the same origin as the configured ChatGPT base URL and no
/// configured bearer token or authorization header resolves.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub use_chatgpt_auth: bool,
/// Effective environment id for where Codex should start this MCP server.
pub environment_id: String,
@@ -239,6 +245,8 @@ pub struct RawMcpServerConfig {
#[serde(default)]
pub environment_id: Option<String>,
#[serde(default)]
pub use_chatgpt_auth: Option<bool>,
#[serde(default)]
pub startup_timeout_sec: Option<f64>,
#[serde(default)]
pub startup_timeout_ms: Option<u64>,
@@ -286,6 +294,7 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
bearer_token,
bearer_token_env_var,
environment_id,
use_chatgpt_auth,
startup_timeout_sec,
startup_timeout_ms,
tool_timeout_sec,
@@ -329,6 +338,7 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
throw_if_set("stdio", "env_http_headers", env_http_headers.as_ref())?;
throw_if_set("stdio", "oauth", oauth.as_ref())?;
throw_if_set("stdio", "oauth_resource", oauth_resource.as_ref())?;
throw_if_set("stdio", "use_chatgpt_auth", use_chatgpt_auth.as_ref())?;
let env_vars = env_vars.unwrap_or_default();
for env_var in &env_vars {
env_var.validate_source()?;
@@ -361,6 +371,7 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
Ok(Self {
transport,
use_chatgpt_auth: use_chatgpt_auth.unwrap_or_default(),
environment_id,
startup_timeout_sec,
tool_timeout_sec,
+1
View File
@@ -426,6 +426,7 @@ fn deserialize_ignores_unknown_server_fields() {
assert_eq!(
cfg,
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "echo".to_string(),
args: vec![],
@@ -677,6 +677,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
mcp_servers: HashMap::from([(
"sample".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "https://sample.example/mcp".to_string(),
bearer_token_env_var: None,
@@ -768,6 +769,7 @@ enabled = true
HashMap::from([(
"counter".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "https://sample.example/counter/mcp".to_string(),
bearer_token_env_var: None,
@@ -1662,6 +1664,7 @@ async fn load_plugins_uses_manifest_configured_component_paths() {
HashMap::from([(
"custom".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "https://custom.example/mcp".to_string(),
bearer_token_env_var: None,
@@ -1842,6 +1845,7 @@ async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
HashMap::from([(
"default".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "https://default.example/mcp".to_string(),
bearer_token_env_var: None,
@@ -2092,6 +2096,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
let connector = |id: &str| AppConnectorId(id.to_string());
let app = |name: &str, connector_id: &str| app_declaration(name, connector_id);
let http_server = |url: &str| McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: url.to_string(),
bearer_token_env_var: None,
+4
View File
@@ -2555,6 +2555,10 @@
},
"url": {
"type": "string"
},
"use_chatgpt_auth": {
"default": null,
"type": "boolean"
}
},
"type": "object"
+17
View File
@@ -114,6 +114,7 @@ use tempfile::TempDir;
fn stdio_mcp(command: &str) -> McpServerConfig {
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: command.to_string(),
args: Vec::new(),
@@ -140,6 +141,7 @@ fn stdio_mcp(command: &str) -> McpServerConfig {
fn http_mcp(url: &str) -> McpServerConfig {
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: url.to_string(),
bearer_token_env_var: None,
@@ -5566,6 +5568,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> {
servers.insert(
"docs".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "echo".to_string(),
args: vec!["hello".to_string()],
@@ -5923,6 +5926,7 @@ async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> {
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: vec!["--verbose".to_string()],
@@ -6002,6 +6006,7 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> {
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
@@ -6057,6 +6062,7 @@ async fn replace_mcp_servers_serializes_sourced_env_vars() -> anyhow::Result<()>
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
@@ -6115,6 +6121,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> {
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
@@ -6170,6 +6177,7 @@ async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com/mcp".to_string(),
bearer_token_env_var: Some("MCP_TOKEN".to_string()),
@@ -6237,6 +6245,7 @@ async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyh
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com/mcp".to_string(),
bearer_token_env_var: Some("MCP_TOKEN".to_string()),
@@ -6319,6 +6328,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh
let mut servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com/mcp".to_string(),
bearer_token_env_var: Some("MCP_TOKEN".to_string()),
@@ -6357,6 +6367,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh
servers.insert(
"docs".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com/mcp".to_string(),
bearer_token_env_var: None,
@@ -6424,6 +6435,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers()
(
"docs".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com/mcp".to_string(),
bearer_token_env_var: Some("MCP_TOKEN".to_string()),
@@ -6452,6 +6464,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers()
(
"logs".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "logs-server".to_string(),
args: vec!["--follow".to_string()],
@@ -6540,6 +6553,7 @@ async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> {
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
@@ -6590,6 +6604,7 @@ async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> {
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
@@ -6640,6 +6655,7 @@ async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> {
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
@@ -6695,6 +6711,7 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com/mcp".to_string(),
bearer_token_env_var: None,
@@ -95,6 +95,9 @@ fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable {
}
}
if config.use_chatgpt_auth {
entry["use_chatgpt_auth"] = value(true);
}
if !config.enabled {
entry["enabled"] = value(false);
}
+7
View File
@@ -873,6 +873,7 @@ fn blocking_replace_mcp_servers_round_trips() {
servers.insert(
"stdio".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "cmd".to_string(),
args: vec!["--flag".to_string()],
@@ -907,6 +908,7 @@ fn blocking_replace_mcp_servers_round_trips() {
servers.insert(
"http".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com".to_string(),
bearer_token_env_var: Some("TOKEN".to_string()),
@@ -981,6 +983,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
servers.insert(
"docs".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
@@ -1041,6 +1044,7 @@ foo = { command = "cmd" }
servers.insert(
"foo".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "cmd".to_string(),
args: Vec::new(),
@@ -1091,6 +1095,7 @@ foo = { command = "cmd" } # keep me
servers.insert(
"foo".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "cmd".to_string(),
args: Vec::new(),
@@ -1140,6 +1145,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
servers.insert(
"foo".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "cmd".to_string(),
args: Vec::new(),
@@ -1190,6 +1196,7 @@ foo = { command = "cmd" }
servers.insert(
"foo".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "cmd".to_string(),
args: Vec::new(),
@@ -357,6 +357,7 @@ fn mcp_dependency_to_server_config(
.as_ref()
.ok_or_else(|| "missing url for streamable_http dependency".to_string())?;
return Ok(McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::StreamableHttp {
url: url.clone(),
bearer_token_env_var: None,
@@ -386,6 +387,7 @@ fn mcp_dependency_to_server_config(
.as_ref()
.ok_or_else(|| "missing command for stdio dependency".to_string())?;
return Ok(McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: command.clone(),
args: Vec::new(),
+1
View File
@@ -395,6 +395,7 @@ async fn run_code_mode_turn_with_rmcp_config(
servers.insert(
"rmcp".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
+1
View File
@@ -184,6 +184,7 @@ fn insert_rmcp_test_server(config: &mut Config, command: String, approval_mode:
servers.insert(
RMCP_SERVER.to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command,
args: Vec::new(),
@@ -30,6 +30,7 @@ async fn refresh_shuts_down_superseded_mcp_stdio_server() -> anyhow::Result<()>
servers.insert(
"refresh_cleanup".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command,
args: Vec::new(),
+154
View File
@@ -47,6 +47,7 @@ use codex_protocol::protocol::Op;
use codex_protocol::user_input::UserInput;
use codex_utils_cargo_bin::cargo_bin;
use codex_utils_path_uri::PathUri;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::assert_regex_match;
use core_test_support::is_remote_test_environment;
use core_test_support::responses;
@@ -267,6 +268,7 @@ fn copy_binary_to_remote_env(
struct TestMcpServerOptions {
environment_id: String,
use_chatgpt_auth: bool,
supports_parallel_tool_calls: bool,
tool_timeout_sec: Option<Duration>,
}
@@ -275,6 +277,7 @@ impl Default for TestMcpServerOptions {
fn default() -> Self {
Self {
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
use_chatgpt_auth: false,
supports_parallel_tool_calls: false,
tool_timeout_sec: None,
}
@@ -315,6 +318,7 @@ fn insert_mcp_server(
server_name.to_string(),
McpServerConfig {
transport,
use_chatgpt_auth: options.use_chatgpt_auth,
environment_id: options.environment_id,
enabled: true,
required: false,
@@ -1227,6 +1231,7 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
use_chatgpt_auth: false,
supports_parallel_tool_calls: true,
tool_timeout_sec: Some(Duration::from_secs(2)),
},
@@ -2298,6 +2303,155 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn streamable_http_chatgpt_auth_respects_configured_authorization() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let Some(chatgpt_auth_server) =
start_streamable_http_test_server("chatgpt-auth", Some("Access Token")).await?
else {
return Ok(());
};
let chatgpt_auth_url = chatgpt_auth_server.url().to_string();
let chatgpt_base_url = chatgpt_auth_url
.strip_suffix("/mcp")
.expect("test MCP URL should end in /mcp")
.to_string();
let chatgpt_auth_fixture = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
config.chatgpt_base_url = chatgpt_base_url;
insert_mcp_server(
config,
"chatgpt_auth",
McpServerTransportConfig::StreamableHttp {
url: chatgpt_auth_url,
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
use_chatgpt_auth: true,
..Default::default()
},
);
})
.build_with_remote_env(&server)
.await?;
wait_for_mcp_server(&chatgpt_auth_fixture.codex, "chatgpt_auth").await?;
drop(chatgpt_auth_fixture);
chatgpt_auth_server.shutdown().await;
let Some(configured_auth_server) =
start_streamable_http_test_server("configured-auth", Some("configured-token")).await?
else {
return Ok(());
};
let configured_auth_url = configured_auth_server.url().to_string();
let configured_auth_base_url = configured_auth_url
.strip_suffix("/mcp")
.expect("test MCP URL should end in /mcp")
.to_string();
let configured_auth_fixture = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
config.chatgpt_base_url = configured_auth_base_url;
insert_mcp_server(
config,
"configured_auth",
McpServerTransportConfig::StreamableHttp {
url: configured_auth_url,
bearer_token_env_var: None,
http_headers: Some(HashMap::from([(
"Authorization".to_string(),
"Bearer configured-token".to_string(),
)])),
env_http_headers: None,
},
TestMcpServerOptions {
environment_id: remote_aware_environment_id(),
use_chatgpt_auth: true,
..Default::default()
},
);
})
.build_with_remote_env(&server)
.await?;
wait_for_mcp_server(&configured_auth_fixture.codex, "configured_auth").await?;
drop(configured_auth_fixture);
configured_auth_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn streamable_http_chatgpt_auth_is_not_sent_to_another_origin() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let untrusted_server = MockServer::start().await;
let untrusted_apps = AppsTestServer::mount(&untrusted_server).await?;
let untrusted_mcp_url = format!("{}/api/codex/apps", untrusted_apps.chatgpt_base_url);
let trusted_chatgpt_base_url = server.uri();
let fixture = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
config.chatgpt_base_url = trusted_chatgpt_base_url;
insert_mcp_server(
config,
"untrusted_origin",
McpServerTransportConfig::StreamableHttp {
url: untrusted_mcp_url,
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
TestMcpServerOptions {
use_chatgpt_auth: true,
..Default::default()
},
);
})
.build(&server)
.await?;
wait_for_mcp_server(&fixture.codex, "untrusted_origin").await?;
let observed_requests = untrusted_server
.received_requests()
.await
.expect("mock server should capture MCP startup requests")
.into_iter()
.filter(|request| request.url.path() == "/api/codex/apps")
.filter_map(|request| {
let body: Value = serde_json::from_slice(&request.body).ok()?;
let method = body.get("method")?.as_str()?.to_string();
let authorization = request
.headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.map(str::to_string);
Some((method, authorization))
})
.collect::<Vec<_>>();
assert_eq!(
observed_requests,
vec![
("initialize".to_string(), None),
("notifications/initialized".to_string(), None),
("tools/list".to_string(), None),
],
);
Ok(())
}
/// This test writes to a fallback credentials file in CODEX_HOME.
/// Ideally, we wouldn't need to serialize the test but it's much more cumbersome to wire CODEX_HOME through the code.
#[test]
+3
View File
@@ -1090,6 +1090,7 @@ async fn tool_search_indexes_only_enabled_non_app_mcp_tools() -> Result<()> {
servers.insert(
"rmcp".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
@@ -1216,6 +1217,7 @@ async fn tool_search_surfaced_mcp_tool_errors_are_returned_to_model() -> Result<
servers.insert(
"rmcp".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
@@ -1364,6 +1366,7 @@ async fn tool_search_uses_non_app_mcp_server_instructions_as_namespace_descripti
servers.insert(
"rmcp".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
@@ -657,6 +657,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result<
servers.insert(
server_name.to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
@@ -276,6 +276,7 @@ async fn token_budget_context_injects_plain_thread_hint_text() -> Result<()> {
servers.insert(
"notes".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
+3
View File
@@ -383,6 +383,7 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()>
servers.insert(
server_name.to_string(),
codex_config::types::McpServerConfig {
use_chatgpt_auth: false,
transport: codex_config::types::McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
@@ -481,6 +482,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> {
servers.insert(
server_name.to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
@@ -773,6 +775,7 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> {
servers.insert(
server_name.to_string(),
codex_config::types::McpServerConfig {
use_chatgpt_auth: false,
transport: codex_config::types::McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
@@ -166,6 +166,7 @@ async fn reads_declared_config_only_through_executor_file_system() {
vec![(
"demo".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "demo-mcp".to_string(),
args: Vec::new(),
@@ -221,6 +222,7 @@ async fn reads_manifest_object_config_without_executor_file_system_access() {
vec![(
"counter".to_string(),
McpServerConfig {
use_chatgpt_auth: false,
transport: McpServerTransportConfig::Stdio {
command: "counter-mcp".to_string(),
args: Vec::new(),
+7 -1
View File
@@ -787,6 +787,12 @@ impl RmcpClient {
} => {
let default_headers =
build_default_headers(http_headers.clone(), env_http_headers.clone())?;
let auth_provider =
if bearer_token.is_some() || default_headers.contains_key(AUTHORIZATION) {
None
} else {
auth_provider.clone()
};
let initial_oauth_tokens = if bearer_token.is_none()
&& auth_provider.is_none()
@@ -861,7 +867,7 @@ impl RmcpClient {
StreamableHttpClientAdapter::new(
Arc::clone(http_client),
default_headers,
auth_provider.clone(),
auth_provider,
),
http_config,
);