diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.test.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.test.ts
index 4aab626e393..fc568fae59a 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.test.ts
+++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.test.ts
@@ -12,6 +12,7 @@ import {
getEnabledEndpoints,
getEnabledEndpointsForCapability,
getEnabledMcpTools,
+ getEnabledMcpToolsForCapability,
normalizePermissionScopeMap,
type PermissionScopeMap,
} from '@/data/scoped-access-tokens/permission-scope-map-query'
@@ -341,3 +342,64 @@ describe('getEnabledEndpointsForCapability', () => {
).toEqual(['PUT /api/upgrade'])
})
})
+
+describe('getEnabledMcpToolsForCapability', () => {
+ it('attributes a tool to each capability whose scope is in a fully-granted group', () => {
+ const permissionScopeMap = scopeMap({
+ mcp_tools: {
+ list_branches: [['branching_development_read'], ['branching_production_read']],
+ },
+ })
+ const allGrantedScopes = ['branching_development_read', 'branching_production_read']
+
+ expect(
+ getEnabledMcpToolsForCapability({
+ capabilityScopes: ['branching_development_read'],
+ allGrantedScopes,
+ permissionScopeMap,
+ })
+ ).toEqual(['list_branches'])
+ expect(
+ getEnabledMcpToolsForCapability({
+ capabilityScopes: ['branching_production_read'],
+ allGrantedScopes,
+ permissionScopeMap,
+ })
+ ).toEqual(['list_branches'])
+ })
+
+ it('does not attribute a tool to a capability whose own group is unsatisfied', () => {
+ const enabled = getEnabledMcpToolsForCapability({
+ capabilityScopes: ['branching_production_read'],
+ allGrantedScopes: ['branching_development_read'],
+ permissionScopeMap: scopeMap({
+ mcp_tools: {
+ list_branches: [['branching_development_read'], ['branching_production_read']],
+ },
+ }),
+ })
+
+ expect(enabled).toEqual([])
+ })
+
+ it('requires every scope of the capability group to be granted', () => {
+ const permissionScopeMap = scopeMap({
+ mcp_tools: { upgrade_project: [['project_admin_read', 'database_read']] },
+ })
+
+ expect(
+ getEnabledMcpToolsForCapability({
+ capabilityScopes: ['database_read'],
+ allGrantedScopes: ['database_read'],
+ permissionScopeMap,
+ })
+ ).toEqual([])
+ expect(
+ getEnabledMcpToolsForCapability({
+ capabilityScopes: ['database_read'],
+ allGrantedScopes: ['database_read', 'project_admin_read'],
+ permissionScopeMap,
+ })
+ ).toEqual(['upgrade_project'])
+ })
+})
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx
index 3f71e882fb7..ed79e8172e2 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx
@@ -4,6 +4,7 @@ import { Admonition } from 'ui-patterns/Admonition'
import {
computeOverallRisk,
+ PERMISSION_CATALOG_BY_CATEGORY,
PERMISSION_MODE_LABEL,
selectionToScopes,
} from '../../AccessToken.permissions'
@@ -88,12 +89,33 @@ export const NewScopedTokenFormReview = ({
const hasCapabilities = grantedScopes.length > 0
- const { activeByCategory, mcpTools, capabilityGroups } = useCapabilitySummary({
+ const { capabilities } = useCapabilitySummary({
selection,
grantedScopes,
permissionScopeMap,
})
+ const activeByCategory = useMemo(
+ () =>
+ PERMISSION_CATALOG_BY_CATEGORY.map((category) => ({
+ ...category,
+ entries: category.entries
+ .map((entry) => ({ entry, mode: selection[entry.key] ?? 'none' }))
+ .filter(({ mode }) => mode !== 'none'),
+ })).filter((category) => category.entries.length > 0),
+ [selection]
+ )
+
+ const mcpTools = useMemo(
+ () => Array.from(new Set(capabilities.flatMap((capability) => capability.mcpTools))),
+ [capabilities]
+ )
+
+ const capabilityGroups = useMemo(
+ () => capabilities.filter((capability) => capability.endpoints.length > 0),
+ [capabilities]
+ )
+
const rows: [string, React.ReactNode][] = [
['Name', values.tokenName || Untitled token],
['Expires', expiresSummary],
@@ -190,13 +212,15 @@ export const NewScopedTokenFormReview = ({
- {endpoints.map(([method, path]) => (
+ {endpoints.map((endpoint) => (
- {method}
- {path}
+
+ {endpoint.method}
+
+ {endpoint.path}
))}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilitiesSection.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilitiesSection.tsx
new file mode 100644
index 00000000000..a9a9d2611ea
--- /dev/null
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilitiesSection.tsx
@@ -0,0 +1,56 @@
+import { Accordion } from 'ui'
+
+import type { EntryAccess } from '../../AccessToken.roles'
+import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary'
+import { CapabilityCard } from './CapabilityCard'
+import { DenseCapabilities } from './DenseCapabilities'
+import { getCapabilityDensityTier } from './TokenCapabilities.utils'
+
+interface CapabilitiesSectionProps {
+ capabilities: CapabilitySummaryEntry[]
+ accessEntries: Record
+}
+
+/**
+ * Switches capability presentation on granted count: a handful render fully expanded, a moderate
+ * number collapse into an accordion, and a large grant switches to the dense, filterable view.
+ */
+export const CapabilitiesSection = ({ capabilities, accessEntries }: CapabilitiesSectionProps) => {
+ if (capabilities.length === 0) {
+ return No capabilities selected
+ }
+
+ const tier = getCapabilityDensityTier(capabilities.length)
+
+ if (tier === 'expanded') {
+ return (
+
+ {capabilities.map((capability) => (
+
+ ))}
+
+ )
+ }
+
+ if (tier === 'accordion') {
+ return (
+
+ {capabilities.map((capability) => (
+
+ ))}
+
+ )
+ }
+
+ return
+}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityCard.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityCard.tsx
new file mode 100644
index 00000000000..2927d5300a7
--- /dev/null
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityCard.tsx
@@ -0,0 +1,69 @@
+import { AccordionContent, AccordionItem, AccordionTrigger, Badge } from 'ui'
+
+import { PERMISSION_MODE_LABEL } from '../../AccessToken.permissions'
+import type { EntryAccess } from '../../AccessToken.roles'
+import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary'
+import { ExceedsRoleBadge } from '../ExceedsRoleBadge'
+import { CapabilityCardBody } from './CapabilityCardBody'
+import { pluralize } from '@/lib/helpers'
+
+interface CapabilityCardProps {
+ capability: CapabilitySummaryEntry
+ /** Accordion tiers wrap the header in a trigger button; the ≤2 tier renders it inert. */
+ collapsible: boolean
+ accessEntries: Record
+}
+
+const CapabilityCardHeader = ({
+ capability,
+ accessEntries,
+}: Pick) => {
+ const { entry, mode, endpoints, mcpTools } = capability
+ const entryAccess = accessEntries[entry.key]
+
+ return (
+
+
+ {entry.name}
+
+ {PERMISSION_MODE_LABEL[mode]}
+
+ {entryAccess?.status === 'exceeds-role' && (
+
+ )}
+
+
+ {endpoints.length} {pluralize(endpoints.length, 'endpoint')}
+ {mcpTools.length > 0 && ` · ${mcpTools.length} ${pluralize(mcpTools.length, 'tool')}`}
+
+
+ )
+}
+
+export const CapabilityCard = ({ capability, collapsible, accessEntries }: CapabilityCardProps) => {
+ const body = (
+
+ )
+
+ if (!collapsible) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ {body}
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityCardBody.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityCardBody.tsx
new file mode 100644
index 00000000000..988f8a2a61b
--- /dev/null
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityCardBody.tsx
@@ -0,0 +1,99 @@
+import { useState } from 'react'
+import { cn } from 'ui'
+
+import { EndpointRow } from './EndpointRow'
+import { MAX_VISIBLE_ENDPOINT_ROWS } from './TokenCapabilities.constants'
+import { getSharedPathPrefix } from './TokenCapabilities.utils'
+import type { EnabledEndpoint } from '@/data/scoped-access-tokens/permission-scope-map-query'
+
+interface CapabilityCardBodyProps {
+ endpoints: EnabledEndpoint[]
+ mcpTools: string[]
+}
+
+/**
+ * One bordered container holding two labelled, independently-tabled groups — endpoints and MCP
+ * tools — rather than a single table with interleaved header rows, per the two-table a11y pattern.
+ */
+export const CapabilityCardBody = ({ endpoints, mcpTools }: CapabilityCardBodyProps) => {
+ const [showAllEndpoints, setShowAllEndpoints] = useState(false)
+
+ if (endpoints.length === 0 && mcpTools.length === 0) {
+ return (
+
+ No Management API endpoints or MCP tools are enabled by this capability yet.
+
+ )
+ }
+
+ const sharedPrefix = getSharedPathPrefix(endpoints.map((endpoint) => endpoint.path))
+ const methodColumnWidth = `${Math.max(0, ...endpoints.map((endpoint) => endpoint.method.length)) + 2}ch`
+ const visibleEndpoints = endpoints.slice(
+ 0,
+ showAllEndpoints ? endpoints.length : MAX_VISIBLE_ENDPOINT_ROWS
+ )
+ const hiddenEndpointCount = endpoints.length - visibleEndpoints.length
+
+ return (
+
+ {endpoints.length > 0 && (
+
+
+
+ Management API endpoints
+
+ {endpoints.length}
+
+
+
+ {visibleEndpoints.map((endpoint) => (
+
+ ))}
+
+
+ {hiddenEndpointCount > 0 && (
+
+ )}
+
+ )}
+ {mcpTools.length > 0 && (
+
0 && 'border-t border-strong')}>
+
+
+ MCP tools
+
+ {mcpTools.length}
+
+
+
+ {mcpTools.map((tool) => (
+
+ |
+ {tool}
+ |
+ {/* No description field exists yet for MCP tools — stubbed rather than fabricated. */}
+
+ No description available
+ |
+
+ ))}
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/DenseCapabilities.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/DenseCapabilities.tsx
new file mode 100644
index 00000000000..12e54f94373
--- /dev/null
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/DenseCapabilities.tsx
@@ -0,0 +1,136 @@
+import { useState } from 'react'
+import { Accordion, Badge, Input, ToggleGroup, ToggleGroupItem } from 'ui'
+
+import type { EntryAccess } from '../../AccessToken.roles'
+import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary'
+import { CapabilityCard } from './CapabilityCard'
+import { DENSE_READONLY_PREVIEW_ROWS } from './TokenCapabilities.constants'
+import {
+ filterCapabilities,
+ getNotGrantedCatalogEntries,
+ groupCapabilitiesByLevel,
+ type CapabilityLevelFilter,
+} from './TokenCapabilities.utils'
+
+interface DenseCapabilitiesProps {
+ capabilities: CapabilitySummaryEntry[]
+ accessEntries: Record
+}
+
+/**
+ * 9+ capabilities: a text/path filter and read/read-write segmented control narrow a level-grouped
+ * list. Read-write is pinned first and never truncated; read-only previews a few rows. A path match
+ * auto-expands its parent capability on top of whatever the user has manually opened.
+ */
+export const DenseCapabilities = ({ capabilities, accessEntries }: DenseCapabilitiesProps) => {
+ const [query, setQuery] = useState('')
+ const [levelFilter, setLevelFilter] = useState('all')
+ const [manuallyOpenKeys, setManuallyOpenKeys] = useState([])
+ const [showAllReadOnly, setShowAllReadOnly] = useState(false)
+
+ const filtered = filterCapabilities(capabilities, query, levelFilter)
+ const forcedOpenKeys = filtered
+ .filter((match) => match.matchedByPath)
+ .map((match) => match.capability.entry.key)
+ const openKeys = Array.from(new Set([...manuallyOpenKeys, ...forcedOpenKeys]))
+
+ const { readwrite, read } = groupCapabilitiesByLevel(filtered.map((match) => match.capability))
+ const notGranted = getNotGrantedCatalogEntries(capabilities)
+
+ const visibleRead = showAllReadOnly ? read : read.slice(0, DENSE_READONLY_PREVIEW_ROWS)
+ const hiddenReadCount = read.length - visibleRead.length
+
+ return (
+
+
+ setQuery(event.target.value)}
+ className="max-w-xs"
+ />
+ {
+ if (value) setLevelFilter(value as CapabilityLevelFilter)
+ }}
+ >
+ All
+ Read
+ Read-write
+
+
+
+ {readwrite.length === 0 && read.length === 0 && (
+
No capabilities match your filter.
+ )}
+
+
+ {readwrite.length > 0 && (
+
+
+ Read-write · {readwrite.length}
+
+
+ {readwrite.map((capability) => (
+
+ ))}
+
+
+ )}
+
+ {read.length > 0 && (
+
+
+ Read-only · {read.length}
+
+
+ {visibleRead.map((capability) => (
+
+ ))}
+
+ {hiddenReadCount > 0 && (
+
+ )}
+
+ )}
+
+
+ {notGranted.length > 0 && (
+
+
+ Not granted · {notGranted.length}
+
+
+ {notGranted.map((entry) => (
+
+ {entry.name}
+
+ ))}
+
+
+ )}
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/EndpointRow.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/EndpointRow.tsx
new file mode 100644
index 00000000000..92527243027
--- /dev/null
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/EndpointRow.tsx
@@ -0,0 +1,47 @@
+import { Badge } from 'ui'
+
+import { splitEndpointPath } from './TokenCapabilities.utils'
+
+interface EndpointRowProps {
+ method: string
+ path: string
+ /** Shared leading segments across the group, rendered muted ahead of the distinguishing part. */
+ sharedPrefix: string
+ /** Sized by the caller for the longest method present in the group. */
+ methodColumnWidth: string
+}
+
+/**
+ * The muted prefix span shrinks with an end-ellipsis while the distinguishing segment stays
+ * fixed-width — visually equivalent to truncating the full path in its middle, without needing to
+ * measure pixel widths.
+ */
+export const EndpointRow = ({
+ method,
+ path,
+ sharedPrefix,
+ methodColumnWidth,
+}: EndpointRowProps) => {
+ const { prefix, distinguishing } = splitEndpointPath(path, sharedPrefix)
+ const isMutating = method !== 'GET'
+
+ return (
+
+ |
+ {isMutating ? (
+ {method}
+ ) : (
+ {method}
+ )}
+ |
+
+
+ {prefix !== '' && (
+ {prefix}
+ )}
+ {distinguishing}
+
+ |
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/RiskBanner.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/RiskBanner.tsx
new file mode 100644
index 00000000000..c4f50328f1a
--- /dev/null
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/RiskBanner.tsx
@@ -0,0 +1,27 @@
+import { Admonition } from 'ui-patterns/Admonition'
+import type { AdmonitionType } from 'ui-patterns/Admonition'
+
+import type { RiskBannerResult } from './TokenCapabilities.utils'
+
+const TONE_TO_ADMONITION_TYPE: Record = {
+ default: 'default',
+ low: 'success',
+ medium: 'warning',
+ high: 'destructive',
+}
+
+interface RiskBannerProps {
+ risk: RiskBannerResult
+ /** True when some selected permissions exceed the owner's role, so the risk is role-capped. */
+ showRoleCaveat: boolean
+}
+
+export const RiskBanner = ({ risk, showRoleCaveat }: RiskBannerProps) => (
+
+ {showRoleCaveat && 'Based on what your current role allows this token to do.'}
+
+)
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.constants.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.constants.ts
new file mode 100644
index 00000000000..dc873289023
--- /dev/null
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.constants.ts
@@ -0,0 +1,12 @@
+/**
+ * Density-tier thresholds for the capabilities section, keyed to the number of granted
+ * capabilities. Provisional — revisit with product/design once real tokens exercise them.
+ */
+export const CAPABILITY_DENSITY_EXPANDED_MAX = 2
+export const CAPABILITY_DENSITY_ACCORDION_MAX = 8
+
+/** Endpoint rows shown per capability before collapsing behind "Show all N". */
+export const MAX_VISIBLE_ENDPOINT_ROWS = 8
+
+/** Read-only capability rows shown in dense mode before "Show N more". */
+export const DENSE_READONLY_PREVIEW_ROWS = 3
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.utils.test.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.utils.test.ts
new file mode 100644
index 00000000000..a2491cc0839
--- /dev/null
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.utils.test.ts
@@ -0,0 +1,240 @@
+import { describe, expect, it } from 'vitest'
+
+import { getCatalogEntry } from '../../AccessToken.permissions'
+import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary'
+import {
+ computeRiskBanner,
+ filterCapabilities,
+ getCapabilityDensityTier,
+ getNotGrantedCatalogEntries,
+ getSharedPathPrefix,
+ groupCapabilitiesByLevel,
+ splitEndpointPath,
+} from './TokenCapabilities.utils'
+
+const endpoint = (method: string, path: string) => ({ method, path, raw: `${method} ${path}` })
+
+// 'project:database' is catalog-high and writable; 'project:advisors' is catalog-low and read-only.
+const databaseCapability = (
+ mode: CapabilitySummaryEntry['mode'],
+ endpoints: CapabilitySummaryEntry['endpoints'] = []
+): CapabilitySummaryEntry => ({
+ entry: getCatalogEntry('project:database')!,
+ mode,
+ endpoints,
+ mcpTools: [],
+})
+
+const advisorsCapability = (mode: CapabilitySummaryEntry['mode']): CapabilitySummaryEntry => ({
+ entry: getCatalogEntry('project:advisors')!,
+ mode,
+ endpoints: [],
+ mcpTools: [],
+})
+
+describe('getCapabilityDensityTier', () => {
+ it('is expanded at 2 or fewer capabilities', () => {
+ expect(getCapabilityDensityTier(0)).toBe('expanded')
+ expect(getCapabilityDensityTier(2)).toBe('expanded')
+ })
+
+ it('is accordion between 3 and 8 capabilities', () => {
+ expect(getCapabilityDensityTier(3)).toBe('accordion')
+ expect(getCapabilityDensityTier(8)).toBe('accordion')
+ })
+
+ it('is dense at 9 or more capabilities', () => {
+ expect(getCapabilityDensityTier(9)).toBe('dense')
+ expect(getCapabilityDensityTier(50)).toBe('dense')
+ })
+})
+
+describe('getSharedPathPrefix', () => {
+ it('returns nothing for a single endpoint — there is nothing to share', () => {
+ expect(getSharedPathPrefix(['/v1/projects/{ref}'])).toBe('')
+ })
+
+ it('finds the longest shared leading segments across paths', () => {
+ expect(
+ getSharedPathPrefix(['/v1/projects/{ref}/functions', '/v1/projects/{ref}/functions/{slug}'])
+ ).toBe('/v1/projects/{ref}/functions/')
+ })
+
+ it('never cuts a shared prefix mid-segment', () => {
+ // "functions" and "functions-secrets" share characters but not a path segment.
+ expect(
+ getSharedPathPrefix(['/v1/projects/{ref}/functions', '/v1/projects/{ref}/functions-secrets'])
+ ).toBe('/v1/projects/{ref}/')
+ })
+
+ it('shares only the common leading segments, not any further', () => {
+ expect(getSharedPathPrefix(['/v1/branches', '/v1/organizations'])).toBe('/v1/')
+ })
+
+ it('returns nothing when paths share no leading segment at all', () => {
+ expect(getSharedPathPrefix(['/v1/branches', '/v2/organizations'])).toBe('')
+ })
+
+ it('keeps at least the last segment distinguishing when every path is identical', () => {
+ expect(getSharedPathPrefix(['/v1/projects/{ref}', '/v1/projects/{ref}'])).toBe('/v1/projects/')
+ })
+})
+
+describe('splitEndpointPath', () => {
+ it('splits off the shared prefix when the path starts with it', () => {
+ expect(splitEndpointPath('/v1/projects/{ref}/functions', '/v1/projects/{ref}/')).toEqual({
+ prefix: '/v1/projects/{ref}/',
+ distinguishing: 'functions',
+ })
+ })
+
+ it('treats the whole path as distinguishing when there is no shared prefix', () => {
+ expect(splitEndpointPath('/v1/projects/{ref}', '')).toEqual({
+ prefix: '',
+ distinguishing: '/v1/projects/{ref}',
+ })
+ })
+})
+
+describe('groupCapabilitiesByLevel', () => {
+ it('splits granted capabilities into read-write and read-only', () => {
+ const capabilities = [databaseCapability('readwrite'), advisorsCapability('read')]
+
+ const { readwrite, read } = groupCapabilitiesByLevel(capabilities)
+ expect(readwrite.map((c) => c.entry.key)).toEqual(['project:database'])
+ expect(read.map((c) => c.entry.key)).toEqual(['project:advisors'])
+ })
+})
+
+describe('getNotGrantedCatalogEntries', () => {
+ it('returns every catalog entry when nothing is granted', () => {
+ expect(getNotGrantedCatalogEntries([]).map((e) => e.key)).toContain('project:database')
+ })
+
+ it('excludes granted entries', () => {
+ const notGranted = getNotGrantedCatalogEntries([databaseCapability('read')])
+ expect(notGranted.map((e) => e.key)).not.toContain('project:database')
+ })
+})
+
+describe('filterCapabilities', () => {
+ const capabilities = [
+ databaseCapability('readwrite', [endpoint('GET', '/v1/projects/{ref}/database')]),
+ advisorsCapability('read'),
+ ]
+
+ it('returns everything, unmatched by path, when the query is empty', () => {
+ const result = filterCapabilities(capabilities, '', 'all')
+ expect(result.map((r) => r.capability.entry.key)).toEqual([
+ 'project:database',
+ 'project:advisors',
+ ])
+ expect(result.every((r) => !r.matchedByPath)).toBe(true)
+ })
+
+ it('matches by capability name', () => {
+ const result = filterCapabilities(capabilities, 'advisors', 'all')
+ expect(result.map((r) => r.capability.entry.key)).toEqual(['project:advisors'])
+ expect(result[0].matchedByPath).toBe(false)
+ })
+
+ it('matches by endpoint path and flags it as a path match', () => {
+ const result = filterCapabilities(capabilities, '/database', 'all')
+ expect(result.map((r) => r.capability.entry.key)).toEqual(['project:database'])
+ expect(result[0].matchedByPath).toBe(true)
+ })
+
+ it('applies the level filter before matching', () => {
+ expect(filterCapabilities(capabilities, '', 'read')).toHaveLength(1)
+ expect(filterCapabilities(capabilities, '', 'readwrite')).toHaveLength(1)
+ })
+
+ it('drops capabilities matching neither the name nor any endpoint path', () => {
+ expect(filterCapabilities(capabilities, 'storage', 'all')).toEqual([])
+ })
+})
+
+describe('computeRiskBanner', () => {
+ it('reports Minimal with no active capabilities', () => {
+ const risk = computeRiskBanner({
+ effectiveSelection: {},
+ resourceAccess: 'project',
+ organizationSlugs: [],
+ projectRefs: ['proj-1'],
+ })
+ expect(risk).toEqual({ level: 'Minimal', tone: 'default', summary: 'No capabilities granted.' })
+ })
+
+ it('downgrades a read-only grant so it never outranks read-write on a lower-risk resource', () => {
+ // project:database is catalog-high; read-only downgrades it to medium.
+ const risk = computeRiskBanner({
+ effectiveSelection: { 'project:database': 'read' },
+ resourceAccess: 'project',
+ organizationSlugs: [],
+ projectRefs: ['proj-1'],
+ })
+ expect(risk.level).toBe('Medium')
+ })
+
+ it('takes the max risk across capabilities, not just a write flag', () => {
+ const risk = computeRiskBanner({
+ effectiveSelection: { 'project:database': 'readwrite', 'project:advisors': 'read' },
+ resourceAccess: 'project',
+ organizationSlugs: [],
+ projectRefs: ['proj-1'],
+ })
+ expect(risk.level).toBe('High')
+ })
+
+ it('escalates account-wide read-write access to High', () => {
+ const risk = computeRiskBanner({
+ effectiveSelection: { 'project:advisors': 'readwrite' },
+ resourceAccess: 'account',
+ organizationSlugs: [],
+ projectRefs: [],
+ })
+ expect(risk.level).toBe('High')
+ expect(risk.summary).toContain('across your entire account')
+ })
+
+ it('escalates project scope spanning many bound projects', () => {
+ const narrow = computeRiskBanner({
+ effectiveSelection: { 'project:advisors': 'read' },
+ resourceAccess: 'project',
+ organizationSlugs: [],
+ projectRefs: ['a'],
+ })
+ const broad = computeRiskBanner({
+ effectiveSelection: { 'project:advisors': 'read' },
+ resourceAccess: 'project',
+ organizationSlugs: [],
+ projectRefs: ['a', 'b', 'c', 'd', 'e', 'f'],
+ })
+ expect(narrow.level).toBe('Low')
+ expect(broad.level).toBe('Medium')
+ })
+
+ it('summarizes mixed read and read-write grants with explicit scope breadth', () => {
+ const risk = computeRiskBanner({
+ effectiveSelection: {
+ 'project:database': 'readwrite',
+ 'project:advisors': 'read',
+ 'project:storage': 'read',
+ },
+ resourceAccess: 'project',
+ organizationSlugs: [],
+ projectRefs: ['a', 'b', 'c'],
+ })
+ expect(risk.summary).toBe('Read-write on 1 capability, read on 2, across 3 projects.')
+ })
+
+ it('states scope breadth even when nothing is bound', () => {
+ const risk = computeRiskBanner({
+ effectiveSelection: { 'project:advisors': 'read' },
+ resourceAccess: 'project',
+ organizationSlugs: [],
+ projectRefs: [],
+ })
+ expect(risk.summary).toContain('with no projects bound')
+ })
+})
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.utils.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.utils.ts
new file mode 100644
index 00000000000..52f136316bb
--- /dev/null
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.utils.ts
@@ -0,0 +1,180 @@
+import {
+ getCatalogEntry,
+ PERMISSION_CATALOG,
+ type PermissionCatalogEntry,
+ type PermissionSelection,
+ type ResourceAccessMode,
+ type RiskLevel,
+} from '../../AccessToken.permissions'
+import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary'
+import {
+ CAPABILITY_DENSITY_ACCORDION_MAX,
+ CAPABILITY_DENSITY_EXPANDED_MAX,
+} from './TokenCapabilities.constants'
+import { pluralize } from '@/lib/helpers'
+
+export type CapabilityDensityTier = 'expanded' | 'accordion' | 'dense'
+
+export const getCapabilityDensityTier = (count: number): CapabilityDensityTier => {
+ if (count <= CAPABILITY_DENSITY_EXPANDED_MAX) return 'expanded'
+ if (count <= CAPABILITY_DENSITY_ACCORDION_MAX) return 'accordion'
+ return 'dense'
+}
+
+/**
+ * Longest shared leading path segments across a group of endpoint paths, so the UI can mute the
+ * boilerplate prefix and highlight only the segment that distinguishes each row. Matching is
+ * segment-aware (split on "/") so a shared prefix never cuts a path mid-segment. A single-endpoint
+ * group has nothing to share, so the whole path is treated as the distinguishing part.
+ */
+export const getSharedPathPrefix = (paths: string[]): string => {
+ if (paths.length < 2) return ''
+
+ const segmentLists = paths.map((path) => path.split('/'))
+ const [first, ...rest] = segmentLists
+ let matched = 0
+ while (matched < first.length && rest.every((segments) => segments[matched] === first[matched])) {
+ matched++
+ }
+ // Every path is identical (not just the shortest one fully consumed as a prefix of a longer
+ // one) — back off one segment so each path keeps at least the last as its distinguishing part.
+ if (segmentLists.every((segments) => segments.length === matched)) matched -= 1
+ // matched === 1 only captures the empty segment before the leading "/", shared trivially by
+ // every absolute path — not a meaningful prefix.
+ if (matched <= 1) return ''
+ return first.slice(0, matched).join('/') + '/'
+}
+
+export const splitEndpointPath = (path: string, sharedPrefix: string) =>
+ sharedPrefix !== '' && path.startsWith(sharedPrefix)
+ ? { prefix: sharedPrefix, distinguishing: path.slice(sharedPrefix.length) }
+ : { prefix: '', distinguishing: path }
+
+export const groupCapabilitiesByLevel = (capabilities: CapabilitySummaryEntry[]) => ({
+ readwrite: capabilities.filter((capability) => capability.mode === 'readwrite'),
+ read: capabilities.filter((capability) => capability.mode === 'read'),
+})
+
+/** Catalog entries the token doesn't grant at all — dense mode's "Not granted" group. */
+export const getNotGrantedCatalogEntries = (
+ capabilities: CapabilitySummaryEntry[]
+): PermissionCatalogEntry[] => {
+ const grantedKeys = new Set(capabilities.map((capability) => capability.entry.key))
+ return PERMISSION_CATALOG.filter((entry) => !grantedKeys.has(entry.key))
+}
+
+export type CapabilityLevelFilter = 'all' | 'read' | 'readwrite'
+
+export interface FilteredCapability {
+ capability: CapabilitySummaryEntry
+ /** True when the match came from an endpoint path rather than just the capability name. */
+ matchedByPath: boolean
+}
+
+/** Local, in-memory filter for dense mode: matches capability name or any enabled endpoint path. */
+export const filterCapabilities = (
+ capabilities: CapabilitySummaryEntry[],
+ query: string,
+ levelFilter: CapabilityLevelFilter
+): FilteredCapability[] => {
+ const normalizedQuery = query.trim().toLowerCase()
+
+ return capabilities
+ .filter((capability) => levelFilter === 'all' || capability.mode === levelFilter)
+ .flatMap((capability) => {
+ if (normalizedQuery === '') return [{ capability, matchedByPath: false }]
+
+ const nameMatches = capability.entry.name.toLowerCase().includes(normalizedQuery)
+ const matchedByPath = capability.endpoints.some((endpoint) =>
+ endpoint.path.toLowerCase().includes(normalizedQuery)
+ )
+ if (!nameMatches && !matchedByPath) return []
+ return [{ capability, matchedByPath }]
+ })
+}
+
+const RISK_RANK: Record = { low: 1, medium: 2, high: 3 }
+const RANK_TO_RISK: Record = { 1: 'low', 2: 'medium', 3: 'high' }
+
+/** One severity tier down, floored at 'low' — a read-only grant never outranks a read-write one. */
+const downgradeRisk = (risk: RiskLevel): RiskLevel => RANK_TO_RISK[Math.max(1, RISK_RANK[risk] - 1)]
+
+export interface RiskBannerResult {
+ level: 'Minimal' | 'Low' | 'Medium' | 'High'
+ tone: 'default' | 'low' | 'medium' | 'high'
+ summary: string
+}
+
+/**
+ * Computes the risk banner from the grant itself, never from a stored string. Severity is a max()
+ * over every granted capability's catalog risk — downgraded a tier for read-only grants so a
+ * read-only high-risk resource never outranks read-write on a medium one — then escalated for
+ * account-wide tokens and resource bindings spanning many orgs/projects.
+ */
+export const computeRiskBanner = ({
+ effectiveSelection,
+ resourceAccess,
+ organizationSlugs,
+ projectRefs,
+}: {
+ effectiveSelection: PermissionSelection
+ resourceAccess: ResourceAccessMode
+ organizationSlugs: string[]
+ projectRefs: string[]
+}): RiskBannerResult => {
+ const active = Object.entries(effectiveSelection).filter(([, mode]) => mode !== 'none')
+
+ if (active.length === 0) {
+ return { level: 'Minimal', tone: 'default', summary: 'No capabilities granted.' }
+ }
+
+ const readWriteCount = active.filter(([, mode]) => mode === 'readwrite').length
+ const readCount = active.length - readWriteCount
+
+ const maxRisk = active.reduce((max, [key, mode]) => {
+ const entry = getCatalogEntry(key)
+ if (!entry) return max
+ const effectiveRisk: RiskLevel = mode === 'readwrite' ? entry.risk : downgradeRisk(entry.risk)
+ return RISK_RANK[effectiveRisk] > RISK_RANK[max] ? effectiveRisk : max
+ }, 'low')
+
+ let rank = RISK_RANK[maxRisk]
+ if (resourceAccess === 'account') {
+ rank = Math.max(rank, RISK_RANK.medium) + (readWriteCount > 0 ? 1 : 0)
+ } else if (resourceAccess === 'organization' && organizationSlugs.length > 3) {
+ rank += 1
+ } else if (resourceAccess === 'project' && projectRefs.length > 5) {
+ rank += 1
+ }
+ rank = Math.min(rank, RISK_RANK.high)
+
+ const level = rank === RISK_RANK.high ? 'High' : rank === RISK_RANK.medium ? 'Medium' : 'Low'
+ const tone = rank === RISK_RANK.high ? 'high' : rank === RISK_RANK.medium ? 'medium' : 'low'
+
+ const resourceNoun = resourceAccess === 'organization' ? 'organization' : 'project'
+ const boundCount =
+ resourceAccess === 'organization' ? organizationSlugs.length : projectRefs.length
+ const scopeText =
+ resourceAccess === 'account'
+ ? 'across your entire account'
+ : boundCount === 0
+ ? `with no ${resourceNoun}s bound`
+ : `across ${boundCount} ${pluralize(boundCount, resourceNoun)}`
+
+ const segments: string[] = []
+ if (readWriteCount > 0) {
+ segments.push(
+ `read-write on ${readWriteCount} ${pluralize(readWriteCount, 'capability', 'capabilities')}`
+ )
+ }
+ if (readCount > 0) {
+ segments.push(
+ segments.length === 0
+ ? `read on ${readCount} ${pluralize(readCount, 'capability', 'capabilities')}`
+ : `read on ${readCount}`
+ )
+ }
+ const sentence = `${segments.join(', ')}, ${scopeText}.`
+
+ return { level, tone, summary: sentence.charAt(0).toUpperCase() + sentence.slice(1) }
+}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx
index 97e970b6c51..bf1e4261e6f 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx
@@ -1,4 +1,4 @@
-import { screen } from '@testing-library/react'
+import { fireEvent, screen } from '@testing-library/react'
import { platformComponents as components } from 'api-types'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
import {
MOCK_ORG,
+ MOCK_PROJECT,
mockPermissionsApi,
mockScopedTokenEnvironment,
ownerRows,
@@ -140,4 +141,100 @@ describe('ViewTokenSheet', () => {
).toBeGreaterThan(0)
expect(screen.queryByText('This token no longer has access')).toBeNull()
})
+
+ test('renders capability cards with attributed endpoints, MCP tools, and a risk banner', async () => {
+ mockPermissionsApi(ownerRows(MOCK_ORG.slug))
+ mockToken({
+ ...TOKEN_BASE,
+ scope: 'project',
+ project_refs: [MOCK_PROJECT.ref],
+ permissions: ['advisors_read', 'database_read', 'database_write'],
+ })
+ addAPIMock({
+ method: 'get',
+ // @ts-expect-error Studio API is missing from types
+ path: '/scoped-access-token-permissions',
+ response: () =>
+ HttpResponse.json({
+ scopes: {},
+ endpoints: {
+ 'GET /v1/projects/{ref}/advisors/security': [['advisors_read']],
+ 'GET /v1/projects/{ref}/database': [['database_read']],
+ 'POST /v1/projects/{ref}/database/query': [['database_write']],
+ },
+ mcp_tools: {
+ get_advisors: [['advisors_read']],
+ execute_sql: [['database_write']],
+ },
+ }),
+ })
+ renderSheet()
+
+ // ≤2 capabilities render fully expanded — both cards are visible without interaction.
+ expect(await screen.findByText('Advisors')).toBeInTheDocument()
+ expect(screen.getByText('Database')).toBeInTheDocument()
+ expect(screen.getByText('Read-write')).toBeInTheDocument()
+ expect(screen.getByText('Read')).toBeInTheDocument()
+
+ expect(screen.getByTitle('/v1/projects/{ref}/advisors/security')).toBeInTheDocument()
+ expect(screen.getByTitle('/v1/projects/{ref}/database')).toBeInTheDocument()
+ expect(screen.getByTitle('/v1/projects/{ref}/database/query')).toBeInTheDocument()
+ // Non-GET methods get a tinted badge; GET stays plain — both still render as text.
+ expect(screen.getByText('POST')).toBeInTheDocument()
+
+ expect(screen.getByText('get_advisors')).toBeInTheDocument()
+ expect(screen.getByText('execute_sql')).toBeInTheDocument()
+ // MCP tools have no description field yet — stubbed visibly rather than fabricated.
+ expect(screen.getAllByText('No description available').length).toBe(2)
+
+ // project:database is catalog-high risk and granted read-write — max() over capabilities.
+ expect(screen.getByText('High risk')).toBeInTheDocument()
+ expect(
+ screen.getByText('Read-write on 1 capability, read on 1, across 1 project.')
+ ).toBeInTheDocument()
+ })
+
+ test('switches to the dense, filterable view at 9+ granted capabilities', async () => {
+ mockPermissionsApi(ownerRows(MOCK_ORG.slug))
+ mockToken({
+ ...TOKEN_BASE,
+ scope: 'project',
+ project_refs: [MOCK_PROJECT.ref],
+ permissions: [
+ 'advisors_read',
+ 'database_read',
+ 'database_write',
+ 'backups_read',
+ 'custom_domain_read',
+ 'edge_functions_read',
+ 'storage_read',
+ 'realtime_config_read',
+ 'vanity_subdomain_read',
+ 'infra_add_ons_read',
+ ],
+ })
+ renderSheet()
+
+ expect(
+ await screen.findByPlaceholderText('Filter by capability or endpoint...')
+ ).toBeInTheDocument()
+ expect(screen.getByText('Read-write · 1')).toBeInTheDocument()
+ expect(screen.getByText('Read-only · 8')).toBeInTheDocument()
+ expect(screen.getByText(/Not granted · \d+/)).toBeInTheDocument()
+ // Read-only previews only 3 of the 8 rows until "Show N more" is clicked.
+ expect(screen.getByText('Show 5 more')).toBeInTheDocument()
+ expect(screen.queryByText('Storage')).toBeNull()
+
+ fireEvent.click(screen.getByText('Show 5 more'))
+ expect(screen.getByText('Storage')).toBeInTheDocument()
+ expect(screen.getByText('Backups')).toBeInTheDocument()
+
+ // Filtering re-derives the read-only bucket, so a narrowed result isn't re-truncated.
+ fireEvent.change(screen.getByPlaceholderText('Filter by capability or endpoint...'), {
+ target: { value: 'storage' },
+ })
+
+ expect(screen.getByText('Storage')).toBeInTheDocument()
+ expect(screen.queryByText('Backups')).toBeNull()
+ })
})
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx
index edb9183e456..30cb1650cc1 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx
@@ -5,16 +5,14 @@ import { Admonition } from 'ui-patterns/Admonition'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { TOKEN_DENIED_REMEDIATION } from '../AccessToken.constants'
-import {
- computeOverallRisk,
- PERMISSION_MODE_LABEL,
- scopesToSelection,
- type ResourceAccessMode,
-} from '../AccessToken.permissions'
+import { scopesToSelection, type ResourceAccessMode } from '../AccessToken.permissions'
import { useCapabilitySummary } from '../hooks/useCapabilitySummary'
import { useOrgAndProjectData } from '../hooks/useOrgAndProjectData'
import { useTokenAccessEvaluation } from '../hooks/useTokenAccessEvaluation'
-import { CapabilityCategoryList, ResourceSummaryItem, RiskLevelSummary } from './TokenSummaryRows'
+import { CapabilitiesSection } from './TokenCapabilities/CapabilitiesSection'
+import { RiskBanner } from './TokenCapabilities/RiskBanner'
+import { computeRiskBanner } from './TokenCapabilities/TokenCapabilities.utils'
+import { ResourceSummaryItem } from './TokenSummaryRows'
import { DocsButton } from '@/components/ui/DocsButton'
import { useGetEnabledEndpointsForCapability } from '@/data/scoped-access-tokens/permission-scope-map-query'
import { useScopedAccessTokenQuery } from '@/data/scoped-access-tokens/scoped-access-token-query'
@@ -82,13 +80,17 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
const boundResourcesDeletedText = `Every ${resourceNoun} this token was bound to has been deleted`
const risk = useMemo(
- () => computeOverallRisk(access.effectiveSelection, resourceAccess),
- [access.effectiveSelection, resourceAccess]
+ () =>
+ computeRiskBanner({
+ effectiveSelection: access.effectiveSelection,
+ resourceAccess,
+ organizationSlugs: tokenOrganizationSlugs,
+ projectRefs: tokenProjectRefs,
+ }),
+ [access.effectiveSelection, resourceAccess, tokenOrganizationSlugs, tokenProjectRefs]
)
- const hasCapabilities = grantedScopes.length > 0
-
- const { activeByCategory, mcpTools, capabilityGroups } = useCapabilitySummary({
+ const { capabilities } = useCapabilitySummary({
selection,
grantedScopes,
permissionScopeMap,
@@ -222,18 +224,6 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
,
],
- [
- 'Capabilities',
- hasCapabilities ? (
-
- ) : (
- No capabilities selected
- ),
- ],
- [
- 'Risk level',
- ,
- ],
]
: []
@@ -300,6 +290,9 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
description="A token only works with permissions you currently hold. Permissions marked below will be denied until your role includes them."
/>
)}
+
+
+
Token summary
@@ -312,62 +305,10 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
- {hasCapabilities && (
- <>
-
-
Management API endpoints enabled
- {capabilityGroups.length === 0 ? (
-
- No Management API endpoints are enabled by the selected capabilities.
-
- ) : (
- capabilityGroups.map(({ entry, mode, endpoints }) => (
-
-
- {entry.name}
-
- {PERMISSION_MODE_LABEL[mode]}
-
-
-
- {endpoints.map(([method, path]) => (
-
-
- {method}
-
- {path}
-
- ))}
-
-
- ))
- )}
-
-
-
-
MCP tools
- {mcpTools.length === 0 ? (
-
- No MCP tools are enabled by the selected capabilities.
-
- ) : (
-
- {mcpTools.map((tool) => (
-
- {tool}
-
- ))}
-
- )}
-
- >
- )}
+
+
Capabilities
+
+
>
)}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts
index 76b78959d4a..891a56cc2ef 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts
+++ b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts
@@ -2,15 +2,16 @@ import { useMemo } from 'react'
import {
getEntryScopes,
- PERMISSION_CATALOG_BY_CATEGORY,
+ PERMISSION_CATALOG,
type PermissionCatalogEntry,
type PermissionMode,
type PermissionSelection,
} from '../AccessToken.permissions'
import {
getEnabledEndpointsForCapability,
- getEnabledMcpTools,
- PermissionScopeMap,
+ getEnabledMcpToolsForCapability,
+ type EnabledEndpoint,
+ type PermissionScopeMap,
} from '@/data/scoped-access-tokens/permission-scope-map-query'
interface UseCapabilitySummaryArgs {
@@ -19,49 +20,43 @@ interface UseCapabilitySummaryArgs {
permissionScopeMap: PermissionScopeMap | undefined
}
+export interface CapabilitySummaryEntry {
+ entry: PermissionCatalogEntry
+ mode: PermissionMode
+ endpoints: EnabledEndpoint[]
+ mcpTools: string[]
+}
+
/**
- * Selection-derived summary data for the token view sheet: selected entries grouped by catalog
- * category, the Management API endpoints each capability enables, and the enabled MCP tools.
+ * Selection-derived summary data for the token view sheet: every granted catalog entry paired with
+ * the Management API endpoints and MCP tools it enables.
*/
export const useCapabilitySummary = ({
selection,
grantedScopes,
permissionScopeMap,
}: UseCapabilitySummaryArgs) => {
- const activeByCategory = useMemo(
- () =>
- PERMISSION_CATALOG_BY_CATEGORY.map((category) => ({
- ...category,
- entries: category.entries
- .map((entry) => ({ entry, mode: selection[entry.key] ?? 'none' }))
- .filter(({ mode }) => mode !== 'none'),
- })).filter((category) => category.entries.length > 0),
- [selection]
- )
+ const capabilities = useMemo(() => {
+ const result: CapabilitySummaryEntry[] = []
+ for (const entry of PERMISSION_CATALOG) {
+ const mode = selection[entry.key] ?? 'none'
+ if (mode === 'none') continue
- const mcpTools = useMemo(
- () => getEnabledMcpTools({ grantedScopes, permissionScopeMap }),
- [grantedScopes, permissionScopeMap]
- )
-
- const capabilityGroups = useMemo(() => {
- const groups: { entry: PermissionCatalogEntry; mode: PermissionMode; endpoints: string[][] }[] =
- []
- for (const category of activeByCategory) {
- for (const { entry, mode } of category.entries) {
- const capabilityScopes = getEntryScopes(entry, mode)
- const endpoints = getEnabledEndpointsForCapability({
- capabilityScopes,
- allGrantedScopes: grantedScopes,
- permissionScopeMap,
- })
- if (endpoints.length > 0) {
- groups.push({ entry, mode, endpoints: endpoints.map((e) => [e.method, e.path]) })
- }
- }
+ const capabilityScopes = getEntryScopes(entry, mode)
+ const endpoints = getEnabledEndpointsForCapability({
+ capabilityScopes,
+ allGrantedScopes: grantedScopes,
+ permissionScopeMap,
+ })
+ const mcpTools = getEnabledMcpToolsForCapability({
+ capabilityScopes,
+ allGrantedScopes: grantedScopes,
+ permissionScopeMap,
+ })
+ result.push({ entry, mode, endpoints, mcpTools })
}
- return groups
- }, [activeByCategory, grantedScopes, permissionScopeMap])
+ return result
+ }, [selection, grantedScopes, permissionScopeMap])
- return { activeByCategory, mcpTools, capabilityGroups }
+ return { capabilities }
}
diff --git a/apps/studio/data/scoped-access-tokens/permission-scope-map-query.ts b/apps/studio/data/scoped-access-tokens/permission-scope-map-query.ts
index 604cc8d68eb..c662c8f3667 100644
--- a/apps/studio/data/scoped-access-tokens/permission-scope-map-query.ts
+++ b/apps/studio/data/scoped-access-tokens/permission-scope-map-query.ts
@@ -154,6 +154,34 @@ export const getEnabledEndpointsForCapability = ({
.map(([raw]) => splitEndpoint(raw))
}
+/**
+ * MCP-tool counterpart to getEnabledEndpointsForCapability: the MCP tools enabled by the complete
+ * granted-scope set that owe that to `capabilityScopes`, for grouping enabled tools under the
+ * capability that contributes them.
+ */
+export const getEnabledMcpToolsForCapability = ({
+ capabilityScopes,
+ allGrantedScopes,
+ permissionScopeMap,
+}: {
+ capabilityScopes: Iterable
+ allGrantedScopes: Iterable
+ permissionScopeMap: PermissionScopeMap | undefined
+}): string[] => {
+ if (permissionScopeMap == null) return []
+
+ const granted = new Set(allGrantedScopes)
+ const capability = new Set(capabilityScopes)
+ return Object.entries(permissionScopeMap.mcp_tools)
+ .filter(([, groups]) =>
+ groups.some(
+ (group) =>
+ group.some((scope) => capability.has(scope)) && group.every((scope) => granted.has(scope))
+ )
+ )
+ .map(([tool]) => tool)
+}
+
/**
* Informational lookup for the per-permission risk tooltip: the MCP tools associated with any of
* the given scopes. Unlike getEnabledMcpTools this is not conjunctive — it surfaces every tool that