From d5562983d9cf934801a1164b87ca4d0702559b6b Mon Sep 17 00:00:00 2001 From: WhammyLeaf Date: Mon, 12 Jan 2026 17:57:04 +0100 Subject: [PATCH] Add static mcp callback uri support (#8971) Currently the callback URI for MCP authentication is dynamically generated. More specifically, the callback URI is dynamic because the port part of it is randomly chosen by the OS. This is not ideal as callback URIs are recommended to be static and many authorization servers do not support dynamic callback URIs. This PR fixes that issue by exposing a new config option named `mcp_oauth_callback_port`. When it is set, the callback URI is constructed using this port rather than a random one chosen by the OS, thereby making callback URI static. Related issue: https://github.com/openai/codex/issues/8827 --- .../app-server/src/codex_message_processor.rs | 1 + codex-rs/cli/src/mcp_cmd.rs | 2 + codex-rs/core/src/config/mod.rs | 42 +++++++++++++++++++ .../rmcp-client/src/perform_oauth_login.rs | 29 ++++++++++++- 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index da6cecc6b..391ee40cd 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -2358,6 +2358,7 @@ impl CodexMessageProcessor { env_http_headers, scopes.as_deref().unwrap_or_default(), timeout_secs, + config.mcp_oauth_callback_port, ) .await { diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index ef872e597..497ac8397 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -274,6 +274,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re http_headers.clone(), env_http_headers.clone(), &Vec::new(), + config.mcp_oauth_callback_port, ) .await?; println!("Successfully logged in."); @@ -352,6 +353,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) http_headers, env_http_headers, &scopes, + config.mcp_oauth_callback_port, ) .await?; println!("Successfully logged in to MCP server '{name}'."); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 7b483f944..7ce0c13d1 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -268,6 +268,11 @@ pub struct Config { /// auto (default): keyring if available, otherwise file. pub mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode, + /// Optional fixed port to use for the local HTTP callback server used during MCP OAuth login. + /// + /// When unset, Codex will bind to an ephemeral port chosen by the OS. + pub mcp_oauth_callback_port: Option, + /// Combined provider map (defaults merged with user-defined overrides). pub model_providers: HashMap, @@ -751,6 +756,10 @@ pub struct ConfigToml { #[serde(default)] pub mcp_oauth_credentials_store: Option, + /// Optional fixed port for the local HTTP callback server used during MCP OAuth login. + /// When unset, Codex will bind to an ephemeral port chosen by the OS. + pub mcp_oauth_callback_port: Option, + /// User-defined provider entries that extend/override the built-in list. #[serde(default)] pub model_providers: HashMap, @@ -1361,6 +1370,7 @@ impl Config { // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. mcp_oauth_credentials_store_mode: cfg.mcp_oauth_credentials_store.unwrap_or_default(), + mcp_oauth_callback_port: cfg.mcp_oauth_callback_port, model_providers, project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES), project_doc_fallback_filenames: cfg @@ -3245,6 +3255,7 @@ model_verbosity = "high" cli_auth_credentials_store_mode: Default::default(), mcp_servers: HashMap::new(), mcp_oauth_credentials_store_mode: Default::default(), + mcp_oauth_callback_port: None, model_providers: fixture.model_provider_map.clone(), project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), @@ -3331,6 +3342,7 @@ model_verbosity = "high" cli_auth_credentials_store_mode: Default::default(), mcp_servers: HashMap::new(), mcp_oauth_credentials_store_mode: Default::default(), + mcp_oauth_callback_port: None, model_providers: fixture.model_provider_map.clone(), project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), @@ -3432,6 +3444,7 @@ model_verbosity = "high" cli_auth_credentials_store_mode: Default::default(), mcp_servers: HashMap::new(), mcp_oauth_credentials_store_mode: Default::default(), + mcp_oauth_callback_port: None, model_providers: fixture.model_provider_map.clone(), project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), @@ -3519,6 +3532,7 @@ model_verbosity = "high" cli_auth_credentials_store_mode: Default::default(), mcp_servers: HashMap::new(), mcp_oauth_credentials_store_mode: Default::default(), + mcp_oauth_callback_port: None, model_providers: fixture.model_provider_map.clone(), project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), @@ -3832,6 +3846,34 @@ trust_level = "untrusted" assert_eq!(result, Some("explicit-provider".to_string())); } + #[test] + fn config_toml_deserializes_mcp_oauth_callback_port() { + let toml = r#"mcp_oauth_callback_port = 4321"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for callback port"); + assert_eq!(cfg.mcp_oauth_callback_port, Some(4321)); + } + + #[test] + fn config_loads_mcp_oauth_callback_port_from_toml() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let toml = r#" +model = "gpt-5.1" +mcp_oauth_callback_port = 5678 +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for callback port"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!(config.mcp_oauth_callback_port, Some(5678)); + Ok(()) + } + #[test] fn test_untrusted_project_gets_unless_trusted_approval_policy() -> anyhow::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index 9815a3a22..64cf979ec 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -6,6 +6,7 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; +use anyhow::bail; use reqwest::ClientBuilder; use rmcp::transport::auth::OAuthState; use tiny_http::Response; @@ -44,6 +45,7 @@ pub async fn perform_oauth_login( http_headers: Option>, env_http_headers: Option>, scopes: &[String], + callback_port: Option, ) -> Result<()> { let headers = OauthHeaders { http_headers, @@ -56,6 +58,7 @@ pub async fn perform_oauth_login( headers, scopes, true, + callback_port, None, ) .await? @@ -63,6 +66,7 @@ pub async fn perform_oauth_login( .await } +#[allow(clippy::too_many_arguments)] pub async fn perform_oauth_login_return_url( server_name: &str, server_url: &str, @@ -71,6 +75,7 @@ pub async fn perform_oauth_login_return_url( env_http_headers: Option>, scopes: &[String], timeout_secs: Option, + callback_port: Option, ) -> Result { let headers = OauthHeaders { http_headers, @@ -83,6 +88,7 @@ pub async fn perform_oauth_login_return_url( headers, scopes, false, + callback_port, timeout_secs, ) .await?; @@ -188,7 +194,21 @@ struct OauthLoginFlow { timeout: Duration, } +fn resolve_callback_port(callback_port: Option) -> Result> { + if let Some(config_port) = callback_port { + if config_port == 0 { + bail!( + "invalid MCP OAuth callback port `{config_port}`: port must be between 1 and 65535" + ); + } + return Ok(Some(config_port)); + } + + Ok(None) +} + impl OauthLoginFlow { + #[allow(clippy::too_many_arguments)] async fn new( server_name: &str, server_url: &str, @@ -196,11 +216,18 @@ impl OauthLoginFlow { headers: OauthHeaders, scopes: &[String], launch_browser: bool, + callback_port: Option, timeout_secs: Option, ) -> Result { const DEFAULT_OAUTH_TIMEOUT_SECS: i64 = 300; - let server = Arc::new(Server::http("127.0.0.1:0").map_err(|err| anyhow!(err))?); + let callback_port = resolve_callback_port(callback_port)?; + let bind_addr = match callback_port { + Some(port) => format!("127.0.0.1:{port}"), + None => "127.0.0.1:0".to_string(), + }; + + let server = Arc::new(Server::http(&bind_addr).map_err(|err| anyhow!(err))?); let guard = CallbackServerGuard { server: Arc::clone(&server), };