[codex] Surface MCP reauthentication-required startup failures (#29877)

## Summary

- distinguish expired, non-refreshable stored MCP OAuth credentials from
first-time missing credentials
- carry a typed `failureReason: "reauthenticationRequired"` on the
existing `mcpServer/startupStatus/updated` notification only when user
action is required
- keep the public MCP auth-status API unchanged and regenerate the
app-server protocol schemas and documentation

## Why

An MCP server with an expired access token and no usable refresh token
currently fails startup without giving clients a reliable, typed
recovery signal.

The existing startup-status notification is the natural place to carry
this state. Its nullable `failureReason` keeps the recovery reason
attached to the failed startup transition without adding a one-off
notification. Internally, Codex distinguishes first-time login from
reauthentication and emits the reason only when the startup error itself
requires authentication.

## User impact

App clients can prompt an existing user to reconnect an MCP server when
automatic recovery is impossible by handling a failed
`mcpServer/startupStatus/updated` notification whose `failureReason` is
`reauthenticationRequired`. Starting, ready, cancelled, unrelated
failures, and first-time setup carry no reauthentication reason.

## Companion app PR

- openai/openai#1069582

## Validation

- `just test -p codex-app-server-protocol` — 248 passed; schema fixture
tests passed
- `cargo check -p codex-app-server -p codex-tui`
- `just test -p codex-rmcp-client -p codex-mcp` — 184 passed, 2 skipped
- `just test -p codex-protocol -p codex-app-server-protocol -p
codex-mcp` — 579 passed
- `just write-app-server-schema`
- `just fmt`
This commit is contained in:
felixxia-oai authored and GitHub committed 2026-06-25 21:50:36 +00:00
1 parent b80fbb70cd
commit a6d20ed297
27 files changed
+350 -55

No files matched your search

+39 -1
View File
@@ -63,6 +63,7 @@ use rmcp::model::InitializeRequestParams;
use rmcp::model::JsonObject;
use rmcp::model::ProtocolVersion;
use rmcp::model::Tool as RmcpTool;
use rmcp::transport::auth::AuthError;
use tokio_util::sync::CancellationToken;
use tracing::Instrument;
use tracing::instrument;
@@ -304,13 +305,39 @@ pub(crate) enum StartupOutcomeError {
// We can't store the original error here because anyhow::Error doesn't implement
// `Clone`.
#[error("MCP startup failed: {error}")]
Failed { error: String },
Failed {
error: String,
is_authentication_required: bool,
},
}
impl StartupOutcomeError {
pub(crate) fn is_authentication_required(&self) -> bool {
match self {
Self::Cancelled => false,
Self::Failed {
is_authentication_required,
..
} => *is_authentication_required,
}
}
}
impl From<anyhow::Error> for StartupOutcomeError {
fn from(error: anyhow::Error) -> Self {
let is_authentication_required = error.chain().any(|source| {
source
.downcast_ref::<AuthError>()
.is_some_and(|auth_error| {
matches!(
auth_error,
AuthError::AuthorizationRequired | AuthError::TokenExpired
)
})
});
Self::Failed {
error: error.to_string(),
is_authentication_required,
}
}
}
@@ -765,6 +792,17 @@ mod tests {
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
use rmcp::model::Meta;
use rmcp::transport::auth::AuthError;
#[test]
fn startup_outcome_error_identifies_authentication_required() {
let error = anyhow::Error::new(AuthError::AuthorizationRequired)
.context("failed to initialize MCP server");
let error = StartupOutcomeError::from(error);
assert!(error.is_authentication_required());
}
#[test]
fn mcp_initialize_advertises_openai_form_only_when_supported() {