Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/app-server/src/codex_message_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2358,6 +2358,7 @@ impl CodexMessageProcessor {
env_http_headers,
scopes.as_deref().unwrap_or_default(),
timeout_secs,
config.mcp_oauth_callback_port,
)
.await
{
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/cli/src/mcp_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down Expand Up @@ -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}'.");
Expand Down
44 changes: 44 additions & 0 deletions codex-rs/core/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,12 @@ 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. The environment
/// variable `CODEX_MCP_OAUTH_CALLBACK_PORT` takes precedence over this value if set.
pub mcp_oauth_callback_port: Option<u16>,

/// Combined provider map (defaults merged with user-defined overrides).
pub model_providers: HashMap<String, ModelProviderInfo>,

Expand Down Expand Up @@ -738,6 +744,11 @@ pub struct ConfigToml {
#[serde(default)]
pub mcp_oauth_credentials_store: Option<OAuthCredentialsStoreMode>,

/// 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. The environment
/// variable `CODEX_MCP_OAUTH_CALLBACK_PORT` takes precedence over this value if set.
pub mcp_oauth_callback_port: Option<u16>,

/// User-defined provider entries that extend/override the built-in list.
#[serde(default)]
pub model_providers: HashMap<String, ModelProviderInfo>,
Expand Down Expand Up @@ -1344,6 +1355,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
Expand Down Expand Up @@ -3198,6 +3210,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(),
Expand Down Expand Up @@ -3282,6 +3295,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(),
Expand Down Expand Up @@ -3381,6 +3395,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(),
Expand Down Expand Up @@ -3466,6 +3481,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(),
Expand Down Expand Up @@ -3777,6 +3793,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()?;
Expand Down
29 changes: 28 additions & 1 deletion codex-rs/rmcp-client/src/perform_oauth_login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -44,6 +45,7 @@ pub async fn perform_oauth_login(
http_headers: Option<HashMap<String, String>>,
env_http_headers: Option<HashMap<String, String>>,
scopes: &[String],
callback_port: Option<u16>,
) -> Result<()> {
let headers = OauthHeaders {
http_headers,
Expand All @@ -56,13 +58,15 @@ pub async fn perform_oauth_login(
headers,
scopes,
true,
callback_port,
None,
)
.await?
.finish()
.await
}

#[allow(clippy::too_many_arguments)]
pub async fn perform_oauth_login_return_url(
server_name: &str,
server_url: &str,
Expand All @@ -71,6 +75,7 @@ pub async fn perform_oauth_login_return_url(
env_http_headers: Option<HashMap<String, String>>,
scopes: &[String],
timeout_secs: Option<i64>,
callback_port: Option<u16>,
) -> Result<OauthLoginHandle> {
let headers = OauthHeaders {
http_headers,
Expand All @@ -83,6 +88,7 @@ pub async fn perform_oauth_login_return_url(
headers,
scopes,
false,
callback_port,
timeout_secs,
)
.await?;
Expand Down Expand Up @@ -188,19 +194,40 @@ struct OauthLoginFlow {
timeout: Duration,
}

fn resolve_callback_port(callback_port: Option<u16>) -> Result<Option<u16>> {
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,
store_mode: OAuthCredentialsStoreMode,
headers: OauthHeaders,
scopes: &[String],
launch_browser: bool,
callback_port: Option<u16>,
timeout_secs: Option<i64>,
) -> Result<Self> {
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),
};
Expand Down