Fix dynamic registration dropping the mcp_orbit scope
What does this MR do and why?
Claude Desktop's GitLab Orbit connector cannot authorize. The user sees The requested scope is invalid, unknown, or malformed. Reported in #627293 (closed).
Oauth::DynamicRegistrationsController#resolve_mcp_scope resolved the MCP scope for a dynamically registered application by looking only at the first space-separated token of the client's scope field:
normalized = requested_scope.to_s.strip.split.first
return normalized if normalized.present? && RESOURCE_SCOPE_MAP.value?(normalized)If that first token was not a recognized MCP scope, the entire field was discarded and the application fell back to the default mcp. The registered scope therefore depended on token order rather than on what the client asked for. Claude Desktop's Orbit connector registers with scope: "mcp mcp_orbit", so it was registered mcp, and its later authorization request for mcp_orbit failed Doorkeeper's scope subset check — an application cannot be granted a scope it was not registered with.
End-to-end flow
| step | what the client sends | on master |
with this MR |
|---|---|---|---|
| register | scope: "mcp mcp_orbit", no resource |
app registered mcp |
app registered mcp_orbit |
| authorize | scope=mcp mcp_orbit, resource=…/api/v4/orbit/mcp |
forced to mcp_orbit, subset check fails |
forced to mcp_orbit, subset check passes |
The change
One method in app/controllers/oauth/dynamic_registrations_controller.rb. It scans the whole requested list instead of the first token:
MCP_SCOPES_BY_SPECIFICITY = RESOURCE_SCOPE_MAP.values.freeze
def scope_from_requested_scopes(requested_scope)
requested = requested_scope.to_s.split
recognized = MCP_SCOPES_BY_SPECIFICITY & requested
return recognized.first if recognized.size <= 1
return recognized.first if (requested - MCP_SCOPES_BY_SPECIFICITY).empty?
Gitlab::Auth::MCP_SCOPE.to_s
endMCP_SCOPES_BY_SPECIFICITY derives from RESOURCE_SCOPE_MAP, which is ordered most specific first, so recognized.first is the narrower scope when a client names both.
The resource field still takes precedence over scope, unchanged.
Registered application scope, by scope field in the registration request:
scope field |
Before | After |
|---|---|---|
mcp_orbit |
mcp_orbit |
mcp_orbit |
mcp |
mcp |
mcp |
mcp mcp_orbit |
mcp |
mcp_orbit (changed, fixes the report) |
mcp_orbit mcp |
mcp_orbit |
mcp_orbit (now order-independent) |
mcp_orbit openid |
mcp_orbit |
mcp_orbit |
openid mcp_orbit |
mcp |
mcp_orbit (changed) |
full scopes_supported list (26 scopes on GitLab.com) |
mcp |
mcp (unchanged, deliberately) |
api, or any list with no MCP scope |
mcp |
mcp |
Why mcp_orbit wins a two-way tie while a full 26-scope echo still resolves to mcp: mcp_orbit is the narrower privilege, opening only /api/v4/orbit/mcp, whereas an mcp token is accepted by both /api/v4/mcp and /api/v4/orbit/mcp. A request naming only MCP scopes is a deliberate choice, so least privilege applies. A 26-scope echo expresses no choice and keeps the mcp default; registering mcp_orbit there would break /api/v4/mcp for every dynamic-registration client. An earlier revision of this MR did exactly that, using recognized.max_by(&:length), and was reverted. A regression test pins the full-list case to mcp.
Validation
All of the following comes from production rails logs and live protected-resource metadata.
Three registration requests to Oauth::DynamicRegistrationsController#create were located in the GitLab.com rails log:
- Claude Desktop / claude.ai Orbit connector (
client_name: "Claude",ua: python-httpx/0.28.1,application_type: "web",token_endpoint_auth_method: "client_secret_post") — this is the failing registration from the issue:
{
"application_type": "web",
"client_name": "Claude",
"grant_types": ["authorization_code", "refresh_token"],
"redirect_uris": "[FILTERED]",
"response_types": ["code"],
"scope": "mcp mcp_orbit",
"token_endpoint_auth_method": "client_secret_post"
}- The same claude.ai client registering for the regular MCP connector sends one scope instead:
"scope": "mcp", everything else identical. - Claude Code CLI (
ua: claude-code/2.1.236 (cli)and a plugin variant onBun/1.4.1) sends a single scope too.
None of the three echoes the full scopes_supported array.
The requested list is the resource's advertised scopes plus a constant mcp, and each protected-resource metadata document advertises exactly one scope:
| endpoint | scopes_supported |
client sends | registers as (with this MR) |
|---|---|---|---|
/api/v4/mcp |
["mcp"] |
mcp |
mcp (unchanged) |
/api/v4/orbit/mcp |
["mcp_orbit"] |
mcp mcp_orbit |
mcp_orbit (fixed) |
So the two connectors send distinguishable registration bodies, and narrowing a two-scope request to mcp_orbit cannot affect clients registering for /api/v4/mcp.
The Orbit authorize request that follows carries both a scope list and a resource indicator:
scope = mcp mcp_orbit
resource = https://gitlab.com/api/v4/orbit/mcpOauth::AuthorizationsController#pre_auth_params replaces the requested scope list with the single scope the resource maps to, rather than intersecting it with the application's scopes, so Doorkeeper only ever subset-checks that one value. The application therefore has to hold mcp_orbit. On master it holds mcp and the request is rejected; with this MR it holds mcp_orbit and the flow completes.
What changed relative to the previous revision of this MR
The previous revision changed Oauth::AuthorizationsController#pre_auth_params instead: when the resource indicator named the orbit endpoint but the application only held mcp, it fell back to forcing mcp, via a new application_can_use_scope? helper. That treated the symptom at authorize time and handed an Orbit-only connector the broader mcp scope, which also opens /api/v4/mcp. Fixing the registration means the application holds exactly the scope it asked for. That change has been fully reverted; authorizations_controller.rb is identical to master.
Tests
spec/requests/oauth/dynamic_registrations_controller_spec.rb: 63 examples, 0 failures. Added contexts for both MCP scopes with nothing else, and both MCP scopes in the opposite order. The context that previously asserted a leading unrecognized token forced themcpdefault now asserts the recognized MCP scope is used. The full-scope-list regression test is unchanged and still assertsmcp.spec/requests/oauth/authorizations_controller_spec.rb: 36 examples, 0 failures. One regression test added — a dynamic application registered withmcp_orbitauthorizes successfully when the client requestsscope=mcp mcp_orbit. The controller itself is unchanged frommaster.
References
Screenshots or screen recordings
N/A -- OAuth registration fix, no UI changes.
How to set up and validate locally
for s in "mcp_orbit" "mcp mcp_orbit" "mcp_orbit mcp"; do
curl -sk -X POST https://gdk.test:3443/oauth/register \
-H 'Content-Type: application/json' \
-d "{\"client_name\":\"probe\",\"redirect_uris\":[\"http://localhost:9999/callback\"],\"scope\":\"$s\"}" \
| jq -r .scope
doneOn master this prints mcp_orbit, mcp, mcp_orbit — the result depends on scope ordering. With this MR it prints mcp_orbit three times.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.