diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 7baee7d34..e874ca6ab 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -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; diff --git a/codex-rs/codex-mcp/src/catalog_tests.rs b/codex-rs/codex-mcp/src/catalog_tests.rs index 696876cc4..09a061441 100644 --- a/codex-rs/codex-mcp/src/catalog_tests.rs +++ b/codex-rs/codex-mcp/src/catalog_tests.rs @@ -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, diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 33631393c..074717347 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -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, -) -> ( - Option, - Option, -) { - 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 { + 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, - Option, -) { - (None, None) +/// Keeps regular MCP servers isolated from the Codex Apps tools cache. +fn regular_mcp_tools_cache_context() -> Option { + 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, +) -> Option { + if !server + .configured_config() + .is_some_and(|config| config.use_chatgpt_auth) + { + return None; + } + chatgpt_auth_provider } async fn emit_update( diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index f4dfc1fd6..46fa7fae6 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -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()), diff --git a/codex-rs/codex-mcp/src/mcp/auth.rs b/codex-rs/codex-mcp/src/mcp/auth.rs index 78d55fb78..182c6b6c3 100644 --- a/codex-rs/codex-mcp/src/mcp/auth.rs +++ b/codex-rs/codex-mcp/src/mcp/auth.rs @@ -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!( diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 93ccf1a9e..d2a6a9f2d 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -257,9 +257,26 @@ pub fn effective_mcp_servers_from_configured( config: &McpConfig, auth: Option<&CodexAuth>, ) -> HashMap { + 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::>(); 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, diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index 3a159e111..389bf1adc 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -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, diff --git a/codex-rs/codex-mcp/src/plugin_config_tests.rs b/codex-rs/codex-mcp/src/plugin_config_tests.rs index d95cd5095..fc495d7ee 100644 --- a/codex-rs/codex-mcp/src/plugin_config_tests.rs +++ b/codex-rs/codex-mcp/src/plugin_config_tests.rs @@ -32,6 +32,7 @@ fn stdio_server( env_vars: Vec, ) -> 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, diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index ce8d4156a..be18660e2 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -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, diff --git a/codex-rs/config/src/mcp_edit.rs b/codex-rs/config/src/mcp_edit.rs index 1d896e5f3..bc5d4d331 100644 --- a/codex-rs/config/src/mcp_edit.rs +++ b/codex-rs/config/src/mcp_edit.rs @@ -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); } diff --git a/codex-rs/config/src/mcp_edit_tests.rs b/codex-rs/config/src/mcp_edit_tests.rs index 030b0a023..3c2801b26 100644 --- a/codex-rs/config/src/mcp_edit_tests.rs +++ b/codex-rs/config/src/mcp_edit_tests.rs @@ -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, diff --git a/codex-rs/config/src/mcp_types.rs b/codex-rs/config/src/mcp_types.rs index 42142212c..08bf2fef2 100644 --- a/codex-rs/config/src/mcp_types.rs +++ b/codex-rs/config/src/mcp_types.rs @@ -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, #[serde(default)] + pub use_chatgpt_auth: Option, + #[serde(default)] pub startup_timeout_sec: Option, #[serde(default)] pub startup_timeout_ms: Option, @@ -286,6 +294,7 @@ impl TryFrom 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 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 for McpServerConfig { Ok(Self { transport, + use_chatgpt_auth: use_chatgpt_auth.unwrap_or_default(), environment_id, startup_timeout_sec, tool_timeout_sec, diff --git a/codex-rs/config/src/mcp_types_tests.rs b/codex-rs/config/src/mcp_types_tests.rs index d3ec9d77e..cab51f14c 100644 --- a/codex-rs/config/src/mcp_types_tests.rs +++ b/codex-rs/config/src/mcp_types_tests.rs @@ -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![], diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 72d357b90..e4441e15d 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -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, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 25ae84cfb..f8cfa6f72 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2555,6 +2555,10 @@ }, "url": { "type": "string" + }, + "use_chatgpt_auth": { + "default": null, + "type": "boolean" } }, "type": "object" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 4d3a7bcc6..e0c2f1663 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -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, diff --git a/codex-rs/core/src/config/edit/document_helpers.rs b/codex-rs/core/src/config/edit/document_helpers.rs index 3aed9a49b..5f9445ad1 100644 --- a/codex-rs/core/src/config/edit/document_helpers.rs +++ b/codex-rs/core/src/config/edit/document_helpers.rs @@ -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); } diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index dce192831..9c6cfe50c 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -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(), diff --git a/codex-rs/core/src/mcp_skill_dependencies.rs b/codex-rs/core/src/mcp_skill_dependencies.rs index 32e95b80e..0574fd14f 100644 --- a/codex-rs/core/src/mcp_skill_dependencies.rs +++ b/codex-rs/core/src/mcp_skill_dependencies.rs @@ -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(), diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index d7d0b9be7..4db905e6b 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -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(), diff --git a/codex-rs/core/tests/suite/hooks_mcp.rs b/codex-rs/core/tests/suite/hooks_mcp.rs index db35577b0..b3d13035c 100644 --- a/codex-rs/core/tests/suite/hooks_mcp.rs +++ b/codex-rs/core/tests/suite/hooks_mcp.rs @@ -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(), diff --git a/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs b/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs index a8e06bb35..8c523cb47 100644 --- a/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs +++ b/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs @@ -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(), diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 6fdf13065..ab53c553f 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -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, } @@ -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::>(); + + 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] diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index af6782792..e0361d477 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -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(), diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index ddfc8ef21..d9aefecf7 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -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(), diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index 18d800850..a55c18218 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -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(), diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index c7e296ec0..708005097 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -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(), diff --git a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs index a007acaac..cbc87b818 100644 --- a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs +++ b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs @@ -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(), diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 721d801e1..93abf7d11 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -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, );