MCP OAuth login retries after explicit access_denied when using discovered scopes
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?
- Configure a streamable HTTP MCP server protected by OAuth.
- Have its OAuth metadata advertise supported scopes.
- Add the server to Codex.
- Run:
``shell``
codex mcp login <server>
- Click Cancel on the provider’s consent page.
- Observe that Codex opens another authorization page.
- Click Cancel again.
- 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:
However, the retry classifier matches every OAuthProviderError when scopes were discovered:
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_scopewith discovered scopes retries once without scopes.access_denieddoes not retry.- Other unrelated OAuth provider errors do not retry.
1 Comment
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 relevant code changes are:
pub fn error_code(&self) -> Option<&str> {
self.error.as_deref()
}
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.