Distinguish unknown MCP authentication status (#36045)

## Why

OAuth discovery failures do not establish that an MCP server lacks OAuth
support. Reporting those failures as `unsupported` conflates an inconclusive
check with a confirmed result.

## What changed

- Add an `unknown` MCP authentication status across the protocol, app server,
  CLI, and TUI.
- Preserve OAuth discovery errors so callers can report `unknown`, while
  retaining `unsupported` for servers known not to support OAuth.
- Document the distinction in the app server API.

## Testing

- Verify transient HTTP discovery errors are preserved.
- Verify `codex mcp list --json` reports `unknown` when discovery is rate
  limited.

GitOrigin-RevId: e4562985971606740538e542ec7eeee502111964
This commit is contained in:
jif
2026-07-29 23:24:43 +00:00
committed by copyberry
parent 6493417150
commit 7ec480dda5
16 changed files with 58 additions and 12 deletions
@@ -12777,6 +12777,7 @@
},
"McpAuthStatus": {
"enum": [
"unknown",
"unsupported",
"notLoggedIn",
"bearerToken",
@@ -9119,6 +9119,7 @@
},
"McpAuthStatus": {
"enum": [
"unknown",
"unsupported",
"notLoggedIn",
"bearerToken",
@@ -3,6 +3,7 @@
"definitions": {
"McpAuthStatus": {
"enum": [
"unknown",
"unsupported",
"notLoggedIn",
"bearerToken",
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type McpAuthStatus = "unsupported" | "notLoggedIn" | "bearerToken" | "oAuth";
export type McpAuthStatus = "unknown" | "unsupported" | "notLoggedIn" | "bearerToken" | "oAuth";
@@ -16,6 +16,7 @@ use ts_rs::TS;
v2_enum_from_core!(
pub enum McpAuthStatus from codex_protocol::protocol::McpAuthStatus {
Unknown,
Unsupported,
NotLoggedIn,
BearerToken,
@@ -2409,7 +2409,7 @@ fn mcp_server_status_serializes_absent_server_info_as_null() {
tools: HashMap::new(),
resources: Vec::new(),
resource_templates: Vec::new(),
auth_status: McpAuthStatus::Unsupported,
auth_status: McpAuthStatus::Unknown,
}],
next_cursor: None,
};
@@ -2423,7 +2423,7 @@ fn mcp_server_status_serializes_absent_server_info_as_null() {
"tools": {},
"resources": [],
"resourceTemplates": [],
"authStatus": "unsupported",
"authStatus": "unknown",
}],
"nextCursor": null,
})
+1 -1
View File
@@ -241,7 +241,7 @@ Example with notification opt-out:
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; pass `threadId` to resolve servers from that thread's selected plugins and executor, and receive an `authorization_url` followed by `mcpServer/oauthLogin/completed` once the browser flow finishes.
- `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental).
- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server.
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, auth status, server info, plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`.
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, auth status, server info, plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`. An `unknown` auth status means OAuth support could not be determined; `unsupported` means OAuth is known not to be supported.
- `mcpServer/resource/read` — read a resource from a configured MCP server by optional `threadId`, `server`, and `uri`, returning text/blob resource `contents`. If `threadId` is omitted, the server reads from the latest MCP config directly.
- `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result.
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`.
+30
View File
@@ -232,6 +232,36 @@ async fn list_discovers_local_oauth_server_through_environment_proxy() -> Result
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_reports_unknown_auth_status_when_oauth_discovery_is_rate_limited() -> Result<()> {
let codex_home = TempDir::new()?;
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/mcp"))
.respond_with(wiremock::ResponseTemplate::new(429))
.expect(1)
.mount(&server)
.await;
configure_http_oauth_server(codex_home.path(), &format!("{}/mcp", server.uri())).await?;
codex_command(codex_home.path())?
.env("NO_PROXY", "127.0.0.1,localhost")
.env("no_proxy", "127.0.0.1,localhost")
.args([
"-c",
"mcp_oauth_credentials_store=\"file\"",
"mcp",
"list",
"--json",
])
.assert()
.success()
.stdout(contains(r#""auth_status": "unknown""#));
server.verify().await;
Ok(())
}
#[cfg(target_os = "macos")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_with_macos_proxy_resolution_does_not_panic() -> Result<()> {
@@ -62,6 +62,7 @@ pub(super) fn mcp_startup_failure_reason(
}
Some(
McpAuthState::Unsupported
| McpAuthState::Unknown
| McpAuthState::LoggedOut(McpLoginRequirement::Login)
| McpAuthState::BearerToken
| McpAuthState::OAuth,
+1 -1
View File
@@ -190,7 +190,7 @@ where
Ok(status) => status,
Err(error) => {
warn!("failed to determine auth status for MCP server `{name}`: {error:?}");
McpAuthState::Unsupported
McpAuthState::Unknown
}
};
let entry = McpAuthStatusEntry {
+2
View File
@@ -3770,6 +3770,7 @@ pub struct McpStartupFailure {
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
pub enum McpAuthStatus {
Unknown,
Unsupported,
NotLoggedIn,
BearerToken,
@@ -3779,6 +3780,7 @@ pub enum McpAuthStatus {
impl fmt::Display for McpAuthStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text = match self {
McpAuthStatus::Unknown => "Unknown",
McpAuthStatus::Unsupported => "Unsupported",
McpAuthStatus::NotLoggedIn => "Not logged in",
McpAuthStatus::BearerToken => "Bearer token",
+11 -5
View File
@@ -49,6 +49,7 @@ pub enum McpLoginRequirement {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpAuthState {
Unsupported,
Unknown,
LoggedOut(McpLoginRequirement),
BearerToken,
OAuth,
@@ -58,6 +59,7 @@ impl From<McpAuthState> for McpAuthStatus {
fn from(value: McpAuthState) -> Self {
match value {
McpAuthState::Unsupported => Self::Unsupported,
McpAuthState::Unknown => Self::Unknown,
McpAuthState::LoggedOut(_) => Self::NotLoggedIn,
McpAuthState::BearerToken => Self::BearerToken,
McpAuthState::OAuth => Self::OAuth,
@@ -180,7 +182,7 @@ fn determine_auth_status_from_discovery(
debug!(
"failed to detect OAuth support for MCP server `{server_name}` at {url}: {error:?}"
);
Ok(McpAuthState::Unsupported)
Err(error)
}
}
}
@@ -576,7 +578,7 @@ mod tests {
}
#[tokio::test]
async fn oauth_discovery_preserves_transient_http_errors() {
async fn determine_auth_status_preserves_transient_http_errors() {
for status in [
StatusCode::REQUEST_TIMEOUT,
StatusCode::TOO_EARLY,
@@ -590,22 +592,26 @@ mod tests {
.mount(&server)
.await;
let error = discover_streamable_http_oauth(
let error = determine_streamable_http_auth_status(
"transient-http-error",
&format!("{}/mcp", server.uri()),
/*bearer_token_env_var*/ None,
/*http_headers*/ None,
/*env_http_headers*/ None,
OAuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
test_http_client(),
OAuthDiscoveryTimeout::LOCAL,
)
.await
.expect_err("transient OAuth discovery failures must not become anonymous access");
.expect_err("transient OAuth discovery failures must not become unsupported access");
assert!(
matches!(
error.downcast_ref::<AuthError>(),
Some(AuthError::MetadataError(reason)) if reason.contains(status.as_str())
),
"OAuth discovery must preserve HTTP {status}: {error:#}"
"auth-status discovery must preserve HTTP {status}: {error:#}"
);
server.verify().await;
}
@@ -1263,6 +1263,7 @@ pub(super) fn mcp_inventory_maps_from_statuses(statuses: Vec<McpServerStatus>) -
auth_statuses.insert(
server_name.clone(),
match status.auth_status {
codex_app_server_protocol::McpAuthStatus::Unknown => McpAuthStatus::Unknown,
codex_app_server_protocol::McpAuthStatus::Unsupported => McpAuthStatus::Unsupported,
codex_app_server_protocol::McpAuthStatus::NotLoggedIn => McpAuthStatus::NotLoggedIn,
codex_app_server_protocol::McpAuthStatus::BearerToken => McpAuthStatus::BearerToken,
+2
View File
@@ -16,6 +16,7 @@ impl HistoryCell for McpImageOutputCell {
}
fn mcp_auth_status_label(status: McpAuthStatus) -> &'static str {
match status {
McpAuthStatus::Unknown => "Unknown",
McpAuthStatus::Unsupported => "Unsupported",
McpAuthStatus::NotLoggedIn => "Not logged in",
McpAuthStatus::BearerToken => "Bearer token",
@@ -545,6 +546,7 @@ pub(crate) fn new_mcp_tools_output_from_statuses(
lines.push(header.into());
let auth_status = match status.auth_status {
codex_app_server_protocol::McpAuthStatus::Unknown => McpAuthStatus::Unknown,
codex_app_server_protocol::McpAuthStatus::Unsupported => McpAuthStatus::Unsupported,
codex_app_server_protocol::McpAuthStatus::NotLoggedIn => McpAuthStatus::NotLoggedIn,
codex_app_server_protocol::McpAuthStatus::BearerToken => McpAuthStatus::BearerToken,
@@ -7,5 +7,5 @@ expression: rendered
🔌 MCP Tools
• plugin_docs
• Auth: Unsupported
• Auth: Unknown
• Tools: lookup
+1 -1
View File
@@ -980,7 +980,7 @@ fn mcp_tools_output_from_statuses_renders_status_only_servers() {
)]),
resources: Vec::new(),
resource_templates: Vec::new(),
auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported,
auth_status: codex_app_server_protocol::McpAuthStatus::Unknown,
}];
let cell =