[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
2026-06-25 21:50:36 +00:00
committed by GitHub
parent b80fbb70cd
commit a6d20ed297
27 changed files with 350 additions and 55 deletions
@@ -23,6 +23,12 @@ v2_enum_from_core!(
}
);
v2_enum_from_core!(
pub enum McpServerStartupFailureReason from codex_protocol::protocol::McpStartupFailureReason {
ReauthenticationRequired
}
);
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -242,6 +248,7 @@ pub struct McpServerStatusUpdatedNotification {
pub name: String,
pub status: McpServerStartupState,
pub error: Option<String>,
pub failure_reason: Option<McpServerStartupFailureReason>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
@@ -1,4 +1,5 @@
use super::*;
use crate::ServerNotification;
use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::items::AgentMessageContent;
@@ -2155,6 +2156,7 @@ fn mcp_server_status_updated_accepts_missing_thread_id() {
name: "optional_broken".to_string(),
status: McpServerStartupState::Failed,
error: Some("handshake failed".to_string()),
failure_reason: None,
};
assert_eq!(notification, expected);
assert_eq!(
@@ -2164,6 +2166,33 @@ fn mcp_server_status_updated_accepts_missing_thread_id() {
"name": "optional_broken",
"status": "failed",
"error": "handshake failed",
"failureReason": null,
})
);
}
#[test]
fn mcp_server_status_updated_serializes_failure_reason() {
let notification =
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
thread_id: Some("thread-1".to_string()),
name: "expired-oauth".to_string(),
status: McpServerStartupState::Failed,
error: Some("OAuth credentials expired".to_string()),
failure_reason: Some(McpServerStartupFailureReason::ReauthenticationRequired),
});
assert_eq!(
serde_json::to_value(notification).expect("notification should serialize"),
json!({
"method": "mcpServer/startupStatus/updated",
"params": {
"threadId": "thread-1",
"name": "expired-oauth",
"status": "failed",
"error": "OAuth credentials expired",
"failureReason": "reauthenticationRequired",
},
})
);
}