Files
codex/codex-rs/rmcp-client/src/startup_error.rs
T
jif b3ffe3d001 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
2026-08-07 00:17:46 +00:00

59 lines
2.2 KiB
Rust

use anyhow::Error;
use rmcp::service::ClientInitializeError;
use rmcp::service::ServiceError;
use rmcp::transport::DynamicTransportError;
use rmcp::transport::auth::AuthError;
use rmcp::transport::streamable_http_client::StreamableHttpError;
use crate::http_client_adapter::StreamableHttpClientAdapterError;
use crate::rmcp_client::ClientOperationError;
/// Returns whether an RMCP client error indicates that authentication is required.
///
/// This does not distinguish first-time login from reauthentication.
/// Streamable HTTP initialization errors are stored inside RMCP's dynamic
/// transport error, which is not part of the standard error source chain.
pub fn is_authentication_required_error(error: &Error) -> bool {
error.chain().any(|source| {
source
.downcast_ref::<AuthError>()
.is_some_and(auth_error_requires_authentication)
|| source
.downcast_ref::<ClientInitializeError>()
.is_some_and(|error| {
matches!(
error,
ClientInitializeError::TransportError { error, .. }
if transport_error_requires_authentication(error)
)
})
|| source
.downcast_ref::<ClientOperationError>()
.is_some_and(|error| {
matches!(
error,
ClientOperationError::Service(ServiceError::TransportSend(error))
if transport_error_requires_authentication(error)
)
})
})
}
fn transport_error_requires_authentication(error: &DynamicTransportError) -> bool {
error
.error
.downcast_ref::<StreamableHttpError<StreamableHttpClientAdapterError>>()
.is_some_and(|error| match error {
StreamableHttpError::AuthRequired(_) => true,
StreamableHttpError::Auth(auth_error) => auth_error_requires_authentication(auth_error),
_ => false,
})
}
fn auth_error_requires_authentication(error: &AuthError) -> bool {
matches!(
error,
AuthError::AuthorizationRequired | AuthError::TokenExpired
)
}