use scopes_supported for OAuth when present on MCP servers (#14419)

Fixes [#8889](https://github.com/openai/codex/issues/8889).

## Summary
- Discover and use advertised MCP OAuth `scopes_supported` when no
explicit or configured scopes are present.
- Apply the same scope precedence across `mcp add`, `mcp login`, skill
dependency auto-login, and app-server MCP OAuth login.
- Keep discovered scopes ephemeral and non-persistent.
- Retry once without scopes for CLI and skill auto-login flows if the
OAuth provider rejects discovered scopes.

## Motivation
Some MCP servers advertise the scopes they expect clients to request
during OAuth, but Codex was ignoring that metadata and typically
starting OAuth with no scopes unless the user manually passed `--scopes`
or configured `server.scopes`.

That made compliant MCP servers harder to use out of the box and is the
behavior described in
[#8889](https://github.com/openai/codex/issues/8889).

This change also brings our behavior in line with the MCP authorization
spec's scope selection guidance:

https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-selection-strategy

## Behavior
Scope selection now follows this order everywhere:
1. Explicit request scopes / CLI `--scopes`
2. Configured `server.scopes`
3. Discovered `scopes_supported`
4. Legacy empty-scope behavior

Compatibility notes:
- Existing working setups keep the same behavior because explicit and
configured scopes still win.
- Discovered scopes are never written back into config or token storage.
- If discovery is missing, malformed, or empty, behavior falls back to
the previous empty-scope path.
- App-server login gets the same precedence rules, but does not add a
transparent retry path in this change.

## Implementation
- Extend streamable HTTP OAuth discovery to parse and normalize
`scopes_supported`.
- Add a shared MCP scope resolver in `core` so all login entrypoints use
the same precedence rules.
- Preserve provider callback errors from the OAuth flow so CLI/skill
flows can safely distinguish provider rejections from other failures.
- Reuse discovered scopes from the existing OAuth support check where
possible instead of persisting new config.
This commit is contained in:
jgershen-oai
2026-03-12 11:57:06 -07:00
committed by GitHub
parent fa26597689
commit 3e96c867fe
7 changed files with 522 additions and 38 deletions

View File

@@ -14,8 +14,12 @@ use codex_core::config::types::McpServerConfig;
use codex_core::config::types::McpServerTransportConfig;
use codex_core::mcp::McpManager;
use codex_core::mcp::auth::McpOAuthLoginSupport;
use codex_core::mcp::auth::ResolvedMcpOAuthScopes;
use codex_core::mcp::auth::compute_auth_statuses;
use codex_core::mcp::auth::discover_supported_scopes;
use codex_core::mcp::auth::oauth_login_support;
use codex_core::mcp::auth::resolve_oauth_scopes;
use codex_core::mcp::auth::should_retry_without_scopes;
use codex_core::plugins::PluginsManager;
use codex_protocol::protocol::McpAuthStatus;
use codex_rmcp_client::delete_oauth_tokens;
@@ -183,6 +187,54 @@ impl McpCli {
}
}
/// Preserve compatibility with servers that still expect the legacy empty-scope
/// OAuth request. If a discovered-scope request is rejected by the provider,
/// retry the login flow once without scopes.
#[allow(clippy::too_many_arguments)]
async fn perform_oauth_login_retry_without_scopes(
name: &str,
url: &str,
store_mode: codex_rmcp_client::OAuthCredentialsStoreMode,
http_headers: Option<HashMap<String, String>>,
env_http_headers: Option<HashMap<String, String>>,
resolved_scopes: &ResolvedMcpOAuthScopes,
oauth_resource: Option<&str>,
callback_port: Option<u16>,
callback_url: Option<&str>,
) -> Result<()> {
match perform_oauth_login(
name,
url,
store_mode,
http_headers.clone(),
env_http_headers.clone(),
&resolved_scopes.scopes,
oauth_resource,
callback_port,
callback_url,
)
.await
{
Ok(()) => Ok(()),
Err(err) if should_retry_without_scopes(resolved_scopes, &err) => {
println!("OAuth provider rejected discovered scopes. Retrying without scopes…");
perform_oauth_login(
name,
url,
store_mode,
http_headers,
env_http_headers,
&[],
oauth_resource,
callback_port,
callback_url,
)
.await
}
Err(err) => Err(err),
}
}
async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<()> {
// Validate any provided overrides even though they are not currently applied.
let overrides = config_overrides
@@ -269,13 +321,15 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
match oauth_login_support(&transport).await {
McpOAuthLoginSupport::Supported(oauth_config) => {
println!("Detected OAuth support. Starting OAuth flow…");
perform_oauth_login(
let resolved_scopes =
resolve_oauth_scopes(None, None, oauth_config.discovered_scopes.clone());
perform_oauth_login_retry_without_scopes(
&name,
&oauth_config.url,
config.mcp_oauth_credentials_store_mode,
oauth_config.http_headers,
oauth_config.env_http_headers,
&Vec::new(),
&resolved_scopes,
None,
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
@@ -351,18 +405,22 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
_ => bail!("OAuth login is only supported for streamable HTTP servers."),
};
let mut scopes = scopes;
if scopes.is_empty() {
scopes = server.scopes.clone().unwrap_or_default();
}
let explicit_scopes = (!scopes.is_empty()).then_some(scopes);
let discovered_scopes = if explicit_scopes.is_none() && server.scopes.is_none() {
discover_supported_scopes(&server.transport).await
} else {
None
};
let resolved_scopes =
resolve_oauth_scopes(explicit_scopes, server.scopes.clone(), discovered_scopes);
perform_oauth_login(
perform_oauth_login_retry_without_scopes(
&name,
&url,
config.mcp_oauth_credentials_store_mode,
http_headers,
env_http_headers,
&scopes,
&resolved_scopes,
server.oauth_resource.as_deref(),
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),