Advertise required scope in MCP WWW-Authenticate challenge

What does this MR do and why?

Add an RFC 6750 §3 scope attribute to the WWW-Authenticate challenge the MCP server returns on 401 responses, set to the resource's scope (mcp for /api/v4/mcp, mcp_orbit for /api/v4/orbit/mcp):

WWW-Authenticate: Bearer realm="GitLab", scope="mcp", resource_metadata="…/.well-known/oauth-protected-resource/api/v4/mcp"

The MCP authorization spec (revision 2025-11-25) makes this a SHOULD for MCP servers:

MCP servers SHOULD include a scope parameter in the WWW-Authenticate header as defined in RFC 6750 Section 3 to indicate the scopes required for accessing the resource. […] preventing clients from requesting excessive permissions.

We already advertise that revision in API::Mcp::Handlers::InitializeRequest::SUPPORTED_PROTOCOL_VERSIONS, so today we claim a revision whose scope-advertisement SHOULD we don't satisfy.

The same spec fixes the client's scope-selection order: the challenge's scope first, and only if it's absent, every scope in the protected resource metadata's scopes_supported. The challenge is therefore the one signal that lets a general-purpose client request exactly one scope with no GitLab-specific knowledge.

Why now, when the known client bug is fixed

The original trigger was a Claude Code bug (anthropics/claude-code#55954) that read scopes_supported from the authorization server metadata instead of the protected resource metadata, producing a 23-scope authorize URL including sudo, admin_mode and user:* that GitLab rejected with invalid_scope. That is fixed upstream and #603748 (closed) is closed.

Still worth doing:

  • It's a SHOULD for a spec revision we advertise support for.
  • Without it, a correctly-behaving client falls through to step 2 and requests our whole scopes_supported list — valid, but a noisy consent screen.
  • It removes the failure class rather than the one instance: any future client following the documented order gets the right scope for free.

Relationship to !219070 (merged)

!219070 (merged) already forces the granted scope server-side at the authorization endpoint, so the issued token is correct regardless of what a client requests. This is complementary — it fixes what clients request, so the authorize URL and consent screen show one scope instead of the full list. !219070 bounds the outcome; this bounds the request.

Also in this MR

The path→scope mapping moved to Gitlab::Auth::MCP_RESOURCE_SCOPES, read by both the challenge and Oauth::ProtectedResourceMetadataController, so the two cannot disagree about a resource's scope.

How to set up and validate locally

Restart the web server on this branch and inspect the challenge:

gdk restart rails-web

curl -sk -X POST https://gdk.test:3443/api/v4/mcp -D - -o /dev/null | grep -i www-authenticate
# => Bearer realm="GitLab", scope="mcp", resource_metadata="…/api/v4/mcp"

curl -sk -X POST https://gdk.test:3443/api/v4/orbit/mcp -D - -o /dev/null | grep -i www-authenticate
# => Bearer realm="GitLab", scope="mcp_orbit", resource_metadata="…/api/v4/orbit/mcp"

Confirm the challenge and the metadata document agree:

curl -sk https://gdk.test:3443/.well-known/oauth-protected-resource/api/v4/mcp
# => {"resource":"…/api/v4/mcp","authorization_servers":["…"],"scopes_supported":["mcp"]}

curl -sk https://gdk.test:3443/.well-known/oauth-protected-resource/api/v4/orbit/mcp
# => {"resource":"…/api/v4/orbit/mcp","authorization_servers":["…"],"scopes_supported":["mcp_orbit"]}

To watch a real client, add the server to ~/.cursor/mcp.json and launch Cursor with the mkcert root CA trusted, then check the authorize URL's scope= parameter is mcp rather than the full list:

NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem" open -a Cursor

References

Resolved separately, but this is the standards-compliant version of the same fix:

  • #603748 (closed) — MCP Server OAuth flow scope error has regressed on GitLab.com (closed)

Builds on / complements:

Parent epic:

MR acceptance checklist

  • Applied appropriate MR type label
  • Requested Application Security review (touches OAuth challenge / auth path)
Edited by Hakeem Abdul-Razak

Merge request reports

Loading
Loading