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
@@ -9,7 +9,7 @@ import PhotoProvidersSection from './PhotoProvidersSection'
import AirTrailConnectionSection from './AirTrailConnectionSection'
import LlmConnectionSection from './LlmConnectionSection'
import ApiKeysSection from './ApiKeysSection'
import { ALL_SCOPES } from '../../api/oauthScopes'
import { PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } from '../../api/oauthScopes'
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
import { useAuthStore } from '../../store/authStore'
@@ -27,42 +27,42 @@ const OAUTH_PRESETS: OAuthPreset[] = [
label: 'Claude.ai',
name: 'Claude.ai',
uris: 'https://claude.ai/api/mcp/auth_callback',
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
scopes: PRESET_SCOPES_DEFAULT,
},
{
id: 'claude-desktop',
label: 'Claude Desktop',
name: 'Claude Desktop',
uris: 'http://localhost',
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
scopes: PRESET_SCOPES_DEFAULT,
},
{
id: 'cursor',
label: 'Cursor',
name: 'Cursor',
uris: 'http://localhost',
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
scopes: PRESET_SCOPES_DEFAULT,
},
{
id: 'vscode',
label: 'VS Code',
name: 'VS Code / Copilot',
uris: 'http://localhost',
scopes: ALL_SCOPES.filter(s => s.endsWith(':read')),
scopes: PRESET_SCOPES_READONLY,
},
{
id: 'windsurf',
label: 'Windsurf',
name: 'Windsurf',
uris: 'http://localhost',
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
scopes: PRESET_SCOPES_DEFAULT,
},
{
id: 'zed',
label: 'Zed',
name: 'Zed',
uris: 'http://localhost',
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
scopes: PRESET_SCOPES_DEFAULT,
},
]