diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index a755947b60..f6d0c7ec70 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -231,6 +231,7 @@ impl McpConnectionSet { } = input; let store_mode = config.mcp_oauth_credentials_store_mode; let keyring_backend_kind = config.auth_keyring_backend_kind; + let oauth_refresh_mode = config.oauth_refresh_mode; let codex_home = config.codex_home.clone(); let prefix_mcp_tool_names = config.prefix_mcp_tool_names; let non_prefixed_mcp_tool_servers = config.non_prefixed_mcp_tool_servers.clone(); @@ -362,6 +363,7 @@ impl McpConnectionSet { server: server.clone(), store_mode, keyring_backend_kind, + oauth_refresh_mode, runtime_context: runtime_context.clone(), resolved_environment: resolved_environment.clone(), auth_provider: runtime_auth_provider.clone(), @@ -377,6 +379,7 @@ impl McpConnectionSet { host_plugin_root, store_mode, keyring_backend_kind, + oauth_refresh_mode, &resolved_environment, &runtime_context, runtime_auth_provider.as_ref(), @@ -539,6 +542,7 @@ impl McpConnectionSet { server, store_mode, keyring_backend_kind, + oauth_refresh_mode, cancel_token.clone(), tx_event.clone(), elicitation_requests.clone(), diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index ecc006097e..9c974ac466 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -57,6 +57,7 @@ use codex_rmcp_client::ElicitationResponse; use codex_rmcp_client::InProcessTransportFactory; use codex_rmcp_client::McpAuthState; use codex_rmcp_client::McpLoginRequirement; +use codex_rmcp_client::McpOAuthRefreshMode; use codex_rmcp_client::RmcpClient; use codex_utils_path_uri::PathUri; use futures::FutureExt; @@ -4153,6 +4154,7 @@ async fn executor_owned_chatgpt_mcp_accepts_only_safe_explicit_authorization() - /*host_plugin_root*/ None, OAuthCredentialsStoreMode::File, keyring_backend_kind, + McpOAuthRefreshMode::Legacy, &resolved_environment, &runtime_context, /*runtime_auth_provider*/ None, @@ -4675,6 +4677,7 @@ fn reusable_server_identity( /*host_plugin_root*/ None, OAuthCredentialsStoreMode::default(), AuthKeyringBackendKind::default(), + McpOAuthRefreshMode::Legacy, &resolved_environment, runtime_context, /*runtime_auth_provider*/ None, @@ -5054,6 +5057,7 @@ fn connection_identity_uses_effective_authorization_headers() { (Some("invalid\nheader"), None, false), (None, Some("PATH"), true), (None, Some(missing_env_var.as_str()), false), + (None, None, false), ] { let mut config = reusable_server_config("http://127.0.0.1:1"); config.transport = McpServerTransportConfig::StreamableHttp { @@ -5066,13 +5070,14 @@ fn connection_identity_uses_effective_authorization_headers() { http_headers_helper: None, }; let server = EffectiveMcpServer::configured(config); - let identity = |keyring_backend_kind| { + let identity = |keyring_backend_kind, oauth_refresh_mode| { McpServerConnectionIdentity::new( "docs", &server, /*host_plugin_root*/ None, OAuthCredentialsStoreMode::File, keyring_backend_kind, + oauth_refresh_mode, &Ok(None), &runtime_context, /*runtime_auth_provider*/ None, @@ -5085,8 +5090,19 @@ fn connection_identity_uses_effective_authorization_headers() { }; assert_eq!( - identity(AuthKeyringBackendKind::Direct) - .has_same_connection_config(&identity(AuthKeyringBackendKind::Secrets)), + identity(AuthKeyringBackendKind::Direct, McpOAuthRefreshMode::Legacy) + .has_same_connection_config(&identity( + AuthKeyringBackendKind::Secrets, + McpOAuthRefreshMode::Legacy, + )), + has_authorization, + ); + assert_eq!( + identity(AuthKeyringBackendKind::Direct, McpOAuthRefreshMode::Legacy) + .has_same_connection_config(&identity( + AuthKeyringBackendKind::Direct, + McpOAuthRefreshMode::Coordinated, + )), has_authorization, ); } @@ -5472,6 +5488,7 @@ async fn reconciliation_reconnects_when_host_plugin_root_changes() { Some(&original_root), OAuthCredentialsStoreMode::default(), AuthKeyringBackendKind::default(), + McpOAuthRefreshMode::Legacy, &resolved_environment, &runtime_context, /*runtime_auth_provider*/ None, @@ -5550,6 +5567,7 @@ async fn connection_identity_distinguishes_accounts_with_the_same_token() -> any /*host_plugin_root*/ None, OAuthCredentialsStoreMode::default(), AuthKeyringBackendKind::default(), + McpOAuthRefreshMode::Legacy, &Ok(None), &runtime_context, Some(&provider), @@ -5603,6 +5621,7 @@ async fn connection_identity_distinguishes_agent_account_runtime_and_task() -> a /*host_plugin_root*/ None, OAuthCredentialsStoreMode::default(), AuthKeyringBackendKind::default(), + McpOAuthRefreshMode::Legacy, &Ok(None), &runtime_context, Some(&provider), diff --git a/codex-rs/codex-mcp/src/event_stream.rs b/codex-rs/codex-mcp/src/event_stream.rs index 7ad58dd626..ca52d20f6f 100644 --- a/codex-rs/codex-mcp/src/event_stream.rs +++ b/codex-rs/codex-mcp/src/event_stream.rs @@ -12,6 +12,7 @@ use codex_login::AuthManager; use codex_login::CodexAuth; use codex_protocol::mcp::ClientMcpExtensions; use codex_rmcp_client::ElicitationResponse; +use codex_rmcp_client::McpOAuthRefreshMode; use rmcp::model::ElicitationAction; use rmcp::model::ElicitationCapability; use serde_json::Map; @@ -31,6 +32,7 @@ pub(crate) struct EventStreamConnectionSettings { pub server: EffectiveMcpServer, pub store_mode: OAuthCredentialsStoreMode, pub keyring_backend_kind: AuthKeyringBackendKind, + pub oauth_refresh_mode: McpOAuthRefreshMode, pub runtime_context: McpRuntimeContext, pub resolved_environment: std::result::Result>, String>, pub auth_provider: Option, @@ -81,6 +83,7 @@ impl McpEventStreamOpener { connection.server.clone(), connection.store_mode, connection.keyring_backend_kind, + connection.oauth_refresh_mode, connection.runtime_context.clone(), connection.resolved_environment.clone(), connection.auth_provider.clone(), diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 1042230797..783a116615 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -41,6 +41,7 @@ use codex_protocol::mcp::Tool; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::McpAuthStatus; +use codex_rmcp_client::McpOAuthRefreshMode; use codex_utils_path_uri::PathUri; use rmcp::model::ElicitationCapability; use rmcp::model::ReadResourceRequestParams; @@ -128,6 +129,8 @@ pub struct McpConfig { pub codex_home: PathBuf, /// Preferred credential store for MCP OAuth tokens. pub mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode, + /// OAuth refresh ownership selected for new MCP connections. + pub oauth_refresh_mode: McpOAuthRefreshMode, /// Backend used when MCP OAuth storage is configured for keyring-backed persistence. pub auth_keyring_backend_kind: AuthKeyringBackendKind, /// Optional fixed localhost callback port for MCP OAuth login. diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index dd0856b664..fb1ee3126e 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -24,6 +24,7 @@ pub(crate) fn test_mcp_config(codex_home: PathBuf) -> McpConfig { apps_mcp_product_sku: None, codex_home, mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::default(), + oauth_refresh_mode: McpOAuthRefreshMode::Legacy, auth_keyring_backend_kind: AuthKeyringBackendKind::default(), mcp_oauth_callback_port: None, mcp_oauth_callback_url: None, diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 7acd6b3b7a..9b4c8137b8 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -60,6 +60,7 @@ use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; use codex_rmcp_client::ExecutorStdioServerLauncher; use codex_rmcp_client::LocalStdioServerLauncher; +use codex_rmcp_client::McpOAuthRefreshMode; use codex_rmcp_client::McpProtocolMode; use codex_rmcp_client::RmcpClient; use codex_rmcp_client::StdioServerLauncher; @@ -283,6 +284,7 @@ struct ManagedClientStartup { server: EffectiveMcpServer, store_mode: OAuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, + oauth_refresh_mode: McpOAuthRefreshMode, tx_event: Option>, elicitation_requests: ElicitationRequestManager, codex_apps_tools_cache_context: Option>, @@ -305,6 +307,7 @@ impl ManagedClientStartup { server, store_mode, keyring_backend_kind, + oauth_refresh_mode, tx_event, elicitation_requests, codex_apps_tools_cache_context, @@ -342,6 +345,7 @@ impl ManagedClientStartup { server.clone(), store_mode, keyring_backend_kind, + oauth_refresh_mode, runtime_context, resolved_environment, runtime_auth_provider, @@ -423,6 +427,7 @@ impl AsyncManagedClient { server: EffectiveMcpServer, store_mode: OAuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, + oauth_refresh_mode: McpOAuthRefreshMode, cancel_token: CancellationToken, tx_event: Option>, elicitation_requests: ElicitationRequestManager, @@ -452,6 +457,7 @@ impl AsyncManagedClient { server, store_mode, keyring_backend_kind, + oauth_refresh_mode, tx_event, elicitation_requests, codex_apps_tools_cache_context: codex_apps_tools_cache_context.clone(), @@ -1072,11 +1078,17 @@ pub(crate) async fn make_rmcp_client( server: EffectiveMcpServer, store_mode: OAuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, + oauth_refresh_mode: McpOAuthRefreshMode, runtime_context: McpRuntimeContext, resolved_environment: std::result::Result>, String>, runtime_auth_provider: Option, protocol_mode: McpProtocolMode, ) -> Result { + if oauth_refresh_mode == McpOAuthRefreshMode::Coordinated { + warn!( + "MCP OAuth refresh coordination is not available in this build; using legacy refresh" + ); + } let config = server.config().clone(); if matches!(config.auth, McpServerAuth::ChatGpt) && !config.is_local_environment() diff --git a/codex-rs/codex-mcp/src/server.rs b/codex-rs/codex-mcp/src/server.rs index e1b7276975..328080f207 100644 --- a/codex-rs/codex-mcp/src/server.rs +++ b/codex-rs/codex-mcp/src/server.rs @@ -15,6 +15,7 @@ use codex_connectors::ConnectorRuntimeContextKey; use codex_exec_server::Environment; use codex_login::CodexAuth; use codex_protocol::mcp::ClientMcpExtensions; +use codex_rmcp_client::McpOAuthRefreshMode; use codex_rmcp_client::StoredOAuthCredentialSnapshot; use codex_rmcp_client::StoredOAuthTokens; use codex_utils_path_uri::PathUri; @@ -99,6 +100,7 @@ pub(crate) struct McpServerConnectionIdentity { environment_id: String, host_plugin_root: Option, oauth_store: Option<(OAuthCredentialsStoreMode, AuthKeyringBackendKind)>, + oauth_refresh_mode: Option, oauth_credentials: Result, String>, pub(crate) oauth_store_was_contended: bool, resolved_environment: Result>, String>, @@ -120,6 +122,7 @@ impl McpServerConnectionIdentity { host_plugin_root: Option<&PathUri>, store_mode: OAuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, + oauth_refresh_mode: McpOAuthRefreshMode, resolved_environment: &Result>, String>, runtime_context: &McpRuntimeContext, runtime_auth_provider: Option<&SharedAuthProvider>, @@ -217,6 +220,7 @@ impl McpServerConnectionIdentity { oauth_store: stored_oauth_url .is_some() .then_some((store_mode, keyring_backend_kind)), + oauth_refresh_mode: stored_oauth_url.is_some().then_some(oauth_refresh_mode), oauth_credentials, oauth_store_was_contended, resolved_environment: resolved_environment.clone(), @@ -250,6 +254,7 @@ impl McpServerConnectionIdentity { && self.environment_id == other.environment_id && self.host_plugin_root == other.host_plugin_root && self.oauth_store == other.oauth_store + && self.oauth_refresh_mode == other.oauth_refresh_mode && same_resolved_environment(&self.resolved_environment, &other.resolved_environment) && self.local_stdio_fallback_cwd == other.local_stdio_fallback_cwd && self.referenced_environment_variables == other.referenced_environment_variables diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 3063df2ed7..ec4e457335 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -822,6 +822,9 @@ "mcp_2026_07_28": { "type": "boolean" }, + "mcp_oauth_refresh_coordination": { + "type": "boolean" + }, "memories": { "type": "boolean" }, @@ -6111,6 +6114,9 @@ "mcp_2026_07_28": { "type": "boolean" }, + "mcp_oauth_refresh_coordination": { + "type": "boolean" + }, "memories": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 65e9620403..2e5c8ec9cd 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -124,6 +124,7 @@ use codex_protocol::permissions::ReadDenyMatcher; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SandboxPolicy; +use codex_rmcp_client::McpOAuthRefreshMode; pub use codex_thread_store::ExtraConfig; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; @@ -1739,6 +1740,11 @@ impl Config { apps_mcp_product_sku: self.apps_mcp_product_sku.clone(), codex_home: self.codex_home.to_path_buf(), mcp_oauth_credentials_store_mode: self.mcp_oauth_credentials_store_mode, + oauth_refresh_mode: if self.features.enabled(Feature::McpOAuthRefreshCoordination) { + McpOAuthRefreshMode::Coordinated + } else { + McpOAuthRefreshMode::Legacy + }, auth_keyring_backend_kind: self.auth_keyring_backend_kind(), mcp_oauth_callback_port: self.mcp_oauth_callback_port, mcp_oauth_callback_url: self.mcp_oauth_callback_url.clone(), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 1c3e2ff1c8..3d0af2e645 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1883,6 +1883,14 @@ impl Session { ) { warn!("failed to refresh MCP auth storage config: {err}"); } + if let Err(err) = config.features.set_enabled( + Feature::McpOAuthRefreshCoordination, + next_config + .features + .enabled(Feature::McpOAuthRefreshCoordination), + ) { + warn!("failed to refresh MCP OAuth coordination config: {err}"); + } let config = Arc::new(config); state.session_configuration.original_config_do_not_use = Arc::clone(&config); self.mark_mcp_runtime_dirty(); @@ -1931,6 +1939,14 @@ impl Session { ) { warn!("failed to refresh MCP auth storage config: {err}"); } + if let Err(err) = config.features.set_enabled( + Feature::McpOAuthRefreshCoordination, + next_config + .features + .enabled(Feature::McpOAuthRefreshCoordination), + ) { + warn!("failed to refresh MCP OAuth coordination config: {err}"); + } state.session_configuration.original_config_do_not_use = Arc::new(config); self.mark_mcp_runtime_dirty(); drop(state); diff --git a/codex-rs/core/tests/suite/mcp_oauth_refresh_tests.rs b/codex-rs/core/tests/suite/mcp_oauth_refresh_tests.rs new file mode 100644 index 0000000000..36a7139352 --- /dev/null +++ b/codex-rs/core/tests/suite/mcp_oauth_refresh_tests.rs @@ -0,0 +1,130 @@ +//! Covers OAuth connection replacement when a running thread refreshes its feature config. + +use super::EnvVarGuard; +use super::OAuthCredentialExpiry; +use super::write_fallback_oauth_tokens; +use std::sync::Arc; + +use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID; +use codex_config::McpServerConfig; +use codex_config::types::OAuthCredentialsStoreMode; +use codex_features::Feature; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use pretty_assertions::assert_eq; +use serde_json::json; +use serial_test::serial; +use test_case::test_case; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +enum ConfigRefreshPath { + Runtime, + Mcp, +} + +#[test_case(ConfigRefreshPath::Runtime; "runtime configuration refresh")] +#[test_case(ConfigRefreshPath::Mcp; "MCP configuration refresh")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial(codex_home)] +async fn oauth_mode_refresh_replaces_the_live_connection( + refresh_path: ConfigRefreshPath, +) -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let responses_server = responses::start_mock_server().await; + let mcp_server = responses::start_mock_server().await; + let (http_server, startup_control) = + AppsTestServer::mount_with_startup_control(&mcp_server).await?; + let server_name = "oauth_refresh"; + let server_url = format!("{}/api/codex/ps/mcp", http_server.chatgpt_base_url); + Mock::given(method("GET")) + .and(path( + "/.well-known/oauth-authorization-server/api/codex/ps/mcp", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "issuer": server_url, + "authorization_endpoint": format!("{}/oauth/authorize", mcp_server.uri()), + "token_endpoint": format!("{}/oauth/token", mcp_server.uri()), + }))) + .mount(&mcp_server) + .await; + let server_config: McpServerConfig = serde_json::from_value(json!({ + "url": server_url, + "environment_id": DEFAULT_MCP_SERVER_ENVIRONMENT_ID, + }))?; + let home = Arc::new(tempfile::tempdir()?); + let _codex_home_guard = EnvVarGuard::set("CODEX_HOME", home.path().as_os_str()); + write_fallback_oauth_tokens( + server_config.oauth_credential_name(server_name).as_ref(), + &server_url, + "test-client-id", + "synthetic-access-token", + "synthetic-refresh-token", + OAuthCredentialExpiry::Valid, + )?; + let fixture = test_codex() + .with_home(home) + .with_config(move |config| { + config + .features + .disable(Feature::McpOAuthRefreshCoordination) + .expect("test config should allow the OAuth coordination feature"); + config.mcp_oauth_credentials_store_mode = OAuthCredentialsStoreMode::File; + config + .mcp_servers + .set([(server_name.to_string(), server_config)].into()) + .expect("test config should allow the OAuth server"); + }) + // The mock MCP endpoint is on the host, including when the executor is remote. + .build_with_remote_and_local_env(&responses_server) + .await?; + + let initial_result = fixture + .codex + .call_mcp_tool( + server_name, + "calendar_list_events", + /*arguments*/ None, + /*meta*/ None, + ) + .await?; + assert_eq!(startup_control.initialize_attempts(), 1); + + for (enabled, expected_initializations) in [(true, 2), (true, 2), (false, 3)] { + let mut refreshed_config = fixture.config.clone(); + refreshed_config + .features + .set_enabled(Feature::McpOAuthRefreshCoordination, enabled)?; + match refresh_path { + ConfigRefreshPath::Runtime => { + fixture.codex.refresh_runtime_config(refreshed_config).await; + } + ConfigRefreshPath::Mcp => { + fixture.codex.refresh_mcp_config(refreshed_config).await; + } + } + // A normal tool call reconciles the refreshed config without forcing a reconnect. + let result = fixture + .codex + .call_mcp_tool( + server_name, + "calendar_list_events", + /*arguments*/ None, + /*meta*/ None, + ) + .await?; + assert_eq!(result, initial_result); + assert_eq!( + startup_control.initialize_attempts(), + expected_initializations + ); + } + + fixture.codex.shutdown_and_wait().await?; + Ok(()) +} diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 76a92f8ec6..ebdeaf4823 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -119,6 +119,9 @@ use tokio::time::Instant; use tokio::time::sleep; use wiremock::MockServer; +#[path = "mcp_oauth_refresh_tests.rs"] +mod oauth_refresh_tests; + static OPENAI_PNG: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAD0AAAA9CAYAAAAeYmHpAAAE6klEQVR4Aeyau44UVxCGx1fZsmRLlm3Zoe0XcGQ5cUiCCIgJeS9CHgAhMkISQnIuGQgJEkBcxLW+nqnZ6uqqc+nuWRC7q/P3qetf9e+MtOwyX25O4Nep6JPyop++0qev9HrfgZ+F6r2DuB/vHOrt/UIkqdDHYvujOW6fO7h/CNEI+a5jc+pBR8uy0jVFsziYu5HtfSUk+Io34q921hLNctFSX0gwww+S8wce8K1LfCU+cYW4888aov8NxqvQILUPPReLOrm6zyLxa4i+6VZuFbJo8d1MOHZm+7VUtB/aIvhPWc/3SWg49JcwFLlHxuXKjtyloo+YNhuW3VS+WPBuUEMvCFKjEDVgFBQHXrnazpqiSxNZCkQ1kYiozsbm9Oz7l4i2Il7vGccGNWAc3XosDrZe/9P3ZnMmzHNEQw4smf8RQ87XEAMsC7Az0Au+dgXerfH4+sHvEc0SYGic8WBBUGqFH2gN7yDrazy7m2pbRTeRmU3+MjZmr1h6LJgPbGy23SI6GlYT0brQ71IY8Us4PNQCm+zepSbaD2BY9xCaAsD9IIj/IzFmKMSdHHonwdZATbTnYREf6/VZGER98N9yCWIvXQwXDoDdhZJoT8jwLnJXDB9w4Sb3e6nK5ndzlkTLnP3JBu4LKkbrYrU69gCVceV0JvpyuW1xlsUVngzhwMetn/XamtTORF9IO5YnWNiyeF9zCAfqR3fUW+vZZKLtgP+ts8BmQRBREAdRDhH3o8QuRh/YucNFz2BEjxbRN6LGzphfKmvP6v6QhqIQyZ8XNJ0W0X83MR1PEcJBNO2KC2Z1TW/v244scp9FwRViZxIOBF0Lctk7ZVSavdLvRlV1hz/ysUi9sr8CIcB3nvWBwA93ykTz18eAYxQ6N/K2DkPA1lv3iXCwmDUT7YkjIby9siXueIJj9H+pzSqJ9oIuJWTUgSSt4WO7o/9GGg0viR4VinNRUDoIj34xoCd6pxD3aK3zfdbnx5v1J3ZNNEJsE0sBG7N27ReDrJc4sFxz7dI/ZAbOmmiKvHBitQXpAdR6+F7v+/ol/tOouUV01EeMZQF2BoQDn6dP4XNr+j9GZEtEK1/L8pFw7bd3a53tsTa7WD+054jOFmPg1XBKPQgnqFfmFcy32ZRvjmiIIQTYFvyDxQ8nH8WIwwGwlyDjDznnilYyFr6njrlZwsKkBpO59A7OwgdzPEWRm+G+oeb7IfyNuzjEEVLrOVxJsxvxwF8kmCM6I2QYmJunz4u4TrADpfl7mlbRTWQ7VmrBzh3+C9f6Grc3YoGN9dg/SXFthpRsT6vobfXRs2VBlgBHXVMLHjDNbIZv1sZ9+X3hB09cXdH1JKViyG0+W9bWZDa/r2f9zAFR71sTzGpMSWz2iI4YssWjWo3REy1MDGjdwe5e0dFSiAC1JakBvu4/CUS8Eh6dqHdU0Or0ioY3W5ClSqDXAy7/6SRfgw8vt4I+tbvvNtFT2kVDhY5+IGb1rCqYaXNF08vSALsXCPmt0kQNqJT1p5eI1mkIV/BxCY1z85lOzeFbPBQHURkkPTlwTYK9gTVE25l84IbFFN+YJDHjdpn0gq6mrHht0dkcjbM4UL9283O5p77GN+SPW/QwVB4IUYg7Or+Kp7naR6qktP98LNF2UxWo9yObPIT9KYg+hK4i56no4rfnM0qeyFf6AwAAAP//trwR3wAAAAZJREFUAwBZ0sR75itw5gAAAABJRU5ErkJggg=="; fn assert_wall_time_line(line: &str) { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 65c91e1f06..ec463fefc4 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -204,6 +204,8 @@ pub enum Feature { EnableMcpApps, /// Enable MCP protocol version 2026-07-28 support. Mcp20260728, + /// Let RMCP coordinate OAuth refresh through the configured credential store. + McpOAuthRefreshCoordination, /// Removed compatibility flag for the legacy Apps MCP path override. AppsMcpPathOverride, /// Removed compatibility flag retained as a no-op now that tool_search is always enabled. @@ -1251,6 +1253,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::McpOAuthRefreshCoordination, + key: "mcp_oauth_refresh_coordination", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::AppsMcpPathOverride, key: "apps_mcp_path_override", diff --git a/codex-rs/rmcp-client/src/lib.rs b/codex-rs/rmcp-client/src/lib.rs index 0ca24001a9..4c41f0823a 100644 --- a/codex-rs/rmcp-client/src/lib.rs +++ b/codex-rs/rmcp-client/src/lib.rs @@ -18,6 +18,7 @@ mod oauth; mod oauth_callback; mod oauth_client_registration; mod oauth_http_client; +mod oauth_refresh_mode; mod perform_oauth_login; mod program_resolver; mod protocol_mode; @@ -61,6 +62,7 @@ pub use oauth::stored_oauth_credentials; pub use oauth_callback::McpOAuthCallbackMode; pub use oauth_callback::resolve_mcp_oauth_callback_url; pub use oauth_client_registration::McpOAuthClientRegistration; +pub use oauth_refresh_mode::McpOAuthRefreshMode; pub use perform_oauth_login::OAuthProviderError; pub use perform_oauth_login::OauthLoginHandle; pub use perform_oauth_login::perform_oauth_login; diff --git a/codex-rs/rmcp-client/src/oauth_refresh_mode.rs b/codex-rs/rmcp-client/src/oauth_refresh_mode.rs new file mode 100644 index 0000000000..84b2a98370 --- /dev/null +++ b/codex-rs/rmcp-client/src/oauth_refresh_mode.rs @@ -0,0 +1,11 @@ +//! Selects the owner of MCP OAuth refresh and credential persistence. + +/// MCP OAuth policy pinned for the lifetime of a connection. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum McpOAuthRefreshMode { + /// Keep Codex's existing refresh and persistence path. + #[default] + Legacy, + /// Let RMCP coordinate refresh through Codex's credential store. + Coordinated, +}