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), };