-
Notifications
You must be signed in to change notification settings - Fork 249
Consent-time scope narrowing for MCP grants #3105
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+738
−12
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
daf42ec
feat: consent-time scope narrowing for MCP grants
ChiragAgg5k 3b26e6b
fix: omit authorizationDetails on untouched MCP approve to keep conse…
ChiragAgg5k 8be55af
(test): drop oauth2-mcp unit tests
ChiragAgg5k c7b29a4
(fix): add consent copy for legacy and uncovered scope resources
ChiragAgg5k 74ba7ee
Merge remote-tracking branch 'origin/main' into feat/mcp-consent-narr…
ChiragAgg5k 471e157
(fix): address Greptile review — guard umbrella collapse under read-o…
ChiragAgg5k File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| import { env } from '$env/dynamic/public'; | ||
| import type { Models } from '@appwrite.io/console'; | ||
| import { | ||
| ALL_SCOPE, | ||
| PROJECT_ALL_SCOPE, | ||
| ORGANIZATION_ALL_SCOPE, | ||
| PROJECT_SCOPE_PREFIX, | ||
| ORGANIZATION_SCOPE_PREFIX, | ||
| scopeAction, | ||
| scopeResource, | ||
| type ConsentScopeModel, | ||
| type TierScopes | ||
| } from '$lib/helpers/oauth2-scopes'; | ||
|
|
||
| /** | ||
| * MCP-grant detection and consent-time scope narrowing for the hosted Appwrite | ||
| * MCP server (granular MCP scopes design). | ||
| * | ||
| * The MCP server advertises the authorization server's full scope catalog, so | ||
| * MCP clients mechanically request *everything*. The consent screen detects | ||
| * those grants — the grant's RFC 8707 `resources` contains the MCP server's | ||
| * canonical resource URI — and only then offers a narrowing editor. Because | ||
| * every granular scope was literally requested, whatever the user narrows to is | ||
| * a plain subset that the approve endpoint's literal check already accepts. | ||
| * | ||
| * The detection signal is self-fulfilling: the resource value becomes the | ||
| * token's `aud`, and the MCP server hard-rejects foreign audiences, so a lying | ||
| * client gains nothing by claiming the MCP resource URI. | ||
| */ | ||
|
|
||
| /** Canonical resource URI of the hosted Appwrite MCP server. */ | ||
| export const DEFAULT_MCP_RESOURCE_URLS = ['https://mcp.appwrite.io/mcp']; | ||
|
|
||
| /** Mirror of the authorization server's `scope` parameter length cap. */ | ||
| export const MAX_SCOPE_PARAM_LENGTH = 8192; | ||
|
|
||
| /** Normalize an RFC 8707 resource URI for comparison: trailing slashes only — | ||
| * resource indicators are canonical URIs, so anything else compares exactly. */ | ||
| export function normalizeResourceUrl(url: string): string { | ||
| return url.trim().replace(/\/+$/, ''); | ||
| } | ||
|
|
||
| /** | ||
| * The MCP resource URIs this console recognizes. Defaults to the hosted MCP; | ||
| * extendable via the comma-separated `PUBLIC_MCP_RESOURCE_URLS` env var (e.g. | ||
| * `http://localhost:8000/mcp` for local development). | ||
| */ | ||
| export function mcpResourceUrls(raw?: string | null): string[] { | ||
| const configured = (raw ?? env.PUBLIC_MCP_RESOURCE_URLS ?? '') | ||
| .split(',') | ||
| .map(normalizeResourceUrl) | ||
| .filter((url) => url !== ''); | ||
| return configured.length > 0 ? configured : DEFAULT_MCP_RESOURCE_URLS; | ||
| } | ||
|
|
||
| /** Whether any of the grant's requested resources is a known MCP resource URI. */ | ||
| export function isMcpResource(resources: string[], urls: string[] = mcpResourceUrls()): boolean { | ||
| const known = new Set(urls.map(normalizeResourceUrl)); | ||
| return resources.some( | ||
| (resource) => typeof resource === 'string' && known.has(normalizeResourceUrl(resource)) | ||
| ); | ||
| } | ||
|
|
||
| /** MCP grants (and only MCP grants) get the consent-time narrowing editor. */ | ||
| export function isMcpGrant(grant: Models.Oauth2Grant, urls?: string[]): boolean { | ||
| return isMcpResource(grant.resources ?? [], urls ?? mcpResourceUrls()); | ||
| } | ||
|
|
||
| /* -------------------------------------------------------------------------- */ | ||
| /* Grant composition — turn the editor's selection into the approve payload */ | ||
| /* -------------------------------------------------------------------------- */ | ||
|
|
||
| /** Per-resource editor selection within one tier. Rows absent from the record | ||
| * default to selected with full requested access. */ | ||
| export interface ResourceSelection { | ||
| selected: boolean; | ||
| /** `full` grants every requested action; `read` keeps only `.read`. */ | ||
| level: 'full' | 'read'; | ||
| } | ||
|
|
||
| export type TierSelection = Record<string, ResourceSelection>; | ||
|
|
||
| export interface ComposeInput { | ||
| model: ConsentScopeModel; | ||
| project: TierSelection; | ||
| organization: TierSelection; | ||
| /** Master read-only toggle — treats every selected resource as `read`. */ | ||
| readOnly: boolean; | ||
| /** True when the user narrowed the org/project resource pickers. */ | ||
| resourcesNarrowed: boolean; | ||
| } | ||
|
|
||
| export interface ComposedGrant { | ||
| /** | ||
| * The `scope` value for the approve endpoint, or `undefined` to omit it. | ||
| * Omission means "grant the full literal requested list" — required so the | ||
| * consent-skip diff stays empty on the next re-authorization. | ||
| */ | ||
| scope: string | undefined; | ||
| /** The user's selection is the full request — nothing was narrowed. */ | ||
| untouched: boolean; | ||
| /** No non-identity scope remains; Authorize must be disabled. */ | ||
| blocked: boolean; | ||
| /** A tier was collapsed to its umbrella to fit the scope length cap. */ | ||
| lengthCollapsed: boolean; | ||
| } | ||
|
|
||
| /** The bare scope tokens (`tables.read`, …) one tier's selection emits. Every | ||
| * emitted token is one of the tier's requested tokens — never synthesized — | ||
| * so the result is a literal subset of the request by construction. */ | ||
| function tierEmission( | ||
| tier: TierScopes, | ||
| selection: TierSelection, | ||
| readOnly: boolean | ||
| ): { tokens: string[]; full: boolean } { | ||
| const tokens: string[] = []; | ||
| for (const token of tier.scopes) { | ||
| const resource = scopeResource(token); | ||
| const action = scopeAction(token); | ||
| const row = selection[resource] ?? { selected: true, level: 'full' }; | ||
| if (!row.selected) continue; | ||
| const level = readOnly ? 'read' : row.level; | ||
| if (level === 'read' && action !== 'read') continue; | ||
| tokens.push(token); | ||
| } | ||
| return { tokens, full: tokens.length === tier.scopes.length }; | ||
| } | ||
|
|
||
| /** | ||
| * Compose the scope narrowing to send to the approve endpoint. | ||
| * | ||
| * - Untouched selection → omit `scope` entirely: the server keeps the full | ||
| * literal requested list, so re-authorizations skip consent (the diff against | ||
| * the previously approved scopes stays empty). | ||
| * - Any narrowing → identity scopes plus the selected granular tokens; a tier | ||
| * whose requested tokens all survive collapses to its requested umbrella | ||
| * (`project:all` / `organization:all`). The console-wide `all` is never part | ||
| * of a narrowed grant — it bypasses RFC 9396 resource restrictions. | ||
| * - `.write` never implies `.read`: a "Read + Write" row emits both tokens. | ||
| */ | ||
| export function composeGrantedScopes(input: ComposeInput): ComposedGrant { | ||
| const { model, readOnly, resourcesNarrowed } = input; | ||
|
|
||
| const project = tierEmission(model.project, input.project, readOnly); | ||
| const organization = tierEmission(model.organization, input.organization, readOnly); | ||
|
|
||
| const capabilitiesFull = project.full && organization.full && !readOnly; | ||
| if (capabilitiesFull && !resourcesNarrowed) { | ||
| return { scope: undefined, untouched: true, blocked: false, lengthCollapsed: false }; | ||
| } | ||
|
|
||
| // A fully-kept tier collapses to its umbrella (when the umbrella was | ||
| // requested) — keeps the granted string short and re-consent diffs sane. | ||
| // Covers the degenerate umbrella-only request (`project:all` with no | ||
| // granular tokens) too: a full selection keeps the umbrella. | ||
| const tierScopes = ( | ||
| tier: TierScopes, | ||
| emission: { tokens: string[]; full: boolean }, | ||
| prefix: string, | ||
| umbrella: string | ||
| ): string[] => { | ||
| const requested = tier.all || tier.scopes.length > 0; | ||
| // Never collapse under read-only: the umbrella grants write, so a tier | ||
| // whose requested tokens are all `.read` (emission.full despite the | ||
| // toggle) must still emit the explicit token list. | ||
| if (requested && emission.full && tier.all && !readOnly) return [umbrella]; | ||
| return emission.tokens.map((token) => prefix + token); | ||
| }; | ||
|
|
||
| let projectScopes = tierScopes(model.project, project, PROJECT_SCOPE_PREFIX, PROJECT_ALL_SCOPE); | ||
| let organizationScopes = tierScopes( | ||
| model.organization, | ||
| organization, | ||
| ORGANIZATION_SCOPE_PREFIX, | ||
| ORGANIZATION_ALL_SCOPE | ||
| ); | ||
|
|
||
| const blocked = projectScopes.length === 0 && organizationScopes.length === 0; | ||
|
|
||
| const identity = model.identity.map((scope) => scope.id); | ||
| const join = () => [...identity, ...projectScopes, ...organizationScopes].join(' '); | ||
|
|
||
| // Client-side mirror of the server's scope length cap. Unreachable in | ||
| // practice (the full catalog is ~2.6 KB), but if a selection ever exceeds | ||
| // it, collapse the larger tier to its requested umbrella and surface that. | ||
| // Umbrella collapse grants write, so it is off the table under read-only — | ||
| // the toggle already halves the token list, keeping it well under the cap. | ||
| let lengthCollapsed = false; | ||
| if (!readOnly && join().length > MAX_SCOPE_PARAM_LENGTH) { | ||
| const projectLength = projectScopes.join(' ').length; | ||
| const organizationLength = organizationScopes.join(' ').length; | ||
| if (projectLength >= organizationLength && model.project.all) { | ||
| projectScopes = [PROJECT_ALL_SCOPE]; | ||
| lengthCollapsed = true; | ||
| } else if (model.organization.all) { | ||
| organizationScopes = [ORGANIZATION_ALL_SCOPE]; | ||
| lengthCollapsed = true; | ||
| } | ||
| if (join().length > MAX_SCOPE_PARAM_LENGTH && model.project.all) { | ||
| projectScopes = [PROJECT_ALL_SCOPE]; | ||
| lengthCollapsed = true; | ||
| } | ||
| } | ||
|
|
||
| return { scope: join(), untouched: false, blocked, lengthCollapsed }; | ||
| } | ||
|
|
||
| /** Never granted by a narrowed MCP consent — documented here for tests. */ | ||
| export const NARROWED_GRANT_NEVER_INCLUDES = [ALL_SCOPE]; | ||
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
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.