diff --git a/packages/mcp-server-supabase/src/management-api/index.ts b/packages/mcp-server-supabase/src/management-api/index.ts index 74110c05..74f568b3 100644 --- a/packages/mcp-server-supabase/src/management-api/index.ts +++ b/packages/mcp-server-supabase/src/management-api/index.ts @@ -65,3 +65,37 @@ export function assertSuccess< throw new Error(fallbackMessage); } } + +/** + * Asserts success for project-scoped endpoints, mapping 403 responses to an + * actionable message. A permission error on a specific project most commonly + * means the access token is scoped to an organization that doesn't own the + * project (e.g. the wrong organization was selected during OAuth login). + * + * Currently applied only to database operations (see AI-178). Before adopting + * for other endpoints, check that a 403 there isn't better explained by plan + * or role restrictions, where org-selection advice would mislead. + */ +export function assertProjectScopedSuccess< + T extends Record, + Options, + Media extends MediaType, +>( + response: FetchResponse, + fallbackMessage: string, + projectId: string +): asserts response is SuccessResponseType { + if ('error' in response && response.response.status === 403) { + const { data: errorContent } = errorSchema.safeParse(response.error); + const apiMessage = (errorContent?.message ?? fallbackMessage).replace( + /\.$/, + '' + ); + + throw new Error( + `${apiMessage}. Access to project '${projectId}' was denied. If this project exists, your access token may be scoped to a different organization: re-authenticate with the MCP server and select the organization that owns this project.` + ); + } + + assertSuccess(response, fallbackMessage); +} diff --git a/packages/mcp-server-supabase/src/platform/api-platform.ts b/packages/mcp-server-supabase/src/platform/api-platform.ts index fef901c1..1df3cd6b 100644 --- a/packages/mcp-server-supabase/src/platform/api-platform.ts +++ b/packages/mcp-server-supabase/src/platform/api-platform.ts @@ -8,6 +8,7 @@ import packageJson from '../../package.json' with { type: 'json' }; import { getDeploymentId, normalizeFilename } from '../edge-function.js'; import { getLogQuery } from '../logs.js'; import { + assertProjectScopedSuccess, assertSuccess, createManagementApiClient, } from '../management-api/index.js'; @@ -191,7 +192,11 @@ export function createSupabaseApiPlatform( } ); - assertSuccess(response, 'Failed to execute SQL query'); + assertProjectScopedSuccess( + response, + 'Failed to execute SQL query', + projectId + ); return response.data as unknown as T[]; }, @@ -207,7 +212,11 @@ export function createSupabaseApiPlatform( } ); - assertSuccess(response, 'Failed to fetch migrations'); + assertProjectScopedSuccess( + response, + 'Failed to fetch migrations', + projectId + ); return response.data; }, @@ -229,7 +238,11 @@ export function createSupabaseApiPlatform( } ); - assertSuccess(response, 'Failed to apply migration'); + assertProjectScopedSuccess( + response, + 'Failed to apply migration', + projectId + ); // Intentionally don't return the result of the migration // to avoid prompt injection attacks. If the migration failed, diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index 97dda899..f1ea932f 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -6,6 +6,7 @@ import { import { StreamTransport } from '@supabase/mcp-utils'; import { codeBlock, stripIndent } from 'common-tags'; import gqlmin from 'gqlmin'; +import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { globalRegistry } from 'zod/v4'; @@ -1343,6 +1344,121 @@ describe('tools', () => { await expect(listOrganizationsPromise).rejects.toThrow('Unauthorized.'); }); + const projectScopedDbTools = [ + { + tool: 'execute_sql', + method: 'post', + endpoint: '/v1/projects/:projectId/database/query', + args: (projectId: string) => ({ + project_id: projectId, + query: 'select 1;', + }), + }, + { + tool: 'list_tables', + method: 'post', + endpoint: '/v1/projects/:projectId/database/query', + args: (projectId: string) => ({ project_id: projectId }), + }, + { + tool: 'list_extensions', + method: 'post', + endpoint: '/v1/projects/:projectId/database/query', + args: (projectId: string) => ({ project_id: projectId }), + }, + { + tool: 'list_migrations', + method: 'get', + endpoint: '/v1/projects/:projectId/database/migrations', + args: (projectId: string) => ({ project_id: projectId }), + }, + { + tool: 'apply_migration', + method: 'post', + endpoint: '/v1/projects/:projectId/database/migrations', + args: (projectId: string) => ({ + project_id: projectId, + name: 'test-migration', + query: 'select 1;', + }), + }, + ] as const; + + test.each(projectScopedDbTools)( + 'permission denied for $tool suggests checking organization', + async ({ tool, method, endpoint, args }) => { + const { callTool } = await setup(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + mockServer?.use( + http[method](`${API_URL}${endpoint}`, () => + HttpResponse.json( + { message: 'You do not have permission to perform this action' }, + { status: 403 } + ) + ) + ); + + const resultPromise = callTool({ + name: tool, + arguments: args(project.id), + }); + + await expect(resultPromise).rejects.toThrow( + `You do not have permission to perform this action. Access to project '${project.id}' was denied. If this project exists, your access token may be scoped to a different organization: re-authenticate with the MCP server and select the organization that owns this project.` + ); + } + ); + + test('permission denied with no upstream message falls back to a generic prefix', async () => { + const { callTool } = await setup(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + // A 403 whose body has no `message` field (the missing-message wrong-org + // case) falls back to the generic prefix. + mockServer?.use( + http.post(`${API_URL}/v1/projects/:projectId/database/query`, () => + HttpResponse.json({}, { status: 403 }) + ) + ); + + const executeSqlPromise = callTool({ + name: 'execute_sql', + arguments: { + project_id: project.id, + query: 'select 1;', + }, + }); + + await expect(executeSqlPromise).rejects.toThrow( + `Failed to execute SQL query. Access to project '${project.id}' was denied. If this project exists, your access token may be scoped to a different organization: re-authenticate with the MCP server and select the organization that owns this project.` + ); + }); + test('invalid sql for apply_migration', async () => { const { callTool } = await setup();