Recover MCP servers after OAuth reauthentication (#37337)

## Why

An OAuth-backed Streamable HTTP MCP server can fail startup when its stored credentials are rejected. If a client then completes OAuth sign-in and replaces those credentials, the failed server needs to become available without restarting the Codex session.

## What changed

- Track the credential store and OAuth tokens used for each MCP connection, and detect replacements after authentication failures.
- Refresh affected MCP servers before the next turn and include their recovered tools in the model request.
- Classify HTTP authentication challenges and rejected refresh tokens as requiring reauthentication, with sign-in guidance appropriate to local and client-managed environments.

## Testing

- Cover recovery from expired credentials through an externally written replacement token.
- Cover authentication-error classification, startup messaging, and `Auto` store migration from file credentials to the keyring.

GitOrigin-RevId: 9b090089d4e7a44ffc182f4e25da20a18e97b70f
This commit is contained in:
jif authored and copyberry committed 2026-08-07 00:17:46 +00:00
1 parent 7257826ab2
commit b3ffe3d001
14 files changed
+408 -57

No files matched your search

+61 -12
View File
@@ -50,6 +50,7 @@ use codex_protocol::openai_models::TruncationPolicyConfig;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::McpStartupFailureReason;
use codex_protocol::protocol::McpStartupStatus;
use codex_protocol::protocol::McpToolCallBeginEvent;
use codex_protocol::protocol::Op;
@@ -3077,7 +3078,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
let server_name = "rmcp_http_oauth";
let namespace = format!("mcp__{server_name}");
mount_sse_once(
let response_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
@@ -3130,13 +3131,15 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
credential_name.as_ref(),
&server_url,
client_id,
expected_token,
"expired-access-token",
refresh_token,
OAuthCredentialExpiry::Expired,
)?;
// Phase 4: configure Codex with the OAuth-backed Streamable HTTP MCP
// server and build the fixture in the active local or remote-aware mode.
let fixture = test_codex()
.with_model_info_override("gpt-5.4", |model| model.supports_search_tool = false)
.with_home(temp_home.clone())
.with_config(move |config| {
// Keep OAuth credentials isolated to this test home because Bazel
@@ -3160,9 +3163,35 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
})
.build_with_auto_env(&server)
.await?;
// Phase 5: wait for MCP startup before the turn is submitted, which keeps
// failures tied to server startup/discovery.
wait_for_mcp_server(&fixture.codex, server_name).await?;
// Phase 5: replace rejected credentials as an external OAuth login would.
let mut failure_reason = None;
let startup = wait_for_event(&fixture.codex, |event| {
if let EventMsg::McpStartupUpdate(update) = event
&& update.server == server_name
&& let McpStartupStatus::Failed { reason, .. } = &update.status
{
failure_reason = *reason;
}
matches!(event, EventMsg::McpStartupComplete(_))
})
.await;
let EventMsg::McpStartupComplete(startup) = startup else {
unreachable!("event guard guarantees McpStartupComplete");
};
assert_eq!(startup.failed.len(), 1);
assert_eq!(startup.failed[0].server, server_name);
assert_eq!(
failure_reason,
Some(McpStartupFailureReason::ReauthenticationRequired),
);
write_fallback_oauth_tokens(
credential_name.as_ref(),
http_server.url(),
client_id,
expected_token,
refresh_token,
OAuthCredentialExpiry::Valid,
)?;
// Phase 6: submit the user turn that should invoke the OAuth-backed tool.
fixture
@@ -3175,12 +3204,18 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
// Phase 7: assert Codex begins the expected tool invocation.
let begin_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallBegin(_))
matches!(
ev,
EventMsg::McpToolCallBegin(_)
| EventMsg::Error(_)
| EventMsg::TurnAborted(_)
| EventMsg::TurnComplete(_)
)
})
.await;
let EventMsg::McpToolCallBegin(begin) = begin_event else {
unreachable!("event guard guarantees McpToolCallBegin");
anyhow::bail!("OAuth MCP recovery ended before the tool was called: {begin_event:?}");
};
assert_eq!(begin.invocation.server, server_name);
assert_eq!(begin.invocation.tool, "echo");
@@ -3227,6 +3262,11 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
// placement-aware MCP server.
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let request = response_mock.single_request().body_json();
assert!(
responses::namespace_child_tool(&request, &namespace, "echo").is_some(),
"the recovered MCP tool must be advertised to the model"
);
server.verify().await;
http_server.shutdown().await;
@@ -3584,18 +3624,27 @@ fn streamable_http_metadata_url(server_url: &str) -> String {
format!("{base_url}{STREAMABLE_HTTP_METADATA_PATH}")
}
enum OAuthCredentialExpiry {
Valid,
Expired,
}
fn write_fallback_oauth_tokens(
server_name: &str,
server_url: &str,
client_id: &str,
access_token: &str,
refresh_token: &str,
expiry: OAuthCredentialExpiry,
) -> anyhow::Result<()> {
let expires_at = SystemTime::now()
.checked_add(Duration::from_secs(3600))
.ok_or_else(|| anyhow::anyhow!("failed to compute expiry time"))?
.duration_since(UNIX_EPOCH)?
.as_millis() as u64;
let expires_at = match expiry {
OAuthCredentialExpiry::Valid => SystemTime::now()
.checked_add(Duration::from_secs(3600))
.ok_or_else(|| anyhow::anyhow!("failed to compute expiry time"))?
.duration_since(UNIX_EPOCH)?
.as_millis() as u64,
OAuthCredentialExpiry::Expired => 0,
};
let tokens = serde_json::from_value(json!({
"server_name": server_name,