[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

@@ -2384,6 +2384,12 @@
], ],
"type": "object" "type": "object"
}, },
"McpServerStartupFailureReason": {
"enum": [
"reauthenticationRequired"
],
"type": "string"
},
"McpServerStartupState": { "McpServerStartupState": {
"enum": [ "enum": [
"starting", "starting",
@@ -2401,6 +2407,16 @@
"null" "null"
] ]
}, },
"failureReason": {
"anyOf": [
{
"$ref": "#/definitions/McpServerStartupFailureReason"
},
{
"type": "null"
}
]
},
"name": { "name": {
"type": "string" "type": "string"
}, },
@@ -12094,6 +12094,12 @@
"title": "McpServerRefreshResponse", "title": "McpServerRefreshResponse",
"type": "object" "type": "object"
}, },
"McpServerStartupFailureReason": {
"enum": [
"reauthenticationRequired"
],
"type": "string"
},
"McpServerStartupState": { "McpServerStartupState": {
"enum": [ "enum": [
"starting", "starting",
@@ -12165,6 +12171,16 @@
"null" "null"
] ]
}, },
"failureReason": {
"anyOf": [
{
"$ref": "#/definitions/v2/McpServerStartupFailureReason"
},
{
"type": "null"
}
]
},
"name": { "name": {
"type": "string" "type": "string"
}, },
@@ -8498,6 +8498,12 @@
"title": "McpServerRefreshResponse", "title": "McpServerRefreshResponse",
"type": "object" "type": "object"
}, },
"McpServerStartupFailureReason": {
"enum": [
"reauthenticationRequired"
],
"type": "string"
},
"McpServerStartupState": { "McpServerStartupState": {
"enum": [ "enum": [
"starting", "starting",
@@ -8569,6 +8575,16 @@
"null" "null"
] ]
}, },
"failureReason": {
"anyOf": [
{
"$ref": "#/definitions/McpServerStartupFailureReason"
},
{
"type": "null"
}
]
},
"name": { "name": {
"type": "string" "type": "string"
}, },
@@ -1,6 +1,12 @@
{ {
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"definitions": { "definitions": {
"McpServerStartupFailureReason": {
"enum": [
"reauthenticationRequired"
],
"type": "string"
},
"McpServerStartupState": { "McpServerStartupState": {
"enum": [ "enum": [
"starting", "starting",
@@ -18,6 +24,16 @@
"null" "null"
] ]
}, },
"failureReason": {
"anyOf": [
{
"$ref": "#/definitions/McpServerStartupFailureReason"
},
{
"type": "null"
}
]
},
"name": { "name": {
"type": "string" "type": "string"
}, },
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type McpServerStartupFailureReason = "reauthenticationRequired";
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND! // GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { McpServerStartupFailureReason } from "./McpServerStartupFailureReason";
import type { McpServerStartupState } from "./McpServerStartupState"; import type { McpServerStartupState } from "./McpServerStartupState";
export type McpServerStatusUpdatedNotification = { threadId: string | null, name: string, status: McpServerStartupState, error: string | null, }; export type McpServerStatusUpdatedNotification = { threadId: string | null, name: string, status: McpServerStartupState, error: string | null, failureReason: McpServerStartupFailureReason | null, };
@@ -238,6 +238,7 @@ export type { McpServerOauthLoginCompletedNotification } from "./McpServerOauthL
export type { McpServerOauthLoginParams } from "./McpServerOauthLoginParams"; export type { McpServerOauthLoginParams } from "./McpServerOauthLoginParams";
export type { McpServerOauthLoginResponse } from "./McpServerOauthLoginResponse"; export type { McpServerOauthLoginResponse } from "./McpServerOauthLoginResponse";
export type { McpServerRefreshResponse } from "./McpServerRefreshResponse"; export type { McpServerRefreshResponse } from "./McpServerRefreshResponse";
export type { McpServerStartupFailureReason } from "./McpServerStartupFailureReason";
export type { McpServerStartupState } from "./McpServerStartupState"; export type { McpServerStartupState } from "./McpServerStartupState";
export type { McpServerStatus } from "./McpServerStatus"; export type { McpServerStatus } from "./McpServerStatus";
export type { McpServerStatusDetail } from "./McpServerStatusDetail"; export type { McpServerStatusDetail } from "./McpServerStatusDetail";
@@ -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)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")] #[ts(export_to = "v2/")]
@@ -242,6 +248,7 @@ pub struct McpServerStatusUpdatedNotification {
pub name: String, pub name: String,
pub status: McpServerStartupState, pub status: McpServerStartupState,
pub error: Option<String>, pub error: Option<String>,
pub failure_reason: Option<McpServerStartupFailureReason>,
} }
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
@@ -1,4 +1,5 @@
use super::*; use super::*;
use crate::ServerNotification;
use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest;
use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageContent;
@@ -2155,6 +2156,7 @@ fn mcp_server_status_updated_accepts_missing_thread_id() {
name: "optional_broken".to_string(), name: "optional_broken".to_string(),
status: McpServerStartupState::Failed, status: McpServerStartupState::Failed,
error: Some("handshake failed".to_string()), error: Some("handshake failed".to_string()),
failure_reason: None,
}; };
assert_eq!(notification, expected); assert_eq!(notification, expected);
assert_eq!( assert_eq!(
@@ -2164,6 +2166,33 @@ fn mcp_server_status_updated_accepts_missing_thread_id() {
"name": "optional_broken", "name": "optional_broken",
"status": "failed", "status": "failed",
"error": "handshake 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",
},
}) })
); );
} }
+2 -2
View File
@@ -1345,7 +1345,7 @@ Because audio is intentionally separate from `ThreadItem`, clients can opt out o
### MCP server startup events ### MCP server startup events
- `mcpServer/startupStatus/updated` — `{ threadId, name, status, error }` when app-server observes an MCP server startup transition. `threadId` identifies the owning thread when startup is thread-scoped and is `null` when startup is app-scoped. `status` is one of `starting`, `ready`, `failed`, or `cancelled`. `error` is `null` except for `failed`. - `mcpServer/startupStatus/updated` — `{ threadId, name, status, error, failureReason }` when app-server observes an MCP server startup transition. `threadId` identifies the owning thread when startup is thread-scoped and is `null` when startup is app-scoped. `status` is one of `starting`, `ready`, `failed`, or `cancelled`. `error` and `failureReason` are `null` except for `failed`; `failureReason` is `reauthenticationRequired` when stored OAuth credentials have expired and cannot be refreshed, so clients can prompt the user to reconnect the named server.
### Turn events ### Turn events
@@ -1935,7 +1935,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. - `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot.
- `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit. - `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit.
- `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, threadId, success, error? }`. - `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, threadId, success, error? }`.
- `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes; payload includes `{ threadId, name, status, error }`, where `threadId` is the owning thread when startup is thread-scoped and `null` when it is app-scoped, and `status` is `starting`, `ready`, `failed`, or `cancelled`. - `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes; payload includes `{ threadId, name, status, error, failureReason }`, where `threadId` is the owning thread when startup is thread-scoped and `null` when it is app-scoped, and `status` is `starting`, `ready`, `failed`, or `cancelled`. `failureReason` is `reauthenticationRequired` when stored OAuth credentials have expired and cannot be refreshed, so clients can prompt the user to reconnect the named server.
### 1) Check auth state ### 1) Check auth state
@@ -200,18 +200,20 @@ pub(crate) async fn apply_bespoke_event_handling(
.await; .await;
} }
EventMsg::McpStartupUpdate(update) => { EventMsg::McpStartupUpdate(update) => {
let (status, error) = match update.status { let (status, error, failure_reason) = match update.status {
codex_protocol::protocol::McpStartupStatus::Starting => { codex_protocol::protocol::McpStartupStatus::Starting => {
(McpServerStartupState::Starting, None) (McpServerStartupState::Starting, None, None)
} }
codex_protocol::protocol::McpStartupStatus::Ready => { codex_protocol::protocol::McpStartupStatus::Ready => {
(McpServerStartupState::Ready, None) (McpServerStartupState::Ready, None, None)
}
codex_protocol::protocol::McpStartupStatus::Failed { error } => {
(McpServerStartupState::Failed, Some(error))
} }
codex_protocol::protocol::McpStartupStatus::Failed { error, reason } => (
McpServerStartupState::Failed,
Some(error),
reason.map(Into::into),
),
codex_protocol::protocol::McpStartupStatus::Cancelled => { codex_protocol::protocol::McpStartupStatus::Cancelled => {
(McpServerStartupState::Cancelled, None) (McpServerStartupState::Cancelled, None, None)
} }
}; };
let notification = McpServerStatusUpdatedNotification { let notification = McpServerStatusUpdatedNotification {
@@ -219,6 +221,7 @@ pub(crate) async fn apply_bespoke_event_handling(
name: update.server, name: update.server,
status, status,
error, error,
failure_reason,
}; };
outgoing outgoing
.send_server_notification(ServerNotification::McpServerStatusUpdated(notification)) .send_server_notification(ServerNotification::McpServerStatusUpdated(notification))
@@ -945,6 +945,7 @@ async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result<
name: "optional_broken".to_string(), name: "optional_broken".to_string(),
status: McpServerStartupState::Starting, status: McpServerStartupState::Starting,
error: None, error: None,
failure_reason: None,
} }
); );
@@ -977,6 +978,7 @@ async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result<
assert_eq!(failed.thread_id, Some(start_response.thread.id)); assert_eq!(failed.thread_id, Some(start_response.thread.id));
assert_eq!(failed.name, "optional_broken"); assert_eq!(failed.name, "optional_broken");
assert_eq!(failed.status, McpServerStartupState::Failed); assert_eq!(failed.status, McpServerStartupState::Failed);
assert_eq!(failed.failure_reason, None);
assert!( assert!(
failed failed
.error .error
+3 -3
View File
@@ -573,7 +573,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
.map(|(name, cfg)| { .map(|(name, cfg)| {
let auth_status = auth_statuses let auth_status = auth_statuses
.get(name.as_str()) .get(name.as_str())
.map(|entry| entry.auth_status) .map(|entry| McpAuthStatus::from(entry.auth_state))
.unwrap_or(McpAuthStatus::Unsupported); .unwrap_or(McpAuthStatus::Unsupported);
let transport = match &cfg.transport { let transport = match &cfg.transport {
McpServerTransportConfig::Stdio { McpServerTransportConfig::Stdio {
@@ -657,7 +657,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
let status = format_mcp_status(cfg); let status = format_mcp_status(cfg);
let auth_status = auth_statuses let auth_status = auth_statuses
.get(name.as_str()) .get(name.as_str())
.map(|entry| entry.auth_status) .map(|entry| McpAuthStatus::from(entry.auth_state))
.unwrap_or(McpAuthStatus::Unsupported) .unwrap_or(McpAuthStatus::Unsupported)
.to_string(); .to_string();
stdio_rows.push([ stdio_rows.push([
@@ -678,7 +678,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
let status = format_mcp_status(cfg); let status = format_mcp_status(cfg);
let auth_status = auth_statuses let auth_status = auth_statuses
.get(name.as_str()) .get(name.as_str())
.map(|entry| entry.auth_status) .map(|entry| McpAuthStatus::from(entry.auth_state))
.unwrap_or(McpAuthStatus::Unsupported) .unwrap_or(McpAuthStatus::Unsupported)
.to_string(); .to_string();
let bearer_token_display = let bearer_token_display =
+34 -5
View File
@@ -55,9 +55,12 @@ use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::McpStartupCompleteEvent; use codex_protocol::protocol::McpStartupCompleteEvent;
use codex_protocol::protocol::McpStartupFailure; use codex_protocol::protocol::McpStartupFailure;
use codex_protocol::protocol::McpStartupFailureReason;
use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupStatus;
use codex_protocol::protocol::McpStartupUpdateEvent; use codex_protocol::protocol::McpStartupUpdateEvent;
use codex_rmcp_client::ElicitationResponse; use codex_rmcp_client::ElicitationResponse;
use codex_rmcp_client::McpAuthState;
use codex_rmcp_client::McpLoginRequirement;
use rmcp::model::ElicitationCapability; use rmcp::model::ElicitationCapability;
use rmcp::model::ListResourceTemplatesResult; use rmcp::model::ListResourceTemplatesResult;
use rmcp::model::ListResourcesResult; use rmcp::model::ListResourcesResult;
@@ -228,12 +231,16 @@ impl McpConnectionManager {
Ok(_) => McpStartupStatus::Ready, Ok(_) => McpStartupStatus::Ready,
Err(StartupOutcomeError::Cancelled) => McpStartupStatus::Cancelled, Err(StartupOutcomeError::Cancelled) => McpStartupStatus::Cancelled,
Err(error) => { Err(error) => {
let reason = mcp_startup_failure_reason(auth_entry.as_ref(), error);
let error_str = mcp_init_error_display( let error_str = mcp_init_error_display(
server_name.as_str(), server_name.as_str(),
auth_entry.as_ref(), auth_entry.as_ref(),
error, error,
); );
McpStartupStatus::Failed { error: error_str } McpStartupStatus::Failed {
error: error_str,
reason,
}
} }
}; };
@@ -266,7 +273,7 @@ impl McpConnectionManager {
match outcome { match outcome {
Ok(_) => summary.ready.push(server_name), Ok(_) => summary.ready.push(server_name),
Err(StartupOutcomeError::Cancelled) => summary.cancelled.push(server_name), Err(StartupOutcomeError::Cancelled) => summary.cancelled.push(server_name),
Err(StartupOutcomeError::Failed { error }) => { Err(StartupOutcomeError::Failed { error, .. }) => {
summary.failed.push(McpStartupFailure { summary.failed.push(McpStartupFailure {
server: server_name, server: server_name,
error, error,
@@ -909,6 +916,28 @@ async fn emit_update(
.await .await
} }
fn mcp_startup_failure_reason(
entry: Option<&McpAuthStatusEntry>,
error: &StartupOutcomeError,
) -> Option<McpStartupFailureReason> {
if !error.is_authentication_required() {
return None;
}
match entry.map(|entry| entry.auth_state) {
Some(McpAuthState::LoggedOut(McpLoginRequirement::Reauthentication)) => {
Some(McpStartupFailureReason::ReauthenticationRequired)
}
Some(
McpAuthState::Unsupported
| McpAuthState::LoggedOut(McpLoginRequirement::Login)
| McpAuthState::BearerToken
| McpAuthState::OAuth,
)
| None => None,
}
}
fn mcp_init_error_display( fn mcp_init_error_display(
server_name: &str, server_name: &str,
entry: Option<&McpAuthStatusEntry>, entry: Option<&McpAuthStatusEntry>,
@@ -955,20 +984,20 @@ fn mcp_init_error_display(
fn startup_outcome_error_message(error: StartupOutcomeError) -> String { fn startup_outcome_error_message(error: StartupOutcomeError) -> String {
match error { match error {
StartupOutcomeError::Cancelled => "MCP startup cancelled".to_string(), StartupOutcomeError::Cancelled => "MCP startup cancelled".to_string(),
StartupOutcomeError::Failed { error } => error, StartupOutcomeError::Failed { error, .. } => error,
} }
} }
fn is_mcp_client_auth_required_error(error: &StartupOutcomeError) -> bool { fn is_mcp_client_auth_required_error(error: &StartupOutcomeError) -> bool {
match error { match error {
StartupOutcomeError::Failed { error } => error.contains("Auth required"), StartupOutcomeError::Failed { error, .. } => error.contains("Auth required"),
_ => false, _ => false,
} }
} }
fn is_mcp_client_startup_timeout_error(error: &StartupOutcomeError) -> bool { fn is_mcp_client_startup_timeout_error(error: &StartupOutcomeError) -> bool {
match error { match error {
StartupOutcomeError::Failed { error } => { StartupOutcomeError::Failed { error, .. } => {
error.contains("request timed out") error.contains("request timed out")
|| error.contains("timed out handshaking with MCP server") || error.contains("timed out handshaking with MCP server")
} }
@@ -25,7 +25,6 @@ use codex_protocol::ToolName;
use codex_protocol::mcp::McpServerInfo; use codex_protocol::mcp::McpServerInfo;
use codex_protocol::models::PermissionProfile; use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::GranularApprovalConfig;
use codex_protocol::protocol::McpAuthStatus;
use codex_rmcp_client::InProcessTransportFactory; use codex_rmcp_client::InProcessTransportFactory;
use codex_rmcp_client::RmcpClient; use codex_rmcp_client::RmcpClient;
use futures::FutureExt; use futures::FutureExt;
@@ -927,6 +926,7 @@ async fn list_all_tools_uses_shared_codex_apps_cache_when_client_startup_fails()
let failed_client = futures::future::ready::<Result<ManagedClient, StartupOutcomeError>>(Err( let failed_client = futures::future::ready::<Result<ManagedClient, StartupOutcomeError>>(Err(
StartupOutcomeError::Failed { StartupOutcomeError::Failed {
error: "startup failed".to_string(), error: "startup failed".to_string(),
is_authentication_required: false,
}, },
)) ))
.boxed() .boxed()
@@ -1236,7 +1236,7 @@ fn mcp_init_error_display_prompts_for_github_pat() {
oauth_resource: None, oauth_resource: None,
tools: HashMap::new(), tools: HashMap::new(),
}), }),
auth_status: McpAuthStatus::Unsupported, auth_state: McpAuthState::Unsupported,
}; };
let err: StartupOutcomeError = anyhow::anyhow!("OAuth is unsupported").into(); let err: StartupOutcomeError = anyhow::anyhow!("OAuth is unsupported").into();
@@ -1263,6 +1263,50 @@ fn mcp_init_error_display_prompts_for_login_when_auth_required() {
assert_eq!(expected, display); assert_eq!(expected, display);
} }
#[test]
fn mcp_startup_failure_reason_requires_existing_oauth_and_auth_failure() {
for (auth_state, is_authentication_required, expected) in [
(
Some(McpAuthState::LoggedOut(
McpLoginRequirement::Reauthentication,
)),
true,
Some(McpStartupFailureReason::ReauthenticationRequired),
),
(
Some(McpAuthState::LoggedOut(
McpLoginRequirement::Reauthentication,
)),
false,
None,
),
(
Some(McpAuthState::LoggedOut(McpLoginRequirement::Login)),
true,
None,
),
(Some(McpAuthState::Unsupported), true, None),
(Some(McpAuthState::BearerToken), true, None),
(Some(McpAuthState::OAuth), true, None),
(None, true, None),
] {
let entry = auth_state.map(|auth_state| McpAuthStatusEntry {
config: None,
auth_state,
});
let error = StartupOutcomeError::Failed {
error: "startup failed".to_string(),
is_authentication_required,
};
assert_eq!(
mcp_startup_failure_reason(entry.as_ref(), &error),
expected,
"auth_state={auth_state:?}, is_authentication_required={is_authentication_required}"
);
}
}
#[test] #[test]
fn mcp_init_error_display_reports_generic_errors() { fn mcp_init_error_display_reports_generic_errors() {
let server_name = "custom"; let server_name = "custom";
@@ -1290,7 +1334,7 @@ fn mcp_init_error_display_reports_generic_errors() {
oauth_resource: None, oauth_resource: None,
tools: HashMap::new(), tools: HashMap::new(),
}), }),
auth_status: McpAuthStatus::Unsupported, auth_state: McpAuthState::Unsupported,
}; };
let err: StartupOutcomeError = anyhow::anyhow!("boom").into(); let err: StartupOutcomeError = anyhow::anyhow!("boom").into();
+10 -13
View File
@@ -9,7 +9,7 @@ use codex_config::types::AuthKeyringBackendKind;
use codex_config::types::OAuthCredentialsStoreMode; use codex_config::types::OAuthCredentialsStoreMode;
use codex_exec_server::HttpClient; use codex_exec_server::HttpClient;
use codex_login::CodexAuth; use codex_login::CodexAuth;
use codex_protocol::protocol::McpAuthStatus; use codex_rmcp_client::McpAuthState;
use codex_rmcp_client::OAuthProviderError; use codex_rmcp_client::OAuthProviderError;
use codex_rmcp_client::determine_streamable_http_auth_status; use codex_rmcp_client::determine_streamable_http_auth_status;
use codex_rmcp_client::determine_streamable_http_auth_status_with_http_client; use codex_rmcp_client::determine_streamable_http_auth_status_with_http_client;
@@ -54,7 +54,7 @@ pub struct ResolvedMcpOAuthScopes {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct McpAuthStatusEntry { pub struct McpAuthStatusEntry {
pub config: Option<McpServerConfig>, pub config: Option<McpServerConfig>,
pub auth_status: McpAuthStatus, pub auth_state: McpAuthState,
} }
pub async fn oauth_login_support(transport: &McpServerTransportConfig) -> McpOAuthLoginSupport { pub async fn oauth_login_support(transport: &McpServerTransportConfig) -> McpOAuthLoginSupport {
@@ -208,7 +208,7 @@ where
) )
}); });
async move { async move {
let auth_status = match config.as_ref() { let auth_state = match config.as_ref() {
Some(config) => { Some(config) => {
match compute_auth_status( match compute_auth_status(
&name, &name,
@@ -225,16 +225,13 @@ where
warn!( warn!(
"failed to determine auth status for MCP server `{name}`: {error:?}" "failed to determine auth status for MCP server `{name}`: {error:?}"
); );
McpAuthStatus::Unsupported McpAuthState::Unsupported
} }
} }
} }
None => McpAuthStatus::Unsupported, None => McpAuthState::Unsupported,
};
let entry = McpAuthStatusEntry {
config,
auth_status,
}; };
let entry = McpAuthStatusEntry { config, auth_state };
(name, entry) (name, entry)
} }
}); });
@@ -249,17 +246,17 @@ async fn compute_auth_status(
keyring_backend_kind: AuthKeyringBackendKind, keyring_backend_kind: AuthKeyringBackendKind,
has_runtime_auth: bool, has_runtime_auth: bool,
runtime_context: &McpRuntimeContext, runtime_context: &McpRuntimeContext,
) -> Result<McpAuthStatus> { ) -> Result<McpAuthState> {
if !config.enabled { if !config.enabled {
return Ok(McpAuthStatus::Unsupported); return Ok(McpAuthState::Unsupported);
} }
if has_runtime_auth { if has_runtime_auth {
return Ok(McpAuthStatus::BearerToken); return Ok(McpAuthState::BearerToken);
} }
match &config.transport { match &config.transport {
McpServerTransportConfig::Stdio { .. } => Ok(McpAuthStatus::Unsupported), McpServerTransportConfig::Stdio { .. } => Ok(McpAuthState::Unsupported),
McpServerTransportConfig::StreamableHttp { McpServerTransportConfig::StreamableHttp {
url, url,
bearer_token_env_var, bearer_token_env_var,
+1 -1
View File
@@ -567,7 +567,7 @@ fn auth_statuses_from_entries(
) -> HashMap<String, McpAuthStatus> { ) -> HashMap<String, McpAuthStatus> {
auth_status_entries auth_status_entries
.iter() .iter()
.map(|(name, entry)| (name.clone(), entry.auth_status)) .map(|(name, entry)| (name.clone(), McpAuthStatus::from(entry.auth_state)))
.collect::<HashMap<_, _>>() .collect::<HashMap<_, _>>()
} }
+39 -1
View File
@@ -63,6 +63,7 @@ use rmcp::model::InitializeRequestParams;
use rmcp::model::JsonObject; use rmcp::model::JsonObject;
use rmcp::model::ProtocolVersion; use rmcp::model::ProtocolVersion;
use rmcp::model::Tool as RmcpTool; use rmcp::model::Tool as RmcpTool;
use rmcp::transport::auth::AuthError;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::Instrument; use tracing::Instrument;
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 // We can't store the original error here because anyhow::Error doesn't implement
// `Clone`. // `Clone`.
#[error("MCP startup failed: {error}")] #[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 { impl From<anyhow::Error> for StartupOutcomeError {
fn from(error: anyhow::Error) -> Self { 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 { Self::Failed {
error: error.to_string(), error: error.to_string(),
is_authentication_required,
} }
} }
} }
@@ -765,6 +792,17 @@ mod tests {
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use rmcp::model::JsonObject; use rmcp::model::JsonObject;
use rmcp::model::Meta; 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] #[test]
fn mcp_initialize_advertises_openai_form_only_when_supported() { fn mcp_initialize_advertises_openai_form_only_when_supported() {
@@ -5,6 +5,7 @@ use std::collections::HashMap;
fn stdio_server(command: &str, args: &[&str]) -> McpServerConfig { fn stdio_server(command: &str, args: &[&str]) -> McpServerConfig {
McpServerConfig { McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::Stdio { transport: McpServerTransportConfig::Stdio {
command: command.to_string(), command: command.to_string(),
args: args.iter().map(ToString::to_string).collect(), args: args.iter().map(ToString::to_string).collect(),
+18 -1
View File
@@ -3621,10 +3621,22 @@ pub struct McpStartupUpdateEvent {
pub enum McpStartupStatus { pub enum McpStartupStatus {
Starting, Starting,
Ready, Ready,
Failed { error: String }, Failed {
error: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
reason: Option<McpStartupFailureReason>,
},
Cancelled, Cancelled,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
pub enum McpStartupFailureReason {
ReauthenticationRequired,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, Default)] #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, Default)]
pub struct McpStartupCompleteEvent { pub struct McpStartupCompleteEvent {
pub ready: Vec<String>, pub ready: Vec<String>,
@@ -5749,6 +5761,7 @@ mod tests {
server: "srv".to_string(), server: "srv".to_string(),
status: McpStartupStatus::Failed { status: McpStartupStatus::Failed {
error: "boom".to_string(), error: "boom".to_string(),
reason: Some(McpStartupFailureReason::ReauthenticationRequired),
}, },
}), }),
}; };
@@ -5758,6 +5771,10 @@ mod tests {
assert_eq!(value["msg"]["server"], "srv"); assert_eq!(value["msg"]["server"], "srv");
assert_eq!(value["msg"]["status"]["state"], "failed"); assert_eq!(value["msg"]["status"]["state"], "failed");
assert_eq!(value["msg"]["status"]["error"], "boom"); assert_eq!(value["msg"]["status"]["error"], "boom");
assert_eq!(
value["msg"]["status"]["reason"],
"reauthentication_required"
);
Ok(()) Ok(())
} }
+40 -13
View File
@@ -28,8 +28,33 @@ pub struct StreamableHttpOAuthDiscovery {
pub scopes_supported: Option<Vec<String>>, pub scopes_supported: Option<Vec<String>>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpLoginRequirement {
Login,
Reauthentication,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpAuthState {
Unsupported,
LoggedOut(McpLoginRequirement),
BearerToken,
OAuth,
}
impl From<McpAuthState> for McpAuthStatus {
fn from(value: McpAuthState) -> Self {
match value {
McpAuthState::Unsupported => Self::Unsupported,
McpAuthState::LoggedOut(_) => Self::NotLoggedIn,
McpAuthState::BearerToken => Self::BearerToken,
McpAuthState::OAuth => Self::OAuth,
}
}
}
enum AuthStatusCheck { enum AuthStatusCheck {
Complete(McpAuthStatus), Complete(McpAuthState),
Discover(HeaderMap), Discover(HeaderMap),
} }
@@ -42,7 +67,7 @@ pub async fn determine_streamable_http_auth_status(
env_http_headers: Option<HashMap<String, String>>, env_http_headers: Option<HashMap<String, String>>,
store_mode: OAuthCredentialsStoreMode, store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind, keyring_backend_kind: AuthKeyringBackendKind,
) -> Result<McpAuthStatus> { ) -> Result<McpAuthState> {
let default_headers = match auth_status_before_discovery( let default_headers = match auth_status_before_discovery(
server_name, server_name,
url, url,
@@ -75,7 +100,7 @@ pub async fn determine_streamable_http_auth_status_with_http_client(
store_mode: OAuthCredentialsStoreMode, store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind, keyring_backend_kind: AuthKeyringBackendKind,
http_client: Arc<dyn HttpClient>, http_client: Arc<dyn HttpClient>,
) -> Result<McpAuthStatus> { ) -> Result<McpAuthState> {
let default_headers = match auth_status_before_discovery( let default_headers = match auth_status_before_discovery(
server_name, server_name,
url, url,
@@ -110,20 +135,22 @@ fn auth_status_before_discovery(
keyring_backend_kind: AuthKeyringBackendKind, keyring_backend_kind: AuthKeyringBackendKind,
) -> Result<AuthStatusCheck> { ) -> Result<AuthStatusCheck> {
if bearer_token_env_var.is_some() { if bearer_token_env_var.is_some() {
return Ok(AuthStatusCheck::Complete(McpAuthStatus::BearerToken)); return Ok(AuthStatusCheck::Complete(McpAuthState::BearerToken));
} }
let default_headers = build_default_headers(http_headers, env_http_headers)?; let default_headers = build_default_headers(http_headers, env_http_headers)?;
if default_headers.contains_key(AUTHORIZATION) { if default_headers.contains_key(AUTHORIZATION) {
return Ok(AuthStatusCheck::Complete(McpAuthStatus::BearerToken)); return Ok(AuthStatusCheck::Complete(McpAuthState::BearerToken));
} }
match oauth_token_status(server_name, url, store_mode, keyring_backend_kind)? { match oauth_token_status(server_name, url, store_mode, keyring_backend_kind)? {
StoredOAuthTokenStatus::Usable => { StoredOAuthTokenStatus::Usable => {
return Ok(AuthStatusCheck::Complete(McpAuthStatus::OAuth)); return Ok(AuthStatusCheck::Complete(McpAuthState::OAuth));
} }
StoredOAuthTokenStatus::AuthorizationRequired => { StoredOAuthTokenStatus::AuthorizationRequired => {
return Ok(AuthStatusCheck::Complete(McpAuthStatus::NotLoggedIn)); return Ok(AuthStatusCheck::Complete(McpAuthState::LoggedOut(
McpLoginRequirement::Reauthentication,
)));
} }
StoredOAuthTokenStatus::Missing => {} StoredOAuthTokenStatus::Missing => {}
} }
@@ -135,15 +162,15 @@ fn determine_auth_status_from_discovery(
server_name: &str, server_name: &str,
url: &str, url: &str,
discovery: Result<Option<StreamableHttpOAuthDiscovery>>, discovery: Result<Option<StreamableHttpOAuthDiscovery>>,
) -> Result<McpAuthStatus> { ) -> Result<McpAuthState> {
match discovery { match discovery {
Ok(Some(_)) => Ok(McpAuthStatus::NotLoggedIn), Ok(Some(_)) => Ok(McpAuthState::LoggedOut(McpLoginRequirement::Login)),
Ok(None) => Ok(McpAuthStatus::Unsupported), Ok(None) => Ok(McpAuthState::Unsupported),
Err(error) => { Err(error) => {
debug!( debug!(
"failed to detect OAuth support for MCP server `{server_name}` at {url}: {error:?}" "failed to detect OAuth support for MCP server `{server_name}` at {url}: {error:?}"
); );
Ok(McpAuthStatus::Unsupported) Ok(McpAuthState::Unsupported)
} }
} }
} }
@@ -336,7 +363,7 @@ mod tests {
.await .await
.expect("status should compute"); .expect("status should compute");
assert_eq!(status, McpAuthStatus::BearerToken); assert_eq!(status, McpAuthState::BearerToken);
} }
#[tokio::test] #[tokio::test]
@@ -358,7 +385,7 @@ mod tests {
.await .await
.expect("status should compute"); .expect("status should compute");
assert_eq!(status, McpAuthStatus::BearerToken); assert_eq!(status, McpAuthState::BearerToken);
} }
#[tokio::test] #[tokio::test]
+2
View File
@@ -12,6 +12,8 @@ mod rmcp_client;
mod stdio_server_launcher; mod stdio_server_launcher;
mod utils; mod utils;
pub use auth_status::McpAuthState;
pub use auth_status::McpLoginRequirement;
pub use auth_status::StreamableHttpOAuthDiscovery; pub use auth_status::StreamableHttpOAuthDiscovery;
pub use auth_status::determine_streamable_http_auth_status; pub use auth_status::determine_streamable_http_auth_status;
pub use auth_status::determine_streamable_http_auth_status_with_http_client; pub use auth_status::determine_streamable_http_auth_status_with_http_client;
@@ -7,7 +7,8 @@ use std::time::UNIX_EPOCH;
use codex_config::types::AuthKeyringBackendKind; use codex_config::types::AuthKeyringBackendKind;
use codex_config::types::OAuthCredentialsStoreMode; use codex_config::types::OAuthCredentialsStoreMode;
use codex_exec_server::Environment; use codex_exec_server::Environment;
use codex_rmcp_client::McpAuthStatus; use codex_rmcp_client::McpAuthState;
use codex_rmcp_client::McpLoginRequirement;
use codex_rmcp_client::RmcpClient; use codex_rmcp_client::RmcpClient;
use codex_rmcp_client::StoredOAuthTokens; use codex_rmcp_client::StoredOAuthTokens;
use codex_rmcp_client::WrappedOAuthTokenResponse; use codex_rmcp_client::WrappedOAuthTokenResponse;
@@ -146,6 +147,21 @@ async fn reports_auth_status_for_persisted_credentials() -> anyhow::Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[ignore = "spawned by reports_auth_status_for_persisted_credentials"] #[ignore = "spawned by reports_auth_status_for_persisted_credentials"]
async fn persisted_credentials_auth_status_child() -> anyhow::Result<()> { async fn persisted_credentials_auth_status_child() -> anyhow::Result<()> {
let first_login_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/oauth-authorization-server/mcp"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"authorization_endpoint": format!("{}/oauth/authorize", first_login_server.uri()),
"token_endpoint": format!("{}/oauth/token", first_login_server.uri()),
})))
.expect(1)
.mount(&first_login_server)
.await;
let status = auth_status(&format!("{}/mcp", first_login_server.uri())).await?;
assert_eq!(status, McpAuthState::LoggedOut(McpLoginRequirement::Login));
first_login_server.verify().await;
let response = OAuthTokenResponse::new( let response = OAuthTokenResponse::new(
AccessToken::new(EXPIRED_ACCESS_TOKEN.to_string()), AccessToken::new(EXPIRED_ACCESS_TOKEN.to_string()),
BasicTokenType::Bearer, BasicTokenType::Bearer,
@@ -166,7 +182,10 @@ async fn persisted_credentials_auth_status_child() -> anyhow::Result<()> {
)?; )?;
let status = auth_status(UNREFRESHABLE_SERVER_URL).await?; let status = auth_status(UNREFRESHABLE_SERVER_URL).await?;
assert_eq!(status, McpAuthStatus::NotLoggedIn); assert_eq!(
status,
McpAuthState::LoggedOut(McpLoginRequirement::Reauthentication)
);
let response = OAuthTokenResponse::new( let response = OAuthTokenResponse::new(
AccessToken::new("unexpired-access-token".to_string()), AccessToken::new("unexpired-access-token".to_string()),
@@ -192,7 +211,7 @@ async fn persisted_credentials_auth_status_child() -> anyhow::Result<()> {
)?; )?;
let status = auth_status(UNEXPIRED_SERVER_URL).await?; let status = auth_status(UNEXPIRED_SERVER_URL).await?;
assert_eq!(status, McpAuthStatus::OAuth); assert_eq!(status, McpAuthState::OAuth);
let mut response = OAuthTokenResponse::new( let mut response = OAuthTokenResponse::new(
AccessToken::new(EXPIRED_ACCESS_TOKEN.to_string()), AccessToken::new(EXPIRED_ACCESS_TOKEN.to_string()),
@@ -215,11 +234,11 @@ async fn persisted_credentials_auth_status_child() -> anyhow::Result<()> {
)?; )?;
let status = auth_status(REFRESHABLE_SERVER_URL).await?; let status = auth_status(REFRESHABLE_SERVER_URL).await?;
assert_eq!(status, McpAuthStatus::OAuth); assert_eq!(status, McpAuthState::OAuth);
Ok(()) Ok(())
} }
async fn auth_status(server_url: &str) -> anyhow::Result<McpAuthStatus> { async fn auth_status(server_url: &str) -> anyhow::Result<McpAuthState> {
determine_streamable_http_auth_status( determine_streamable_http_auth_status(
SERVER_NAME, SERVER_NAME,
server_url, server_url,
@@ -285,6 +285,7 @@ mod tests {
name: "sentry".to_string(), name: "sentry".to_string(),
status: McpServerStartupState::Failed, status: McpServerStartupState::Failed,
error: Some("sentry is not logged in".to_string()), error: Some("sentry is not logged in".to_string()),
failure_reason: None,
}); });
let target = server_notification_thread_target(&notification); let target = server_notification_thread_target(&notification);
@@ -300,6 +301,7 @@ mod tests {
name: "sentry".to_string(), name: "sentry".to_string(),
status: McpServerStartupState::Failed, status: McpServerStartupState::Failed,
error: Some("sentry is not logged in".to_string()), error: Some("sentry is not logged in".to_string()),
failure_reason: None,
}); });
let target = server_notification_thread_target(&notification); let target = server_notification_thread_target(&notification);
+3
View File
@@ -3496,6 +3496,7 @@ async fn primary_thread_ignores_child_mcp_startup_notifications() {
name: "sentry".to_string(), name: "sentry".to_string(),
status: McpServerStartupState::Failed, status: McpServerStartupState::Failed,
error: Some("sentry is not logged in".to_string()), error: Some("sentry is not logged in".to_string()),
failure_reason: None,
}), }),
), ),
) )
@@ -3567,6 +3568,7 @@ async fn app_scoped_mcp_startup_notifications_do_not_render_in_active_thread() {
name: "sentry".to_string(), name: "sentry".to_string(),
status: McpServerStartupState::Failed, status: McpServerStartupState::Failed,
error: Some("sentry is not logged in".to_string()), error: Some("sentry is not logged in".to_string()),
failure_reason: None,
}), }),
), ),
) )
@@ -3631,6 +3633,7 @@ async fn active_side_thread_renders_live_mcp_startup_notifications() {
status, status,
error: matches!(status, McpServerStartupState::Failed) error: matches!(status, McpServerStartupState::Failed)
.then(|| "sentry is not logged in".to_string()), .then(|| "sentry is not logged in".to_string()),
failure_reason: None,
}), }),
), ),
) )
+1
View File
@@ -619,6 +619,7 @@ mod tests {
name: "sentry".to_string(), name: "sentry".to_string(),
status: codex_app_server_protocol::McpServerStartupState::Failed, status: codex_app_server_protocol::McpServerStartupState::Failed,
error: Some("sentry is not logged in".to_string()), error: Some("sentry is not logged in".to_string()),
failure_reason: None,
}, },
); );
let mut store = ThreadEventStore::new(/*capacity*/ 8); let mut store = ThreadEventStore::new(/*capacity*/ 8);
@@ -8,6 +8,7 @@ fn notify_mcp_status(chat: &mut ChatWidget, name: &str, status: McpServerStartup
name: name.to_string(), name: name.to_string(),
status, status,
error: None, error: None,
failure_reason: None,
}), }),
/*replay_kind*/ None, /*replay_kind*/ None,
); );
@@ -20,6 +21,7 @@ fn notify_mcp_status_error(chat: &mut ChatWidget, name: &str, error: &str) {
name: name.to_string(), name: name.to_string(),
status: McpServerStartupState::Failed, status: McpServerStartupState::Failed,
error: Some(error.to_string()), error: Some(error.to_string()),
failure_reason: None,
}), }),
/*replay_kind*/ None, /*replay_kind*/ None,
); );
@@ -51,6 +53,7 @@ async fn mcp_startup_ignores_status_for_other_thread() {
status, status,
error: matches!(status, McpServerStartupState::Failed) error: matches!(status, McpServerStartupState::Failed)
.then(|| "sentry is not logged in".to_string()), .then(|| "sentry is not logged in".to_string()),
failure_reason: None,
}), }),
/*replay_kind*/ None, /*replay_kind*/ None,
); );