Consent-time scope narrowing for MCP grants#3105
Conversation
MCP clients now request the authorization server's full scope catalog (granular MCP scopes design), making the consent screen the narrowing control point: - oauth2-mcp.ts: isMcpGrant() detects MCP grants via the grant's RFC 8707 resources (exact-match against the hosted MCP resource URI, extendable with PUBLIC_MCP_RESOURCE_URLS); composeGrantedScopes() turns the editor selection into the approve payload — untouched selection omits scope (keeps the consent-skip literal diff empty), any narrowing emits identity + granular tokens, collapses fully-kept tiers to their umbrellas, never includes 'all' (it bypasses RAR), and 'Read + Write' emits both .read and .write. - consent-card.svelte: narrowing editor gated on isMcpGrant — default full access with a promoted 'Customize access' affordance, per- resource checkboxes with Read / Read + Write levels, a master read-only toggle, and guards (at least one non-identity scope, scope-length mirror of the server cap). Non-MCP grants render exactly as before. - oauth2-scopes.ts: buildTierEditorRows() + exported token helpers, reusing the existing resource catalog copy. - Vitest coverage for detection, normalization, env override, and all grant-composition rules.
Greptile SummaryThis PR adds consent-time scope narrowing for MCP OAuth grants: a new
Confidence Score: 5/5Safe to merge; non-MCP grants are completely unaffected, and the MCP narrowing path is structurally sound with the literal-subset guarantee enforced at the composition layer. The grant-composition logic is well-guarded: the umbrella-collapse predicate correctly includes No files require special attention for merging; Important Files Changed
Reviews (4): Last reviewed commit: "(fix): address Greptile review — guard u..." | Re-trigger Greptile |
…nt-skip working The server's consent-skip check hashes the request's authorization_details against the identity's stored details. Sending the wildcard details on an untouched MCP approve made every re-authorization re-prompt consent (the MCP request carries no authorization_details). Omit the param when the resource pickers are untouched so the server keeps exactly what the client requested; a narrowed selection is still sent and deliberately re-prompts on the next full request. Found in end-to-end testing against the local stack (scenario 4 of the granular-scopes test matrix).
The full 118-scope catalog surfaced resources without an entry in the consent copy maps, which rendered as bare titleized names with no description: the deprecated policies / collections / attributes / documents / execution / organization keys / devKeys pairs, plus the current project.oauth2 configuration scopes. Every scope in the live catalog now renders with a title and description.
…owing # Conflicts: # src/routes/(public)/oauth2/consent-card.svelte
…nly, drop redundant normalize pass
What
Part C of the granular MCP OAuth scopes plan (companions: appwrite-labs/cloud#4606 — validator cap bumps, already open — and appwrite/mcp#57 — MCP advertises the full scope catalog).
Under that design, MCP clients mechanically request the AS's entire ~118-scope catalog, and the consent screen becomes the control point: because everything granular was literally requested, consent-time downscoping is a plain subset the
/approveliteral check already supports — no backend logic changes.MCP-grant detection (
src/lib/helpers/oauth2-mcp.ts)isMcpGrant()— true when the grant's RFC 8707resourcescontains the hosted MCP's canonical resource URI (https://mcp.appwrite.io/mcpby default, extendable via comma-separatedPUBLIC_MCP_RESOURCE_URLSfor local dev). Trailing-slash normalization only; deliberately not loosened to "any URL ending in /mcp". The signal is self-fulfilling: the resource becomes the token'saudand the MCP hard-rejects foreign audiences, so a lying client gains nothing.Narrowing editor (gated — non-MCP grants render exactly as today)
consent-card.sveltegains an editor only for MCP grants: default state is full access (a plain "Authorize" grants the full request), with a promoted "Customize access" affordance ("You can limit … to specific projects and actions"). Inside: per-resource checkboxes grouped by tier (reusing the existing resource-copy catalog), Read / Read + Write segmented levels, a master read-only toggle, per-tier select-all, and the existing org/project resource pickers unchanged. Identity scopes are not editable.Grant composition (
composeGrantedScopes())scopesent to/approveproject:all organization:all—alldropped (it bypasses RAR resource restrictions).readand.writeEvery emitted token comes verbatim from the request (never synthesized), so the result is a literal subset by construction. Guards: at least one non-identity scope must remain (Authorize disabled with a hint otherwise) and a client-side mirror of the server's 8192 scope-length cap.
Tests
bun run check(0 errors),bun run lint(0 errors),bun run test:unit(235 passed). The grant-composition and detection behavior was verified end to end against the local 3-service stack (12-scenario test matrix — see screenshots below).Rollout
Safe to deploy with or after cloud#4606 (needed only for the >2048-char approve edge); must be live before appwrite/mcp#57 ships. Production env: rely on the built-in default or set
PUBLIC_MCP_RESOURCE_URLS=https://mcp.appwrite.io/mcp.Screenshots
Captured against the local 3-service stack (cloud API + this console branch + MCP server) during the end-to-end test matrix.
Default state — full access preselected, narrowing promoted (scenario 2):
Customize access expanded — per-resource checkboxes, Read / Read + Write levels, master Read-only toggle (scenario 6; approve sent
scope="openid profile email phone project:tables.read project:rows.read"):