fix(mcp): always emit a string scope in the /token response #106

Merged
David merged 1 commit from fix/YT-48-token-scope-null into main 2026-07-05 23:31:01 +02:00
Owner

What

Fixes YT-48: the /token response emitted "scope": null when the client requested no scope, and the Claude Code MCP client rejects that (expected string, received null). A full, server-side-successful OAuth dance (POST /token ... status=200, tokens issued) therefore left the client not authenticated.

Root cause

token_success (oauth_as.rs) serialized "scope": scope where scope: Option<&str>. The consent handler only defaults the scope for the consent page display; the value persisted into the authorization code is the client's scope param, which is None when Claude sends none. That None flows to token_success and serializes as JSON null. The refresh grant shares the same emit point and exposure.

Fix

Default a None scope to ADVERTISED_SCOPE (offline_access) in token_success, so the response scope is always a non-null string. This is the single site both the authorization_code and refresh_token grants converge on, and matches what /.well-known/* advertises and the WWW-Authenticate challenge reports.

Chose always-emit-a-string over omitting the field: a client whose schema requires a string would fail on an absent field too; a guaranteed string satisfies both required- and optional-string schemas.

Invariant sweep (the /token response scope is always a JSON string)

Site Classification
oauth_as.rs token_success fixed: defaults None to ADVERTISED_SCOPE
both grant callers (token_code, token_refresh via issue_token_pair) covered: both route through token_success
it_oauth.rs / oauth_hub.rs token-body literals N/A: mock upstream-Hub responses (fixtures), not this AS's response

Tests

  • Extended full_authorization_code_and_refresh_rotation (which authorizes with no scope) to assert both the authorization_code and refresh_token responses carry scope == "offline_access" (previously null).
  • Full just pre-commit green (fmt, clippy -D warnings, build, 88 tests).

Manual end-to-end verification against the Claude Code client to follow once deployed.

## What Fixes YT-48: the `/token` response emitted `"scope": null` when the client requested no scope, and the Claude Code MCP client rejects that (`expected string, received null`). A full, server-side-successful OAuth dance (`POST /token ... status=200`, tokens issued) therefore left the client `not authenticated`. ## Root cause `token_success` (`oauth_as.rs`) serialized `"scope": scope` where `scope: Option<&str>`. The consent handler only defaults the scope for the consent *page* display; the value persisted into the authorization code is the client's `scope` param, which is `None` when Claude sends none. That `None` flows to `token_success` and serializes as JSON null. The refresh grant shares the same emit point and exposure. ## Fix Default a `None` scope to `ADVERTISED_SCOPE` (`offline_access`) in `token_success`, so the response `scope` is always a non-null string. This is the single site both the authorization_code and refresh_token grants converge on, and matches what `/.well-known/*` advertises and the `WWW-Authenticate` challenge reports. Chose always-emit-a-string over omitting the field: a client whose schema requires a string would fail on an absent field too; a guaranteed string satisfies both required- and optional-string schemas. ## Invariant sweep (the /token response scope is always a JSON string) | Site | Classification | | --- | --- | | `oauth_as.rs` `token_success` | fixed: defaults None to `ADVERTISED_SCOPE` | | both grant callers (`token_code`, `token_refresh` via `issue_token_pair`) | covered: both route through `token_success` | | `it_oauth.rs` / `oauth_hub.rs` token-body literals | N/A: mock upstream-Hub responses (fixtures), not this AS's response | ## Tests - Extended `full_authorization_code_and_refresh_rotation` (which authorizes with no scope) to assert both the authorization_code and refresh_token responses carry `scope == "offline_access"` (previously null). - Full `just pre-commit` green (fmt, clippy `-D warnings`, build, 88 tests). Manual end-to-end verification against the Claude Code client to follow once deployed.
fix(mcp): always emit a string scope in the /token response
All checks were successful
Check / fmt + clippy + build + tests (pull_request) Successful in 47s
Create release / Create release from merged PR (pull_request) Has been skipped
2728425b43
The token endpoint serialized the granted scope as `"scope": scope`, which becomes JSON `null` when the client requested no scope (the authorization code is stored without one). The Claude Code MCP client validates the token response and rejects a null scope ("expected string, received null"), so an otherwise-successful OAuth dance left the client unauthenticated.

Default a None scope to ADVERTISED_SCOPE ("offline_access") in token_success, the single point both the authorization_code and refresh_token grants converge on, so the response scope is always a non-null string, consistent with the discovery metadata and the WWW-Authenticate challenge.

#YT-48

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
David merged commit 3e7e301222 into main 2026-07-05 23:31:01 +02:00
David deleted branch fix/YT-48-token-scope-null 2026-07-05 23:31:01 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
pandoras-box/youtrack-cli!106
No description provided.