fix(mcp): make every scope reachable, and let the user pick which to grant - #367
Merged
Conversation
…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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
scopefrom the 401 challenge, not thescopes_supportedcatalogue in the metadata document.CHALLENGE_SCOPESnamed only the two read scopes, so no grant ever coveredballots:write, however the user connected.ballot_upsertandballot_publish_rationaleexisted, 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/decisionintersects 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/authorizealready 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
scopeat all still defaults towallets:readonly.Two descriptions were understating themselves
They matter more now they are what you read at the moment of deciding:
governance:readsaid "active on-chain governance proposals". It also exposes your team's internal ballots and rationales and your DRep voting history.ballots:writesaid "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.tsis 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.scopesfails 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 — thetitleattribute it sets cannot come from the session branch. The live 401 now returnsscope="wallets:read governance:read ballots:write".829 + 85 tests,
tscclean (the 5 pre-existingtxScriptRecovery.test.tserrors 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