Prepare MCP connections for coordinated OAuth refresh (#42128)

## What changed

- Add the under-development `mcp_oauth_refresh_coordination` feature and pin its selected refresh mode to each MCP connection.
- Treat the refresh mode as part of OAuth connection identity so runtime and MCP configuration refreshes reconnect when the mode changes.
- Keep legacy refresh behavior in builds where coordination is unavailable and emit a warning when coordinated mode is selected.

## Testing

- Cover connection replacement when the feature is toggled through both runtime and MCP configuration refresh paths.

GitOrigin-RevId: 89596150f0338e6ede0e712badecea4450831766
This commit is contained in:
Matthew Zeng authored and copyberry committed 2026-09-01 20:26:57 +00:00
1 parent 7730edf62c
commit bd89ce67cc
15 files changed
+232 -3

No files matched your search

@@ -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(())
}
+3
View File
@@ -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) {