Skip to content

Commit d556298

Browse files
authored
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: #8827
1 parent 9659583 commit d556298

File tree

4 files changed

+73
-1
lines changed

4 files changed

+73
-1
lines changed

codex-rs/app-server/src/codex_message_processor.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2358,6 +2358,7 @@ impl CodexMessageProcessor {
23582358
env_http_headers,
23592359
scopes.as_deref().unwrap_or_default(),
23602360
timeout_secs,
2361+
config.mcp_oauth_callback_port,
23612362
)
23622363
.await
23632364
{

codex-rs/cli/src/mcp_cmd.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
274274
http_headers.clone(),
275275
env_http_headers.clone(),
276276
&Vec::new(),
277+
config.mcp_oauth_callback_port,
277278
)
278279
.await?;
279280
println!("Successfully logged in.");
@@ -352,6 +353,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
352353
http_headers,
353354
env_http_headers,
354355
&scopes,
356+
config.mcp_oauth_callback_port,
355357
)
356358
.await?;
357359
println!("Successfully logged in to MCP server '{name}'.");

codex-rs/core/src/config/mod.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,11 @@ pub struct Config {
268268
/// auto (default): keyring if available, otherwise file.
269269
pub mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode,
270270

271+
/// Optional fixed port to use for the local HTTP callback server used during MCP OAuth login.
272+
///
273+
/// When unset, Codex will bind to an ephemeral port chosen by the OS.
274+
pub mcp_oauth_callback_port: Option<u16>,
275+
271276
/// Combined provider map (defaults merged with user-defined overrides).
272277
pub model_providers: HashMap<String, ModelProviderInfo>,
273278

@@ -751,6 +756,10 @@ pub struct ConfigToml {
751756
#[serde(default)]
752757
pub mcp_oauth_credentials_store: Option<OAuthCredentialsStoreMode>,
753758

759+
/// Optional fixed port for the local HTTP callback server used during MCP OAuth login.
760+
/// When unset, Codex will bind to an ephemeral port chosen by the OS.
761+
pub mcp_oauth_callback_port: Option<u16>,
762+
754763
/// User-defined provider entries that extend/override the built-in list.
755764
#[serde(default)]
756765
pub model_providers: HashMap<String, ModelProviderInfo>,
@@ -1361,6 +1370,7 @@ impl Config {
13611370
// The config.toml omits "_mode" because it's a config file. However, "_mode"
13621371
// is important in code to differentiate the mode from the store implementation.
13631372
mcp_oauth_credentials_store_mode: cfg.mcp_oauth_credentials_store.unwrap_or_default(),
1373+
mcp_oauth_callback_port: cfg.mcp_oauth_callback_port,
13641374
model_providers,
13651375
project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES),
13661376
project_doc_fallback_filenames: cfg
@@ -3245,6 +3255,7 @@ model_verbosity = "high"
32453255
cli_auth_credentials_store_mode: Default::default(),
32463256
mcp_servers: HashMap::new(),
32473257
mcp_oauth_credentials_store_mode: Default::default(),
3258+
mcp_oauth_callback_port: None,
32483259
model_providers: fixture.model_provider_map.clone(),
32493260
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
32503261
project_doc_fallback_filenames: Vec::new(),
@@ -3331,6 +3342,7 @@ model_verbosity = "high"
33313342
cli_auth_credentials_store_mode: Default::default(),
33323343
mcp_servers: HashMap::new(),
33333344
mcp_oauth_credentials_store_mode: Default::default(),
3345+
mcp_oauth_callback_port: None,
33343346
model_providers: fixture.model_provider_map.clone(),
33353347
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
33363348
project_doc_fallback_filenames: Vec::new(),
@@ -3432,6 +3444,7 @@ model_verbosity = "high"
34323444
cli_auth_credentials_store_mode: Default::default(),
34333445
mcp_servers: HashMap::new(),
34343446
mcp_oauth_credentials_store_mode: Default::default(),
3447+
mcp_oauth_callback_port: None,
34353448
model_providers: fixture.model_provider_map.clone(),
34363449
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
34373450
project_doc_fallback_filenames: Vec::new(),
@@ -3519,6 +3532,7 @@ model_verbosity = "high"
35193532
cli_auth_credentials_store_mode: Default::default(),
35203533
mcp_servers: HashMap::new(),
35213534
mcp_oauth_credentials_store_mode: Default::default(),
3535+
mcp_oauth_callback_port: None,
35223536
model_providers: fixture.model_provider_map.clone(),
35233537
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
35243538
project_doc_fallback_filenames: Vec::new(),
@@ -3832,6 +3846,34 @@ trust_level = "untrusted"
38323846
assert_eq!(result, Some("explicit-provider".to_string()));
38333847
}
38343848

3849+
#[test]
3850+
fn config_toml_deserializes_mcp_oauth_callback_port() {
3851+
let toml = r#"mcp_oauth_callback_port = 4321"#;
3852+
let cfg: ConfigToml =
3853+
toml::from_str(toml).expect("TOML deserialization should succeed for callback port");
3854+
assert_eq!(cfg.mcp_oauth_callback_port, Some(4321));
3855+
}
3856+
3857+
#[test]
3858+
fn config_loads_mcp_oauth_callback_port_from_toml() -> std::io::Result<()> {
3859+
let codex_home = TempDir::new()?;
3860+
let toml = r#"
3861+
model = "gpt-5.1"
3862+
mcp_oauth_callback_port = 5678
3863+
"#;
3864+
let cfg: ConfigToml =
3865+
toml::from_str(toml).expect("TOML deserialization should succeed for callback port");
3866+
3867+
let config = Config::load_from_base_config_with_overrides(
3868+
cfg,
3869+
ConfigOverrides::default(),
3870+
codex_home.path().to_path_buf(),
3871+
)?;
3872+
3873+
assert_eq!(config.mcp_oauth_callback_port, Some(5678));
3874+
Ok(())
3875+
}
3876+
38353877
#[test]
38363878
fn test_untrusted_project_gets_unless_trusted_approval_policy() -> anyhow::Result<()> {
38373879
let codex_home = TempDir::new()?;

codex-rs/rmcp-client/src/perform_oauth_login.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use std::time::Duration;
66
use anyhow::Context;
77
use anyhow::Result;
88
use anyhow::anyhow;
9+
use anyhow::bail;
910
use reqwest::ClientBuilder;
1011
use rmcp::transport::auth::OAuthState;
1112
use tiny_http::Response;
@@ -44,6 +45,7 @@ pub async fn perform_oauth_login(
4445
http_headers: Option<HashMap<String, String>>,
4546
env_http_headers: Option<HashMap<String, String>>,
4647
scopes: &[String],
48+
callback_port: Option<u16>,
4749
) -> Result<()> {
4850
let headers = OauthHeaders {
4951
http_headers,
@@ -56,13 +58,15 @@ pub async fn perform_oauth_login(
5658
headers,
5759
scopes,
5860
true,
61+
callback_port,
5962
None,
6063
)
6164
.await?
6265
.finish()
6366
.await
6467
}
6568

69+
#[allow(clippy::too_many_arguments)]
6670
pub async fn perform_oauth_login_return_url(
6771
server_name: &str,
6872
server_url: &str,
@@ -71,6 +75,7 @@ pub async fn perform_oauth_login_return_url(
7175
env_http_headers: Option<HashMap<String, String>>,
7276
scopes: &[String],
7377
timeout_secs: Option<i64>,
78+
callback_port: Option<u16>,
7479
) -> Result<OauthLoginHandle> {
7580
let headers = OauthHeaders {
7681
http_headers,
@@ -83,6 +88,7 @@ pub async fn perform_oauth_login_return_url(
8388
headers,
8489
scopes,
8590
false,
91+
callback_port,
8692
timeout_secs,
8793
)
8894
.await?;
@@ -188,19 +194,40 @@ struct OauthLoginFlow {
188194
timeout: Duration,
189195
}
190196

197+
fn resolve_callback_port(callback_port: Option<u16>) -> Result<Option<u16>> {
198+
if let Some(config_port) = callback_port {
199+
if config_port == 0 {
200+
bail!(
201+
"invalid MCP OAuth callback port `{config_port}`: port must be between 1 and 65535"
202+
);
203+
}
204+
return Ok(Some(config_port));
205+
}
206+
207+
Ok(None)
208+
}
209+
191210
impl OauthLoginFlow {
211+
#[allow(clippy::too_many_arguments)]
192212
async fn new(
193213
server_name: &str,
194214
server_url: &str,
195215
store_mode: OAuthCredentialsStoreMode,
196216
headers: OauthHeaders,
197217
scopes: &[String],
198218
launch_browser: bool,
219+
callback_port: Option<u16>,
199220
timeout_secs: Option<i64>,
200221
) -> Result<Self> {
201222
const DEFAULT_OAUTH_TIMEOUT_SECS: i64 = 300;
202223

203-
let server = Arc::new(Server::http("127.0.0.1:0").map_err(|err| anyhow!(err))?);
224+
let callback_port = resolve_callback_port(callback_port)?;
225+
let bind_addr = match callback_port {
226+
Some(port) => format!("127.0.0.1:{port}"),
227+
None => "127.0.0.1:0".to_string(),
228+
};
229+
230+
let server = Arc::new(Server::http(&bind_addr).map_err(|err| anyhow!(err))?);
204231
let guard = CallbackServerGuard {
205232
server: Arc::clone(&server),
206233
};

0 commit comments

Comments
 (0)