Skip to content

Consent-time scope narrowing for MCP grants#3105

Merged
ChiragAgg5k merged 6 commits into
mainfrom
feat/mcp-consent-narrowing
Jul 6, 2026
Merged

Consent-time scope narrowing for MCP grants#3105
ChiragAgg5k merged 6 commits into
mainfrom
feat/mcp-consent-narrowing

Conversation

@ChiragAgg5k

@ChiragAgg5k ChiragAgg5k commented Jul 3, 2026

Copy link
Copy Markdown
Member

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 /approve literal check already supports — no backend logic changes.

MCP-grant detection (src/lib/helpers/oauth2-mcp.ts)

isMcpGrant() — true when the grant's RFC 8707 resources contains the hosted MCP's canonical resource URI (https://mcp.appwrite.io/mcp by default, extendable via comma-separated PUBLIC_MCP_RESOURCE_URLS for 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's aud and the MCP hard-rejects foreign audiences, so a lying client gains nothing.

Narrowing editor (gated — non-MCP grants render exactly as today)

consent-card.svelte gains 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())

User action scope sent to /approve
Untouched omitted — server keeps the full literal requested list, so the consent-skip diff stays empty on re-auth
Resources narrowed only identity + project:all organization:allall dropped (it bypasses RAR resource restrictions)
Capabilities narrowed identity + selected granular tokens; fully-kept tier collapses to its umbrella; "Read + Write" emits both .read and .write

Every 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):

consent-full-access-default

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"):

consent-customized

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-apps

greptile-apps Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds consent-time scope narrowing for MCP OAuth grants: a new oauth2-mcp.ts helper detects MCP grants via RFC 8707 resources, and consent-card.svelte presents an inline editor (gated exclusively to MCP grants) where users can deselect resources, toggle read vs read+write per row, and apply a global read-only lock before approving.

  • isMcpGrant() matches the grant's resources against the canonical https://mcp.appwrite.io/mcp URI (extendable via PUBLIC_MCP_RESOURCE_URLS); the detection signal is self-fulfilling because the MCP server rejects foreign audiences.
  • composeGrantedScopes() converts the editor state into the scope string for /approve; an untouched selection omits scope entirely so re-authorizations skip consent, and every emitted token is taken verbatim from the request (never synthesized), making the result a structural subset by construction.
  • Non-MCP grants are unaffected — the new code path is entirely behind the canNarrow gate.

Confidence Score: 5/5

Safe 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 !readOnly, the untouched early-return correctly requires !resourcesNarrowed, and every emitted token is drawn verbatim from the requested scope list. The one asymmetry in the length-collapse fallback (project-first path has no second-tier recovery) is explicitly documented as unreachable given the current ~2.6 KB catalog size.

No files require special attention for merging; consent-card.svelte has some UX edge cases around write-only resources under the global read-only toggle but none affect grant correctness.

Important Files Changed

Filename Overview
src/lib/helpers/oauth2-mcp.ts New file implementing MCP grant detection and scope composition; well-documented, the read-only/umbrella-collapse guard (!readOnly) is correctly applied, and the literal-subset guarantee is structurally enforced
src/lib/helpers/oauth2-scopes.ts Adds new legacy resource copy entries, exports scopeAction/scopeResource under their original internal names via aliases, and adds buildTierEditorRows — all additive and correct
src/routes/(public)/oauth2/consent-card.svelte Adds MCP-gated narrowing editor with per-resource checkboxes and read/read+write toggles; non-MCP grants render exactly as before; some edge-case UX gaps noted in previous reviews

Reviews (4): Last reviewed commit: "(fix): address Greptile review — guard u..." | Re-trigger Greptile

Comment thread src/lib/helpers/oauth2-mcp.ts Outdated
…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.
Comment thread src/lib/helpers/oauth2-mcp.ts
…owing

# Conflicts:
#	src/routes/(public)/oauth2/consent-card.svelte
@ChiragAgg5k ChiragAgg5k merged commit 79a7d3e into main Jul 6, 2026
4 checks passed
@ChiragAgg5k ChiragAgg5k deleted the feat/mcp-consent-narrowing branch July 6, 2026 12:53
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.

2 participants