diff --git a/apps/studio/app/api/scoped-access-token-permissions/MCPToolScopeMappings.ts b/apps/studio/app/api/scoped-access-token-permissions/MCPToolScopeMappings.ts deleted file mode 100644 index 7e2b8da4823..00000000000 --- a/apps/studio/app/api/scoped-access-token-permissions/MCPToolScopeMappings.ts +++ /dev/null @@ -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 = { - 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 = { - [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 - -// 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 = { - '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[][] => - 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 -) diff --git a/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.test.ts b/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.test.ts index 65a5ee62fc1..d550f2b5683 100644 --- a/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.test.ts +++ b/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.test.ts @@ -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, v2: Record) => { + // 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, + v2: Record, + mcpTools: Record = { 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() }) }) diff --git a/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.ts b/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.ts index b6b568e6022..95655c5884e 100644 --- a/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.ts +++ b/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.ts @@ -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 => { - 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 // 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 = z.record(z.string(), z.array(z.array(z.string())))