feat(mcp): plugins:use OAuth scope

One coarse scope rather than per-plugin granularity. Which plugins are
installed is runtime data, so a per-plugin scope cannot be statically
enumerated: it would break the ${group}:${mode} derivation and the
AssertExact guards in nest-mcp-policy.ts, and its consent copy could not
be written ahead of time for a third-party plugin name. Tokens issued
before a plugin existed could never carry its scope either. Per-plugin
granularity already exists one layer down as the admin's mcp:tools grant.

'use' is a fourth mode, which widens ScopeMode. Nothing else moves:
McpAccessGroup and McpAccessMode derive from Scope, so both AssertExact
guards self-heal, and the policy's fallback branch already covers a third
mode the way it covers files:content.

The presets needed an explicit exclusion. Five of the six are written as
"everything except deletes", so plugins:use would have joined Claude.ai,
Cursor, Windsurf and Zed silently and handed out third-party tool
execution without anyone choosing it. That filter was duplicated across
the desktop and mobile files with nothing pinning it, so both now use one
shared constant, and FE-OAUTH-SCOPES-012 holds the line for the next
scope like this one.

The consent copy says what the scope does not do: the RPC host authorises
from grants frozen at construction, so this scope plus weather:read still
reaches whatever the plugin itself was granted.

Two i18n gates, not one. The dot-only group key is tracked by
i18n-parity.mjs, whose regex has no colon in its character class; the
colon-bearing label/description pair is invisible to it but is compared by
client/tests/unit/i18n/parity.test.ts, which diffs merged bundles. All 23
locales get all three keys, including the three in neither gate.

The consent glyph gets a ':use' branch. Falling through to the eye would
tell the user a tool-running scope only looks at things.
This commit is contained in:
Renzo Beux
2026-08-28 13:26:34 -03:00
committed by Maurice
parent c56521b4a4
commit 092223c259
30 changed files with 156 additions and 19 deletions
+12
View File
@@ -42,6 +42,17 @@ export const SCOPES = {
FILES_CONTENT: 'files:content',
SETTINGS_READ: 'settings:read',
SETTINGS_WRITE: 'settings:write',
// One coarse scope, and a fourth mode rather than a per-plugin group. Which
// plugins are installed is runtime data, so a per-plugin scope cannot be
// statically enumerated: it would break the ${group}:${mode} derivation and
// the AssertExact guards in nest-mcp-policy.ts, and its consent copy could
// not be written ahead of time for a third-party plugin name. Tokens issued
// before a plugin existed could never carry its scope either.
//
// Per-plugin granularity already exists one layer down, as the admin's
// mcp:tools grant, and that is the real boundary: this scope only decides
// whether plugin tools are advertised to a client at all.
PLUGINS_USE: 'plugins:use',
} as const;
export type Scope = typeof SCOPES[keyof typeof SCOPES];
@@ -92,6 +103,7 @@ export const SCOPE_INFO: Record<Scope, ScopeInfo> = {
'files:content': { label: 'Read file contents', description: 'Read what is inside an uploaded document, such as a booking PDF or a ticket', group: 'Files' },
'settings:read': { label: 'View your preferences', description: 'Read units, time format, language, default currency, and start page', group: 'Settings' },
'settings:write': { label: 'Change your preferences', description: 'Change units, time format, language, default currency, and start page. Never stored API keys', group: 'Settings' },
'plugins:use': { label: 'Run plugin tools', description: 'Let this client call tools published by the plugins an administrator installed and approved. Each plugin acts with the access it was already granted, not with the scopes on this token', group: 'Plugins' },
};
// ---------------------------------------------------------------------------