diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index e874ca6ab..15f0b0bb4 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -345,7 +345,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re }; let new_entry = McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: transport.clone(), environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, diff --git a/codex-rs/codex-mcp/src/catalog_tests.rs b/codex-rs/codex-mcp/src/catalog_tests.rs index 09a061441..0b2ffef50 100644 --- a/codex-rs/codex-mcp/src/catalog_tests.rs +++ b/codex-rs/codex-mcp/src/catalog_tests.rs @@ -17,7 +17,7 @@ use super::ResolvedMcpCatalog; fn server(url: &str) -> McpServerConfig { McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), 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 074717347..55c72b046 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -43,6 +43,7 @@ use anyhow::anyhow; use async_channel::Sender; use codex_api::SharedAuthProvider; use codex_config::Constrained; +use codex_config::McpServerAuth; use codex_config::McpServerTransportConfig; use codex_config::types::AuthKeyringBackendKind; use codex_config::types::OAuthCredentialsStoreMode; @@ -872,7 +873,7 @@ fn chatgpt_auth_provider_for_server( ) -> Option { if !server .configured_config() - .is_some_and(|config| config.use_chatgpt_auth) + .is_some_and(|config| matches!(&config.auth, McpServerAuth::ChatGpt)) { return None; } diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 46fa7fae6..d66474c7b 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1272,7 +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, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "echo".to_string(), args: Vec::new(), @@ -1299,7 +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, + auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "http://127.0.0.1:1".to_string(), bearer_token_env_var: None, @@ -1407,7 +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, + auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://api.githubcopilot.com/mcp/".to_string(), bearer_token_env_var: None, @@ -1461,7 +1461,7 @@ fn mcp_init_error_display_reports_generic_errors() { let server_name = "custom"; let entry = McpAuthStatusEntry { config: Some(McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), 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 182c6b6c3..d19766af1 100644 --- a/codex-rs/codex-mcp/src/mcp/auth.rs +++ b/codex-rs/codex-mcp/src/mcp/auth.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use anyhow::Result; +use codex_config::McpServerAuth; use codex_config::McpServerConfig; use codex_config::McpServerTransportConfig; use codex_config::types::AuthKeyringBackendKind; @@ -141,7 +142,7 @@ where let config = server.configured_config().cloned(); let has_runtime_auth = config .as_ref() - .is_some_and(|config| config.use_chatgpt_auth) + .is_some_and(|config| matches!(&config.auth, McpServerAuth::ChatGpt)) && 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 d2a6a9f2d..f2c8f1d02 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -19,6 +19,7 @@ use std::time::Duration; use async_channel::unbounded; use codex_config::Constrained; +use codex_config::McpServerAuth; use codex_config::McpServerConfig; use codex_config::McpServerTransportConfig; use codex_config::types::AppToolApproval; @@ -26,6 +27,7 @@ use codex_config::types::AuthKeyringBackendKind; use codex_config::types::OAuthCredentialsStoreMode; use codex_connectors::ConnectorSnapshot; use codex_login::CodexAuth; +use codex_model_provider::CHATGPT_CODEX_BASE_URL; use codex_protocol::mcp::McpServerInfo; use codex_protocol::mcp::Resource; use codex_protocol::mcp::ResourceTemplate; @@ -257,23 +259,28 @@ pub fn effective_mcp_servers_from_configured( config: &McpConfig, auth: Option<&CodexAuth>, ) -> HashMap { - let chatgpt_origin = url::Url::parse(&config.chatgpt_base_url) + let chatgpt_origin = url::Url::parse(CHATGPT_CODEX_BASE_URL) .ok() - .filter(|url| matches!(url.scheme(), "http" | "https")) .map(|url| url.origin()); let mut servers = configured_servers .into_iter() .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(); + match server.auth.clone() { + McpServerAuth::ChatGpt => { + 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, + }; + if server_origin.as_ref() != chatgpt_origin.as_ref() { + server.auth = McpServerAuth::OAuth; + } + } + McpServerAuth::OAuth => {} } (name, EffectiveMcpServer::configured(server)) }) @@ -474,7 +481,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, + McpServerAuth::ChatGpt, ) } @@ -492,14 +499,14 @@ pub fn hosted_plugin_runtime_mcp_server_config( mcp_server_config_for_url( format!("{base_url}/ps/mcp"), apps_mcp_product_sku, - /*use_chatgpt_auth*/ true, + McpServerAuth::ChatGpt, ) } fn mcp_server_config_for_url( url: String, apps_mcp_product_sku: Option<&str>, - use_chatgpt_auth: bool, + auth_mode: McpServerAuth, ) -> McpServerConfig { let http_headers = apps_mcp_product_sku.map(|product_sku| { HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.to_string())]) @@ -512,7 +519,7 @@ fn mcp_server_config_for_url( http_headers, env_http_headers: None, }, - use_chatgpt_auth, + auth: auth_mode, 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 389bf1adc..7d166cbab 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -296,7 +296,7 @@ async fn effective_mcp_servers_preserve_runtime_servers() { catalog.register(McpServerRegistration::from_config( "sample".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://user.example/mcp".to_string(), bearer_token_env_var: None, @@ -322,7 +322,7 @@ async fn effective_mcp_servers_preserve_runtime_servers() { catalog.register(McpServerRegistration::from_config( "docs".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), 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 fc495d7ee..576bd2961 100644 --- a/codex-rs/codex-mcp/src/plugin_config_tests.rs +++ b/codex-rs/codex-mcp/src/plugin_config_tests.rs @@ -32,7 +32,7 @@ fn stdio_server( env_vars: Vec, ) -> McpServerConfig { McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: command.to_string(), args: Vec::new(), @@ -67,7 +67,7 @@ fn declared_placement_preserves_local_plugin_normalization() { Vec::new(), ); let expected_http = McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), 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 be18660e2..be9d50d3f 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -104,7 +104,7 @@ mod tests { fn stdio_server(environment_id: &str) -> McpServerConfig { McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "echo".to_string(), args: Vec::new(), @@ -131,7 +131,7 @@ mod tests { fn http_server(environment_id: &str) -> McpServerConfig { McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "http://127.0.0.1:1".to_string(), bearer_token_env_var: None, diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 1cbbbaf0a..afda2cf9f 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -115,6 +115,7 @@ pub use mcp_edit::ConfigEditsBuilder; pub use mcp_edit::load_global_mcp_servers; pub use mcp_types::AppToolApproval; pub use mcp_types::DEFAULT_MCP_SERVER_ENVIRONMENT_ID; +pub use mcp_types::McpServerAuth; pub use mcp_types::McpServerConfig; pub use mcp_types::McpServerDisabledReason; pub use mcp_types::McpServerEnvVar; diff --git a/codex-rs/config/src/mcp_edit.rs b/codex-rs/config/src/mcp_edit.rs index bc5d4d331..16b390491 100644 --- a/codex-rs/config/src/mcp_edit.rs +++ b/codex-rs/config/src/mcp_edit.rs @@ -13,6 +13,7 @@ use toml_edit::value; use crate::AppToolApproval; use crate::CONFIG_TOML_FILE; +use crate::McpServerAuth; use crate::McpServerConfig; use crate::McpServerEnvVar; use crate::McpServerTransportConfig; @@ -172,8 +173,8 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { } } - if config.use_chatgpt_auth { - entry["use_chatgpt_auth"] = value(true); + if matches!(&config.auth, McpServerAuth::ChatGpt) { + entry["auth"] = value("chatgpt"); } 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 3c2801b26..10ef23401 100644 --- a/codex-rs/config/src/mcp_edit_tests.rs +++ b/codex-rs/config/src/mcp_edit_tests.rs @@ -16,7 +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, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "docs-server".to_string(), args: Vec::new(), @@ -96,7 +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, + auth: Default::default(), 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 08bf2fef2..550de84a0 100644 --- a/codex-rs/config/src/mcp_types.rs +++ b/codex-rs/config/src/mcp_types.rs @@ -126,16 +126,40 @@ pub struct McpServerOAuthConfig { pub client_id: Option, } +/// Authentication flow Codex attempts after resolving an HTTP MCP server's +/// configured bearer token and authorization headers, which always take +/// precedence. ChatGPT authentication falls back to stored OAuth credentials +/// when its session provider is unavailable; both modes ultimately fall back +/// to an unauthenticated connection. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum McpServerAuth { + /// Use stored MCP OAuth credentials when available. Starting an OAuth login + /// is a separate operation. + #[default] + #[serde(rename = "oauth")] + OAuth, + /// Use the current ChatGPT session for servers on the trusted first-party + /// ChatGPT origin. If no ChatGPT session provider is available, startup can + /// still fall back to stored OAuth credentials. + #[serde(rename = "chatgpt")] + ChatGpt, +} + +impl McpServerAuth { + fn is_default(&self) -> bool { + self == &Self::default() + } +} + #[derive(Serialize, Debug, Clone, PartialEq)] 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, + /// Authentication flow to use when no configured authorization resolves. + #[serde(default, skip_serializing_if = "McpServerAuth::is_default")] + pub auth: McpServerAuth, /// Effective environment id for where Codex should start this MCP server. pub environment_id: String, @@ -245,7 +269,7 @@ pub struct RawMcpServerConfig { #[serde(default)] pub environment_id: Option, #[serde(default)] - pub use_chatgpt_auth: Option, + pub auth: Option, #[serde(default)] pub startup_timeout_sec: Option, #[serde(default)] @@ -294,7 +318,7 @@ impl TryFrom for McpServerConfig { bearer_token, bearer_token_env_var, environment_id, - use_chatgpt_auth, + auth, startup_timeout_sec, startup_timeout_ms, tool_timeout_sec, @@ -338,7 +362,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())?; + throw_if_set("stdio", "auth", auth.as_ref())?; let env_vars = env_vars.unwrap_or_default(); for env_var in &env_vars { env_var.validate_source()?; @@ -371,7 +395,7 @@ impl TryFrom for McpServerConfig { Ok(Self { transport, - use_chatgpt_auth: use_chatgpt_auth.unwrap_or_default(), + auth: 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 cab51f14c..598a53297 100644 --- a/codex-rs/config/src/mcp_types_tests.rs +++ b/codex-rs/config/src/mcp_types_tests.rs @@ -426,7 +426,7 @@ fn deserialize_ignores_unknown_server_fields() { assert_eq!( cfg, McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "echo".to_string(), args: vec![], diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index d25ab64c6..5303fccd4 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -4,6 +4,7 @@ // definitions that do not contain business logic. pub use crate::mcp_types::AppToolApproval; +pub use crate::mcp_types::McpServerAuth; pub use crate::mcp_types::McpServerConfig; pub use crate::mcp_types::McpServerDisabledReason; pub use crate::mcp_types::McpServerEnvVar; diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index e4441e15d..cefca287a 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -677,7 +677,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() { mcp_servers: HashMap::from([( "sample".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://sample.example/mcp".to_string(), bearer_token_env_var: None, @@ -769,7 +769,7 @@ enabled = true HashMap::from([( "counter".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://sample.example/counter/mcp".to_string(), bearer_token_env_var: None, @@ -1664,7 +1664,7 @@ async fn load_plugins_uses_manifest_configured_component_paths() { HashMap::from([( "custom".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://custom.example/mcp".to_string(), bearer_token_env_var: None, @@ -1845,7 +1845,7 @@ async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() { HashMap::from([( "default".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://default.example/mcp".to_string(), bearer_token_env_var: None, @@ -2096,7 +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, + auth: Default::default(), 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 f8cfa6f72..842f4d5cc 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1384,6 +1384,25 @@ }, "type": "object" }, + "McpServerAuth": { + "description": "Authentication flow Codex attempts after resolving an HTTP MCP server's configured bearer token and authorization headers, which always take precedence. ChatGPT authentication falls back to stored OAuth credentials when its session provider is unavailable; both modes ultimately fall back to an unauthenticated connection.", + "oneOf": [ + { + "description": "Use stored MCP OAuth credentials when available. Starting an OAuth login is a separate operation.", + "enum": [ + "oauth" + ], + "type": "string" + }, + { + "description": "Use the current ChatGPT session for servers on the trusted first-party ChatGPT origin. If no ChatGPT session provider is available, startup can still fall back to stored OAuth credentials.", + "enum": [ + "chatgpt" + ], + "type": "string" + } + ] + }, "McpServerEnvVar": { "anyOf": [ { @@ -2427,6 +2446,14 @@ }, "type": "array" }, + "auth": { + "allOf": [ + { + "$ref": "#/definitions/McpServerAuth" + } + ], + "default": null + }, "bearer_token_env_var": { "type": "string" }, @@ -2555,10 +2582,6 @@ }, "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 e0c2f1663..a178dfae0 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -114,7 +114,7 @@ use tempfile::TempDir; fn stdio_mcp(command: &str) -> McpServerConfig { McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: command.to_string(), args: Vec::new(), @@ -141,7 +141,7 @@ fn stdio_mcp(command: &str) -> McpServerConfig { fn http_mcp(url: &str) -> McpServerConfig { McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: url.to_string(), bearer_token_env_var: None, @@ -5568,7 +5568,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { servers.insert( "docs".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "echo".to_string(), args: vec!["hello".to_string()], @@ -5926,7 +5926,7 @@ async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> { let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "docs-server".to_string(), args: vec!["--verbose".to_string()], @@ -6006,7 +6006,7 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> { let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "docs-server".to_string(), args: Vec::new(), @@ -6062,7 +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, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "docs-server".to_string(), args: Vec::new(), @@ -6121,7 +6121,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> { let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "docs-server".to_string(), args: Vec::new(), @@ -6177,7 +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, + auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), bearer_token_env_var: Some("MCP_TOKEN".to_string()), @@ -6245,7 +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, + auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), bearer_token_env_var: Some("MCP_TOKEN".to_string()), @@ -6328,7 +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, + auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), bearer_token_env_var: Some("MCP_TOKEN".to_string()), @@ -6367,7 +6367,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh servers.insert( "docs".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), bearer_token_env_var: None, @@ -6435,7 +6435,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers() ( "docs".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), bearer_token_env_var: Some("MCP_TOKEN".to_string()), @@ -6464,7 +6464,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers() ( "logs".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "logs-server".to_string(), args: vec!["--follow".to_string()], @@ -6553,7 +6553,7 @@ async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> { let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "docs-server".to_string(), args: Vec::new(), @@ -6604,7 +6604,7 @@ async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> { let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "docs-server".to_string(), args: Vec::new(), @@ -6655,7 +6655,7 @@ async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> { let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "docs-server".to_string(), args: Vec::new(), @@ -6711,7 +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, + auth: Default::default(), 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 5f9445ad1..308eb9922 100644 --- a/codex-rs/core/src/config/edit/document_helpers.rs +++ b/codex-rs/core/src/config/edit/document_helpers.rs @@ -1,4 +1,5 @@ use codex_config::types::AppToolApproval; +use codex_config::types::McpServerAuth; use codex_config::types::McpServerConfig; use codex_config::types::McpServerEnvVar; use codex_config::types::McpServerToolConfig; @@ -95,8 +96,8 @@ fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable { } } - if config.use_chatgpt_auth { - entry["use_chatgpt_auth"] = value(true); + if matches!(&config.auth, McpServerAuth::ChatGpt) { + entry["auth"] = value("chatgpt"); } 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 9c6cfe50c..d47f128f9 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -873,7 +873,7 @@ fn blocking_replace_mcp_servers_round_trips() { servers.insert( "stdio".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "cmd".to_string(), args: vec!["--flag".to_string()], @@ -908,7 +908,7 @@ fn blocking_replace_mcp_servers_round_trips() { servers.insert( "http".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com".to_string(), bearer_token_env_var: Some("TOKEN".to_string()), @@ -983,7 +983,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() { servers.insert( "docs".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "docs-server".to_string(), args: Vec::new(), @@ -1044,7 +1044,7 @@ foo = { command = "cmd" } servers.insert( "foo".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "cmd".to_string(), args: Vec::new(), @@ -1095,7 +1095,7 @@ foo = { command = "cmd" } # keep me servers.insert( "foo".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "cmd".to_string(), args: Vec::new(), @@ -1145,7 +1145,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me servers.insert( "foo".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "cmd".to_string(), args: Vec::new(), @@ -1196,7 +1196,7 @@ foo = { command = "cmd" } servers.insert( "foo".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), 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 0574fd14f..9b2b85aa2 100644 --- a/codex-rs/core/src/mcp_skill_dependencies.rs +++ b/codex-rs/core/src/mcp_skill_dependencies.rs @@ -357,7 +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, + auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: url.clone(), bearer_token_env_var: None, @@ -387,7 +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, + auth: Default::default(), 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 4db905e6b..92b6c9d47 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -395,7 +395,7 @@ async fn run_code_mode_turn_with_rmcp_config( servers.insert( "rmcp".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), 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 b3d13035c..44c91a0d4 100644 --- a/codex-rs/core/tests/suite/hooks_mcp.rs +++ b/codex-rs/core/tests/suite/hooks_mcp.rs @@ -184,7 +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, + auth: Default::default(), 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 8c523cb47..7df18727b 100644 --- a/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs +++ b/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs @@ -30,7 +30,7 @@ async fn refresh_shuts_down_superseded_mcp_stdio_server() -> anyhow::Result<()> servers.insert( "refresh_cleanup".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), 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 ab53c553f..a4d56f8cc 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -17,6 +17,7 @@ use std::time::Duration; use std::time::SystemTime; use std::time::UNIX_EPOCH; +use codex_config::types::McpServerAuth; use codex_config::types::McpServerConfig; use codex_config::types::McpServerEnvVar; use codex_config::types::McpServerTransportConfig; @@ -268,7 +269,7 @@ fn copy_binary_to_remote_env( struct TestMcpServerOptions { environment_id: String, - use_chatgpt_auth: bool, + auth: McpServerAuth, supports_parallel_tool_calls: bool, tool_timeout_sec: Option, } @@ -277,7 +278,7 @@ impl Default for TestMcpServerOptions { fn default() -> Self { Self { environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), - use_chatgpt_auth: false, + auth: McpServerAuth::default(), supports_parallel_tool_calls: false, tool_timeout_sec: None, } @@ -318,7 +319,7 @@ fn insert_mcp_server( server_name.to_string(), McpServerConfig { transport, - use_chatgpt_auth: options.use_chatgpt_auth, + auth: options.auth, environment_id: options.environment_id, enabled: true, required: false, @@ -1231,7 +1232,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, + auth: Default::default(), supports_parallel_tool_calls: true, tool_timeout_sec: Some(Duration::from_secs(2)), }, @@ -2304,62 +2305,20 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn streamable_http_chatgpt_auth_respects_configured_authorization() -> anyhow::Result<()> { +async fn streamable_http_configured_auth_precedes_chatgpt_auth() -> 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", @@ -2374,12 +2333,12 @@ async fn streamable_http_chatgpt_auth_respects_configured_authorization() -> any }, TestMcpServerOptions { environment_id: remote_aware_environment_id(), - use_chatgpt_auth: true, + auth: McpServerAuth::ChatGpt, ..Default::default() }, ); }) - .build_with_remote_env(&server) + .build_with_auto_env(&server) .await?; wait_for_mcp_server(&configured_auth_fixture.codex, "configured_auth").await?; @@ -2390,19 +2349,19 @@ async fn streamable_http_chatgpt_auth_respects_configured_authorization() -> any } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn streamable_http_chatgpt_auth_is_not_sent_to_another_origin() -> anyhow::Result<()> { +async fn streamable_http_chatgpt_auth_is_not_sent_to_configured_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 untrusted_chatgpt_base_url = untrusted_apps.chatgpt_base_url; 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; + config.chatgpt_base_url = untrusted_chatgpt_base_url; insert_mcp_server( config, "untrusted_origin", @@ -2413,7 +2372,7 @@ async fn streamable_http_chatgpt_auth_is_not_sent_to_another_origin() -> anyhow: env_http_headers: None, }, TestMcpServerOptions { - use_chatgpt_auth: true, + auth: McpServerAuth::ChatGpt, ..Default::default() }, ); @@ -2452,6 +2411,67 @@ async fn streamable_http_chatgpt_auth_is_not_sent_to_another_origin() -> anyhow: Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn configured_chatgpt_base_url_does_not_grant_mcp_chatgpt_auth() -> 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 untrusted_chatgpt_base_url = untrusted_apps.chatgpt_base_url; + + let fixture = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_pre_build_hook(move |codex_home| { + fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{untrusted_chatgpt_base_url}" + +[mcp_servers.untrusted_origin] +url = "{untrusted_mcp_url}" +auth = "chatgpt" +"#, + ), + ) + .expect("write attacker-controlled MCP config"); + }) + .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 e0361d477..edfc53fdc 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -1090,7 +1090,7 @@ async fn tool_search_indexes_only_enabled_non_app_mcp_tools() -> Result<()> { servers.insert( "rmcp".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: rmcp_test_server_bin, args: Vec::new(), @@ -1217,7 +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, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: rmcp_test_server_bin, args: Vec::new(), @@ -1366,7 +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, + auth: Default::default(), 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 d9aefecf7..5eea5a09e 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -657,7 +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, + auth: Default::default(), 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 a55c18218..e536d6b88 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -276,7 +276,7 @@ async fn token_budget_context_injects_plain_thread_hint_text() -> Result<()> { servers.insert( "notes".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), 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 708005097..c4b574abf 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -383,7 +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, + auth: Default::default(), transport: codex_config::types::McpServerTransportConfig::Stdio { command: rmcp_test_server_bin, args: Vec::new(), @@ -482,7 +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, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: rmcp_test_server_bin, args: Vec::new(), @@ -775,7 +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, + auth: Default::default(), 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 cbc87b818..22e5d1bec 100644 --- a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs +++ b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs @@ -166,7 +166,7 @@ async fn reads_declared_config_only_through_executor_file_system() { vec![( "demo".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "demo-mcp".to_string(), args: Vec::new(), @@ -222,7 +222,7 @@ async fn reads_manifest_object_config_without_executor_file_system_access() { vec![( "counter".to_string(), McpServerConfig { - use_chatgpt_auth: false, + auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: "counter-mcp".to_string(), args: Vec::new(), diff --git a/codex-rs/model-provider/src/lib.rs b/codex-rs/model-provider/src/lib.rs index f5efe1f57..44ce878e2 100644 --- a/codex-rs/model-provider/src/lib.rs +++ b/codex-rs/model-provider/src/lib.rs @@ -8,6 +8,7 @@ pub use auth::auth_provider_from_auth; pub use auth::unauthenticated_auth_provider; pub use bearer_auth_provider::BearerAuthProvider; pub use bearer_auth_provider::BearerAuthProvider as CoreAuthProvider; +pub use codex_model_provider_info::CHATGPT_CODEX_BASE_URL; pub use codex_protocol::account::ProviderAccount; pub use provider::ModelProvider; pub use provider::ModelProviderFuture;