Skip to content

fix(mcp): make every scope reachable, and let the user pick which to grant - #367

Merged
QSchlegel merged 1 commit into
preprodfrom
claude/mcp-consent-scopes
Aug 13, 2026
Merged

fix(mcp): make every scope reachable, and let the user pick which to grant#367
QSchlegel merged 1 commit into
preprodfrom
claude/mcp-consent-scopes

Conversation

@QSchlegel

Copy link
Copy Markdown
Collaborator

Answers "why don't the new tools appear?" — they were unreachable, not hidden.

The two ballot tools could not be granted by anyone

MCP clients request exactly the scope from the 401 challenge, not the scopes_supported catalogue in the metadata document. CHALLENGE_SCOPES named only the two read scopes, so no grant ever covered ballots:write, however the user connected. ballot_upsert and ballot_publish_rationale existed, were tested, were listed on the landing page, and were impossible to reach.

The comment above that constant described this exact failure mode — "anything omitted here is unreachable in practice" — and then omitted the scope anyway, on the reasoning that it should stay "opt-in for a client that asks for it explicitly." No client asks explicitly. They follow the challenge. The reasoning was self-defeating and I wrote it.

Withholding a scope was never the server's call

The challenge now advertises all three, and the human decides at consent — one checkbox per scope, pre-ticked with what the client requested. Untick what you don't want.

/api/oauth/decision intersects the posted selection with the signed request handle, so the body — which is attacker-controlled — can only narrow the grant, never widen it past what /authorize already validated. The chosen set is written to both the authorization code and the durable grant, so the token that gets minted matches what the user watched themselves approve.

An approval that grants nothing is refused rather than recorded. A zero-scope token authenticates fine and registers no tools, so every call returns "method not found" — indistinguishable, from the client's side, from a broken server. Cancel says what actually happened.

A client that sends no scope at all still defaults to wallets:read only.

Two descriptions were understating themselves

They matter more now they are what you read at the moment of deciding:

  • governance:read said "active on-chain governance proposals". It also exposes your team's internal ballots and rationales and your DRep voting history.
  • ballots:write said "ballot drafts, including rationale text". It also publishes rationale documents publicly to IPFS, which is public and effectively permanent.

Both strings are shared by the consent screen and the profile card, so they were wrong in both places.

Verification

oauthDecision.test.ts is new — the endpoint had no tests at all, and it is where consent becomes a durable grant. 8 tests covering the ceiling, the narrowing, code/grant agreement, the empty-scope refusal, non-string entries in the body, the session requirement, and the cancel path.

Mutation-checked rather than assumed: reverting the narrowing logic to the old grantedScopes = request.scopes fails 4 of the 8. The other 4 are boundary guards that correctly hold either way.

Consent screen verified against a real production build (next build --webpack + next start), not just compiled: three checkboxes rendering with the corrected copy, toggling working, and the empty-selection guard confirmed active independently of the wallet-session gate — the title attribute it sets cannot come from the session branch. The live 401 now returns scope="wallets:read governance:read ballots:write".

829 + 85 tests, tsc clean (the 5 pre-existing txScriptRecovery.test.ts errors are on preprod too), build green.

After this merges

Existing connections keep whatever they were granted — this changes what a new consent can cover. To pick up the other six tools, reconnect and tick the boxes. Widening in the profile card also still works, and now reconnecting no longer silently resets you to wallets:read, because the client will actually be asking for the full set.

🤖 Generated with Claude Code

…grant

The two ballot tools could not be granted by anyone. Clients request exactly
the `scope` from the 401 challenge rather than the scopes_supported
catalogue, and the challenge named only the two read scopes — so no grant
ever covered ballots:write, however the user connected. The comment above
CHALLENGE_SCOPES described this failure mode precisely and then omitted the
scope anyway, reasoning it should stay "opt-in for a client that asks for it
explicitly". No client asks; they follow the challenge.

Withholding a scope was never the server's call. The challenge now
advertises all three and the consent screen decides, one checkbox per scope,
pre-ticked with what the client asked for. /api/oauth/decision intersects
the posted selection with the signed request handle, so the body can only
narrow the grant, never widen it past what /authorize already validated. An
approval that grants nothing is refused rather than recorded: a zero-scope
token authenticates and exposes no tools, which reads to a client as a
broken server rather than a refusal.

A client that sends no scope at all still defaults to wallets:read only.

Two scope descriptions were understating themselves, which matters more now
they are what the user reads at the moment of deciding: governance:read also
exposes the team's internal ballots and DRep vote history, and ballots:write
publishes rationale documents publicly and near-permanently to IPFS.

Adds oauthDecision.test.ts — the endpoint had no tests, and it is the point
where consent becomes a durable grant. Mutation-checked: reverting the
narrowing logic fails 4 of the 8. Consent screen verified rendering against
a production build: three checkboxes, correct copy, toggle working, and the
empty-selection guard confirmed live independently of the session gate.
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
multisig Building Building Preview Aug 13, 2026 1:13pm

Request Review

@QSchlegel
QSchlegel merged commit 5265634 into preprod Aug 13, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant