refactor(studio): fetch MCP tool→FGA map from mgmt-api endpoint (#49225)

Paired with
[supabase/platform#37175](https://github.com/supabase/platform/pull/37175).

Deletes the hand-maintained `MCPToolScopeMappings.ts` (the map is
currently 'manually extracted from the mcp controller' and drifts) and
fetches the `tool → FGA permission` map from the new mgmt-api `GET
/mcp-tools-permissions` endpoint, the same way it already fetches the
v1/v2 OpenAPI specs.

Closes AI-1016

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* MCP tool permissions are now fetched dynamically from the management
API and included in API permission mappings.
  * Permission data is validated before being applied.

* **Bug Fixes**
* Improved handling of invalid responses and request failures from the
MCP permissions service.
* Removed outdated bundled permission mappings, keeping access controls
aligned with current configuration.
* Updated permission mapping coverage to include both current API
specifications and MCP tools.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-08-26 14:00:28 +02:00
1 parent ff5376c9f0
commit 334b112e7a
3 files changed
+83 -333

No files matched your search

@@ -1,204 +0,0 @@
import { constants, permissions } from '@supabase/shared-types'
import { McpMap } from '@/data/scoped-access-tokens/permission-scope-map-query'
const { OAuthScope } = constants
type OAuthScopeValue = (typeof OAuthScope)[keyof typeof OAuthScope]
// Manually extracted from platform mcp controller code. Each tool maps to alternative OAuth-scope
// groups with the same semantics as ScopeGroupAlternatives: a token can call the tool when it
// holds ALL scopes of at least ONE group (OR between groups, AND within a group). Most tools
// assert a single scope; execute_sql asserts database:read OR database:write depending on the
// session's read_only mode (mcp.controller.ts), so it carries two alternatives.
const MCPToolOAuthScopeMapping: Record<string, OAuthScopeValue[][]> = {
apply_migration: [[OAuthScope.DATABASE_WRITE]],
// Computes a local confirmation hash without calling the platform — no scope gates it.
confirm_cost: [[]],
create_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
create_project: [[OAuthScope.PROJECTS_WRITE]],
delete_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
deploy_edge_function: [[OAuthScope.EDGE_FUNCTIONS_WRITE]],
execute_sql: [[OAuthScope.DATABASE_READ], [OAuthScope.DATABASE_WRITE]],
generate_typescript_types: [[OAuthScope.DATABASE_READ]],
get_advisors: [[OAuthScope.DATABASE_READ]],
// Calls getOrganization + listProjects to price a project, so it needs both read scopes.
// (The type=branch path returns a constant with no platform call; gating on the project
// path's scopes fails closed for branch-only pricing, which is fine for advisory display.)
get_cost: [[OAuthScope.ORGANIZATIONS_READ, OAuthScope.PROJECTS_READ]],
get_edge_function: [[OAuthScope.EDGE_FUNCTIONS_READ]],
get_logs: [[OAuthScope.ANALYTICS_READ]],
get_organization: [[OAuthScope.ORGANIZATIONS_READ]],
get_project: [[OAuthScope.PROJECTS_READ]],
get_project_url: [[OAuthScope.PROJECTS_READ]],
get_publishable_keys: [[OAuthScope.SECRETS_READ]],
get_storage_config: [[OAuthScope.STORAGE_READ]],
list_branches: [[OAuthScope.ENVIRONMENT_READ]],
list_edge_functions: [[OAuthScope.EDGE_FUNCTIONS_READ]],
// Runs through executeSql with read_only forced true.
list_extensions: [[OAuthScope.DATABASE_READ]],
list_migrations: [[OAuthScope.DATABASE_READ]],
list_organizations: [[OAuthScope.ORGANIZATIONS_READ]],
list_projects: [[OAuthScope.PROJECTS_READ]],
list_storage_buckets: [[OAuthScope.STORAGE_READ]],
// Runs through executeSql with read_only forced true.
list_tables: [[OAuthScope.DATABASE_READ]],
merge_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
pause_project: [[OAuthScope.PROJECTS_WRITE]],
rebase_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
reset_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
restore_project: [[OAuthScope.PROJECTS_WRITE]],
// Queries the public content API — no scope gates it.
search_docs: [[]],
update_storage_config: [[OAuthScope.STORAGE_WRITE]],
}
type ExtractIds<T> = {
[K in keyof T]: {
[P in keyof T[K]]: T[K][P] extends { id: infer I } ? I : never
}
}
const FGA_PERMISSIONS = Object.fromEntries(
Object.entries(permissions.FgaPermissions).map(([group, permissions]) => [
group,
Object.fromEntries(Object.entries(permissions).map(([key, { id }]) => [key, id])),
])
) as ExtractIds<typeof permissions.FgaPermissions>
// Duplicated from platform (packages/api-core/src/lib/permissions/fga-permissions.ts)
// Ideally, this could be exported from @supabase/shared-types
export const legacyOauthScopeToFgaPermissionMap: Record<string, string[]> = {
'analytics:read': [
FGA_PERMISSIONS.PROJECT.ANALYTICS_LOGS_READ,
FGA_PERMISSIONS.PROJECT.ANALYTICS_USAGE_READ,
],
'analytics:write': [],
'analytics_config:read': [FGA_PERMISSIONS.PROJECT.ANALYTICS_CONFIG_READ],
'analytics_config:write': [FGA_PERMISSIONS.PROJECT.ANALYTICS_CONFIG_WRITE],
'auth:read': [FGA_PERMISSIONS.PROJECT.AUTH_CONFIG_READ],
// Note(Hieu) Auth:write scope grants access to all auth config endpoints.
// However, one endpoint requires minimum administrator role, so this oauth scope must also include the FGA PROJECT.ADMIN_WRITE permission
'auth:write': [FGA_PERMISSIONS.PROJECT.ADMIN_WRITE, FGA_PERMISSIONS.PROJECT.AUTH_CONFIG_WRITE],
'database:read': [
FGA_PERMISSIONS.USER.SNIPPETS_READ,
FGA_PERMISSIONS.PROJECT.ADVISORS_READ,
FGA_PERMISSIONS.PROJECT.BACKUPS_READ,
FGA_PERMISSIONS.PROJECT.DATABASE_READ,
FGA_PERMISSIONS.PROJECT.DATABASE_CONFIG_READ,
FGA_PERMISSIONS.PROJECT.DATABASE_JIT_READ,
FGA_PERMISSIONS.PROJECT.DATABASE_MIGRATIONS_READ,
FGA_PERMISSIONS.PROJECT.DATABASE_POOLING_CONFIG_READ,
FGA_PERMISSIONS.PROJECT.DATABASE_READONLY_CONFIG_READ,
FGA_PERMISSIONS.PROJECT.DATABASE_SSL_CONFIG_READ,
FGA_PERMISSIONS.PROJECT.SNIPPETS_READ,
],
'database:write': [
FGA_PERMISSIONS.PROJECT.ADMIN_WRITE,
FGA_PERMISSIONS.PROJECT.BACKUPS_WRITE,
// Note(Hieu): Include database read permission here to align with the project query endpoint.
// RLS and FGA guard this endpoint with database read first, then perform an additional check for write queries.
// The OAuth guard requires database write directly, which causes a discrepancy error if we don't include read here.
FGA_PERMISSIONS.PROJECT.DATABASE_READ,
FGA_PERMISSIONS.PROJECT.DATABASE_WRITE,
FGA_PERMISSIONS.PROJECT.DATABASE_CONFIG_WRITE,
FGA_PERMISSIONS.PROJECT.DATABASE_MIGRATIONS_WRITE,
FGA_PERMISSIONS.PROJECT.DATABASE_POOLING_CONFIG_WRITE,
FGA_PERMISSIONS.PROJECT.DATABASE_READONLY_CONFIG_WRITE,
FGA_PERMISSIONS.PROJECT.DATABASE_SSL_CONFIG_WRITE,
FGA_PERMISSIONS.PROJECT.DATABASE_WEBHOOKS_CONFIG_WRITE,
],
'domains:read': [
FGA_PERMISSIONS.PROJECT.CUSTOM_DOMAIN_READ,
FGA_PERMISSIONS.PROJECT.VANITY_SUBDOMAIN_READ,
],
'domains:write': [
FGA_PERMISSIONS.PROJECT.CUSTOM_DOMAIN_WRITE,
FGA_PERMISSIONS.PROJECT.VANITY_SUBDOMAIN_WRITE,
],
'edge_functions:read': [FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_READ],
'edge_functions:write': [FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_WRITE],
'environment:read': [
FGA_PERMISSIONS.PROJECT.ACTION_RUNS_READ,
FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_READ,
FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_READ,
],
'environment:write': [
FGA_PERMISSIONS.PROJECT.ACTION_RUNS_WRITE,
FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_CREATE,
FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_DELETE,
FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_WRITE,
FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_CREATE,
FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_DELETE,
FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_WRITE,
],
'organizations:read': [
FGA_PERMISSIONS.USER.ORGANIZATIONS_READ,
FGA_PERMISSIONS.ORGANIZATION.ADMIN_READ,
FGA_PERMISSIONS.ORGANIZATION.MEMBERS_READ,
],
'organizations:write': [],
'projects:read': [
FGA_PERMISSIONS.USER.PROJECTS_READ,
FGA_PERMISSIONS.ORGANIZATION.PROJECTS_READ,
FGA_PERMISSIONS.PROJECT.ADMIN_READ,
FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_BANS_READ,
FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_RESTRICTIONS_READ,
],
'projects:write': [
FGA_PERMISSIONS.ORGANIZATION.ADMIN_WRITE,
FGA_PERMISSIONS.ORGANIZATION.PROJECTS_CREATE,
FGA_PERMISSIONS.PROJECT.ADMIN_WRITE,
FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_BANS_WRITE,
FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_RESTRICTIONS_WRITE,
],
'rest:read': [FGA_PERMISSIONS.PROJECT.DATA_API_CONFIG_READ],
'rest:write': [FGA_PERMISSIONS.PROJECT.DATA_API_CONFIG_WRITE],
'secrets:read': [
FGA_PERMISSIONS.PROJECT.API_GATEWAY_KEYS_READ,
FGA_PERMISSIONS.PROJECT.AUTH_SIGNING_KEYS_READ,
FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_SECRETS_READ,
],
'secrets:write': [
FGA_PERMISSIONS.PROJECT.API_GATEWAY_KEYS_WRITE,
FGA_PERMISSIONS.PROJECT.AUTH_SIGNING_KEYS_WRITE,
FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_SECRETS_WRITE,
],
'storage:read': [
FGA_PERMISSIONS.PROJECT.STORAGE_READ,
FGA_PERMISSIONS.PROJECT.STORAGE_CONFIG_READ,
],
'storage:write': [
FGA_PERMISSIONS.PROJECT.STORAGE_WRITE,
FGA_PERMISSIONS.PROJECT.STORAGE_CONFIG_WRITE,
],
}
/*
* Build a map of MCP tools/FGA permissions by expanding each OAuth-scope group to the FGA
* permissions it implies:
* {
* execute_sql: [["snippets_read", "database_read", ...], ["project_admin_write", ...]]
* }
* Groups are expanded independently, preserving the OR-of-AND structure. A group is an AND, so it
* is kept only when every one of its scopes maps to at least one FGA permission — a partial
* expansion would weaken the requirement (e.g. [ORGANIZATIONS_READ, PROJECTS_READ] shrinking to
* projects_read alone). A group with any unmapped scope is dropped whole, so the tool stays gated
* rather than becoming ungated; an explicitly empty group ([]) is the deliberate ungated marker
* and is vacuously kept.
* The code is duplicated from platform until we find a better way to share those mappings
*/
export const expandOAuthScopeGroups = (
oAuthScopeGroups: string[][],
fgaPermissionMap: Record<string, string[]>
): string[][] =>
oAuthScopeGroups
.filter((group) => group.every((oAuthScope) => (fgaPermissionMap[oAuthScope] ?? []).length > 0))
.map((group) => group.flatMap((oAuthScope) => fgaPermissionMap[oAuthScope] ?? []))
export const MCPToolScopeMappings = Object.entries(MCPToolOAuthScopeMapping).reduce(
(acc, [mcpTool, oAuthScopeGroups]) => {
acc[mcpTool] = expandOAuthScopeGroups(oAuthScopeGroups, legacyOauthScopeToFgaPermissionMap)
return acc
},
{} as McpMap
)
@@ -6,7 +6,6 @@ import {
buildAPIPermissionScopeMap,
getScopesAndEndpointsForAPI,
} from './buildAPIPermissionScopeMap'
import { expandOAuthScopeGroups, MCPToolScopeMappings } from './MCPToolScopeMappings'
import { type ScopeMap } from '@/data/scoped-access-tokens/permission-scope-map-query'
import { mswServer } from '@/tests/lib/msw'
@@ -135,115 +134,24 @@ describe('addMCPToolsToScopes', () => {
})
})
describe('MCPToolScopeMappings', () => {
// Platform gates execute_sql on database:read OR database:write depending on the MCP session's
// read_only mode (mcp.controller.ts), so the derived requirement must be two alternatives — a
// single conjunctive group would hide the tool from read-only tokens the platform accepts.
test('execute_sql derives the database:read bundle OR the database:write bundle', () => {
expect(MCPToolScopeMappings.execute_sql).toHaveLength(2)
const [readGroup, writeGroup] = MCPToolScopeMappings.execute_sql
expect(readGroup).toContain('database_read')
expect(readGroup).not.toContain('database_write')
expect(writeGroup).toContain('database_write')
})
test('single-scope tools derive a single conjunctive group', () => {
expect(MCPToolScopeMappings.apply_migration).toHaveLength(1)
expect(MCPToolScopeMappings.apply_migration[0]).toContain('database_write')
})
test('tools without a platform scope gate stay ungated ([[]]), not disabled ([])', () => {
expect(MCPToolScopeMappings.confirm_cost).toEqual([[]])
expect(MCPToolScopeMappings.search_docs).toEqual([[]])
})
// A group is an AND: expanding only its mapped scopes would weaken the requirement (e.g.
// [organizations:read, projects:read] shrinking to projects_read alone) and report the tool
// enabled for an incomplete grant.
test('a group with any unmapped scope is dropped whole, not partially expanded', () => {
const map = { 'projects:read': ['projects_read'] }
expect(expandOAuthScopeGroups([['organizations:read', 'projects:read']], map)).toEqual([])
// Other alternatives and the ungated marker survive the drop untouched.
expect(expandOAuthScopeGroups([['organizations:read'], ['projects:read'], []], map)).toEqual([
['projects_read'],
[],
])
})
// Guards the OAuth-scope -> legacy-map join: a scope key drifting out of the legacy map must
// not inject undefined into the payload (flatMap doesn't flatten it) or silently disable a
// gated tool by dropping all its groups.
test('every derived group is non-empty strings, and only the ungated tools lack scopes', () => {
const ungated = ['confirm_cost', 'search_docs']
for (const [tool, groups] of Object.entries(MCPToolScopeMappings)) {
expect(groups.length, `${tool} lost all its alternatives`).toBeGreaterThan(0)
for (const group of groups) {
if (!ungated.includes(tool))
expect(group.length, `${tool} has an empty group`).toBeGreaterThan(0)
for (const scope of group)
expect(typeof scope, `${tool} leaked a non-string scope`).toBe('string')
}
}
})
test('get_cost requires both organization and project read bundles together', () => {
expect(MCPToolScopeMappings.get_cost).toHaveLength(1)
expect(MCPToolScopeMappings.get_cost[0]).toEqual(
expect.arrayContaining(['organizations_read', 'projects_read'])
)
})
test('covers exactly the tool registry of the deployed MCP server', () => {
expect(Object.keys(MCPToolScopeMappings).sort()).toEqual([
'apply_migration',
'confirm_cost',
'create_branch',
'create_project',
'delete_branch',
'deploy_edge_function',
'execute_sql',
'generate_typescript_types',
'get_advisors',
'get_cost',
'get_edge_function',
'get_logs',
'get_organization',
'get_project',
'get_project_url',
'get_publishable_keys',
'get_storage_config',
'list_branches',
'list_edge_functions',
'list_extensions',
'list_migrations',
'list_organizations',
'list_projects',
'list_storage_buckets',
'list_tables',
'merge_branch',
'pause_project',
'rebase_branch',
'reset_branch',
'restore_project',
'search_docs',
'update_storage_config',
])
})
})
describe('buildAPIPermissionScopeMap', () => {
// vitestSetup starts mswServer with `onUnhandledRequest: 'error'` and resets handlers between
// tests, so mocking here keeps that guard instead of replacing global fetch.
const stubSpecs = (v1: Record<string, unknown>, v2: Record<string, unknown>) => {
// tests, so mocking here keeps that guard instead of replacing global fetch. All three live
// sources (v1 spec, v2 spec, the MCP tool-permissions endpoint) are stubbed.
const stubSources = (
v1: Record<string, unknown>,
v2: Record<string, unknown>,
mcpTools: Record<string, string[][]> = { execute_sql: [['database_read']] }
) => {
mswServer.use(
http.get('*/api/v1-json', () => HttpResponse.json(v1)),
http.get('*/api/v2-json', () => HttpResponse.json(v2))
http.get('*/api/v2-json', () => HttpResponse.json(v2)),
http.get('*/platform/mcp-tools-permissions', () => HttpResponse.json(mcpTools))
)
}
test('merges both specs, attaching each MCP tool to a shared scope exactly once', async () => {
stubSpecs(
stubSources(
{
paths: {
'/v1/projects/{ref}/database/query': {
@@ -274,7 +182,7 @@ describe('buildAPIPermissionScopeMap', () => {
// Path items may legally carry non-operation members; the specs are fetched live, so a benign
// upstream swagger change must not start 500ing this route.
test('tolerates path items with non-method OpenAPI members', async () => {
stubSpecs(
stubSources(
{
paths: {
'/v1/projects/{ref}': {
@@ -293,15 +201,33 @@ describe('buildAPIPermissionScopeMap', () => {
expect(Object.keys(map.endpoints)).toHaveLength(1)
})
test('returns a copy of the tool mapping so callers cannot corrupt the module singleton', async () => {
stubSpecs({ paths: {} }, { paths: {} })
test('returns the MCP tool map fetched from the endpoint', async () => {
stubSources(
{ paths: {} },
{ paths: {} },
{
apply_migration: [['database_migrations_write']],
search_docs: [[]],
}
)
const map = await buildAPIPermissionScopeMap()
expect(map.mcp_tools).toEqual(MCPToolScopeMappings)
expect(map.mcp_tools).not.toBe(MCPToolScopeMappings)
const before = structuredClone(MCPToolScopeMappings.execute_sql)
map.mcp_tools.execute_sql.push(['tampered'])
expect(MCPToolScopeMappings.execute_sql).toEqual(before)
expect(map.mcp_tools).toEqual({
apply_migration: [['database_migrations_write']],
search_docs: [[]],
})
// The gated tool is indexed under its permission; the ungated one is not.
expect(map.scopes.database_migrations_write.mcp_tools).toEqual(['apply_migration'])
})
test('throws when the MCP tool-permissions endpoint is unavailable', async () => {
mswServer.use(
http.get('*/api/v1-json', () => HttpResponse.json({ paths: {} })),
http.get('*/api/v2-json', () => HttpResponse.json({ paths: {} })),
http.get('*/platform/mcp-tools-permissions', () => new HttpResponse(null, { status: 503 }))
)
await expect(buildAPIPermissionScopeMap()).rejects.toThrow()
})
})
@@ -1,9 +1,5 @@
import { cloneDeep } from 'lodash'
import z from 'zod'
// We don't have an OpenAPI that describes mcp tools security requirements so
// we have this hard coded file that must be updated when they change
import { MCPToolScopeMappings } from './MCPToolScopeMappings'
import {
EndpointMap,
McpMap,
@@ -13,32 +9,29 @@ import {
import { InternalServerError } from '@/lib/api/apiHelpers'
/*
* Builds the permissions/endpoint mapping by fetching the OpenAPI specs for our v1 and v2 APIs.
* The two specs are indexed together rather than merged afterwards: every v1 path starts with
* `/v1/` and every v2 path with `/v2/`, so they can't collide, and one pass de-duplicates a
* scope's endpoint list by construction.
* @throws InternalServerError when it can't fetch the OpenAPI specs
* Builds the permissions/endpoint mapping from three live sources: the v1 and v2 OpenAPI specs
* (endpoint -> FGA via `x-fga-permissions`) and the mgmt-api MCP-tool-permissions endpoint
* (tool -> FGA). The MCP map is owned by Control Plane — it's projected from the same MCP_TOOL_AUTH
* descriptor that drives enforcement — so Studio fetches it exactly like the OpenAPI spec instead of
* hand-maintaining or importing a copy.
* @throws InternalServerError when it can't fetch the specs or the MCP map
*/
export const buildAPIPermissionScopeMap = async (): Promise<PermissionScopeMap> => {
const [apiV1SpecsJSON, apiV2SpecsJSON] = await Promise.all([
const [apiV1SpecsJSON, apiV2SpecsJSON, mcpToolsJSON] = await Promise.all([
fetchAPIPermissionScope('v1'),
fetchAPIPermissionScope('v2'),
fetchMcpToolPermissions(),
])
const apiV1Specs = API_SPECS_SCHEMA.parse(apiV1SpecsJSON)
const apiV2Specs = API_SPECS_SCHEMA.parse(apiV2SpecsJSON)
const mcpTools = MCP_TOOLS_SCHEMA.parse(mcpToolsJSON)
const { scopes, endpoints } = getScopesAndEndpointsForAPI({
paths: { ...apiV1Specs.paths, ...apiV2Specs.paths },
})
addMCPToolsToScopes(scopes, MCPToolScopeMappings)
addMCPToolsToScopes(scopes, mcpTools)
return {
scopes,
endpoints,
// Deep copy so a caller mutating the response can't corrupt the module-level mapping, which
// outlives every request in a long-running server.
mcp_tools: cloneDeep(MCPToolScopeMappings),
}
return { scopes, endpoints, mcp_tools: mcpTools }
}
// OPEN API specs look like this (only kept the parts we're interested in):
@@ -63,7 +56,7 @@ export const buildAPIPermissionScopeMap = async (): Promise<PermissionScopeMap>
// KNOWN DIVERGENCE: annotations are trusted verbatim, and the one on
// POST /v1/projects/{ref}/database/query overstates access — the spec publishes
// `[[database_read], [database_write]]`, but the route's guard requires database_read outright and
// the write group is doc-only (see the execute_sql entry in MCPToolScopeMappings.ts). A token
// the write group is doc-only (the MCP endpoint reports execute_sql under database_read only). A token
// granted only database_write is therefore shown this endpoint as callable when the guard would
// reject it. Studio-created tokens can't hit this (write mode always grants the read scopes too),
// so this stays a display inaccuracy for API-created tokens; the fix is correcting the annotation
@@ -147,6 +140,38 @@ const fetchAPIPermissionScope = async (version: 'v1' | 'v2') => {
}
}
// The mgmt-api endpoint that projects the MCP_TOOL_AUTH descriptor (which also drives enforcement)
// to tool -> FGA permission groups. Fetched like the OpenAPI spec above.
const fetchMcpToolPermissions = async () => {
try {
const response = await fetch(`${NEXT_PUBLIC_API_DOMAIN}/platform/mcp-tools-permissions`, {
method: 'get',
headers: {
'Content-Type': 'application/json',
},
})
if (response.ok) {
return response.json()
}
const responseText = await response.text()
const retryAfter = response.headers.get('Retry-After') ?? undefined
throw new InternalServerError(`MCP tool permissions responded with ${response.status}`, {
status: response.status,
body: responseText,
...(retryAfter !== undefined && { retryAfter }),
})
} catch (error: unknown) {
if (error instanceof InternalServerError) {
throw error
}
if (error instanceof Error) {
throw new InternalServerError(error.message)
}
}
}
// Simplified OPEN API specs schemas that only defines what we care about for scoped tokens
const OPEN_API_PATH_METHOD_SCHEMA = z.object({
@@ -172,3 +197,6 @@ const OPEN_API_PATH_ITEM_SCHEMA = z.preprocess(
const API_SPECS_SCHEMA = z.object({
paths: z.record(z.string(), OPEN_API_PATH_ITEM_SCHEMA),
})
// tool name -> OR-of-AND FGA permission groups, as served by GET /platform/mcp-tools-permissions.
const MCP_TOOLS_SCHEMA: z.ZodType<McpMap> = z.record(z.string(), z.array(z.array(z.string())))