MCP OAuth login retries after explicit access_denied when using discovered scopes

Open 💬 1 comment Opened Aug 20, 2026 by scschneider44

What version of Codex CLI is running?

codex-cli 0.148.0

What issue are you seeing?

When codex mcp login uses scopes discovered from OAuth metadata, clicking Cancel on the provider’s consent page launches a second authorization flow.

The authorization server correctly redirects to Codex’s localhost callback:

http://127.0.0.1:<port>/callback/<id>?error=access_denied&error_description=The+user+denied+access&state=<state>

Codex receives and parses this callback. Its local tiny-http server responds:

HTTP 400 Bad Request

OAuth provider returned `access_denied`: The+user+denied+access

Codex then opens a second authorization page. Clicking Cancel a second time finally terminates codex mcp login.

I reproduced this with only one Codex process running.

What steps can reproduce the bug?

  1. Configure a streamable HTTP MCP server protected by OAuth.
  2. Have its OAuth metadata advertise supported scopes.
  3. Add the server to Codex.
  4. Run:

``shell
codex mcp login <server>
``

  1. Click Cancel on the provider’s consent page.
  2. Observe that Codex opens another authorization page.
  3. Click Cancel again.
  4. Observe that the second denial finally terminates the command.

What is the expected behavior?

The first access_denied callback should immediately terminate the OAuth login.

An explicit user denial should not trigger another authorization prompt.

Additional information

The scope fallback introduced in #14419 is intended to retry when a provider rejects discovered scopes:

https://github.com/openai/codex/blob/3ba0f711642a888aec92a611a3f3b2211157ff89/codex-rs/cli/src/mcp_cmd.rs#L256-L313

However, the retry classifier matches every OAuthProviderError when scopes were discovered:

https://github.com/openai/codex/blob/3ba0f711642a888aec92a611a3f3b2211157ff89/codex-rs/codex-mcp/src/mcp/auth.rs#L158-L161

pub fn should_retry_without_scopes(
    scopes: &ResolvedMcpOAuthScopes,
    error: &anyhow::Error,
) -> bool {
    scopes.source == McpOAuthScopesSource::Discovered
        && error.downcast_ref::<OAuthProviderError>().is_some()
}

Because access_denied is represented as an OAuthProviderError, an explicit cancellation incorrectly activates the no-scope fallback.

The second cancellation works because the fallback flow is returned directly and is not retried again.

Suggested fix

Only retry without discovered scopes for provider errors that actually indicate a scope rejection, such as invalid_scope.

Errors representing explicit user intent, especially access_denied, should be returned immediately.

Suggested regression coverage:

  • invalid_scope with discovered scopes retries once without scopes.
  • access_denied does not retry.
  • Other unrelated OAuth provider errors do not retry.

View original on GitHub ↗

1 Comment

KevinAndrewDong · 2 days ago

Confirmed locally that the retry classifier is the cause.

The discovered-scope fallback currently retries for every OAuthProviderError. A minimal fix is to expose the provider error code and retry only when both conditions hold:

  • the scopes came from discovery
  • the provider error code is exactly invalid_scope

The relevant code changes are:

  1. Add a borrowed error-code accessor in codex-rs/rmcp-client/src/perform_oauth_login.rs:

pub fn error_code(&self) -> Option<&str> {
self.error.as_deref()
}

  1. Narrow codex-rs/codex-mcp/src/mcp/auth.rs:

scopes.source == McpOAuthScopesSource::Discovered
&& error
.downcast_ref::<OAuthProviderError>()
.and_then(OAuthProviderError::error_code)
== Some("invalid_scope")

This preserves the legacy no-scope fallback for an actual scope rejection, while access_denied and unrelated errors such as server_error terminate the login immediately.

I validated the change with regression cases for invalid_scope, access_denied, and server_error. The codex-mcp crate test suite passed 192/192, and the perform_oauth_login focused tests passed 19/19.