diff --git a/README.md b/README.md index 6ec0387..6d10130 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Replace `` with the token you created in step 1. Alternat The following options are available: -- `--read-only`: Used to restrict the server to read-only queries. Recommended by default. See [read-only mode](#read-only-mode). +- `--read-only`: Used to restrict the server to read-only queries and tools. Recommended by default. See [read-only mode](#read-only-mode). - `--project-ref`: Used to scope the server to a specific project. Recommended by default. If you omit this, the server will have access to all projects in your Supabase account. See [project scoped mode](#project-scoped-mode). - `--features`: Used to specify which tool groups to enable. See [feature groups](#feature-groups). @@ -150,7 +150,18 @@ To restrict the Supabase MCP server to read-only queries, set the `--read-only` npx -y @supabase/mcp-server-supabase@latest --read-only ``` -We recommend you enable this by default. This prevents write operations on any of your databases by executing SQL as a read-only Postgres user. Note that this flag only applies to database tools (`execute_sql` and `apply_migration`) and not to other tools like `create_project` or `create_branch`. +We recommend enabling this setting by default. This prevents write operations on any of your databases by executing SQL as a read-only Postgres user (via `execute_sql`). All other mutating tools are disabled in read-only mode, including: +`apply_migration` +`create_project` +`pause_project` +`restore_project` +`deploy_edge_function` +`create_branch` +`delete_branch` +`merge_branch` +`reset_branch` +`rebase_branch` +`update_storage_config`. ### Feature groups diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index 1c3a6e2..1b60a7b 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -10,10 +10,10 @@ import { beforeEach, describe, expect, test } from 'vitest'; import { ACCESS_TOKEN, API_URL, - CLOSEST_REGION, contentApiMockSchema, createOrganization, createProject, + createBranch, MCP_CLIENT_NAME, MCP_CLIENT_VERSION, mockBranches, @@ -381,6 +381,41 @@ describe('tools', () => { }); }); + test('create project in read-only mode throws an error', async () => { + const { callTool } = await setup({ readOnly: true }); + + const freeOrg = await createOrganization({ + name: 'Free Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const confirm_cost_id = await callTool({ + name: 'confirm_cost', + arguments: { + type: 'project', + recurrence: 'monthly', + amount: 0, + }, + }); + + const newProject = { + name: 'New Project', + region: 'us-east-1', + organization_id: freeOrg.id, + confirm_cost_id, + }; + + const result = callTool({ + name: 'create_project', + arguments: newProject, + }); + + await expect(result).rejects.toThrow( + 'Cannot create a project in read-only mode.' + ); + }); + test('create project without region fails', async () => { const { callTool } = await setup(); @@ -464,6 +499,34 @@ describe('tools', () => { expect(project.status).toEqual('INACTIVE'); }); + test('pause project in read-only mode throws an error', async () => { + const { callTool } = await setup({ readOnly: true }); + + 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'; + + const result = callTool({ + name: 'pause_project', + arguments: { + project_id: project.id, + }, + }); + + await expect(result).rejects.toThrow( + 'Cannot pause a project in read-only mode.' + ); + }); + test('restore project', async () => { const { callTool } = await setup(); @@ -490,6 +553,34 @@ describe('tools', () => { expect(project.status).toEqual('ACTIVE_HEALTHY'); }); + test('restore project in read-only mode throws an error', async () => { + const { callTool } = await setup({ readOnly: true }); + + 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 = 'INACTIVE'; + + const result = callTool({ + name: 'restore_project', + arguments: { + project_id: project.id, + }, + }); + + await expect(result).rejects.toThrow( + 'Cannot restore a project in read-only mode.' + ); + }); + test('get project url', async () => { const { callTool } = await setup(); @@ -651,6 +742,43 @@ describe('tools', () => { expect(result).toEqual({ success: true }); }); + test('update storage config in read-only mode throws an error', async () => { + const { callTool } = await setup({ readOnly: true, features: ['storage'] }); + + 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'; + + const config = { + fileSizeLimit: 50, + features: { + imageTransformation: { enabled: true }, + s3Protocol: { enabled: false }, + }, + }; + + const result = callTool({ + name: 'update_storage_config', + arguments: { + project_id: project.id, + config, + }, + }); + + await expect(result).rejects.toThrow( + 'Cannot update storage config in read-only mode.' + ); + }); + test('execute sql', async () => { const { callTool } = await setup(); @@ -1344,6 +1472,44 @@ describe('tools', () => { }); }); + test('deploy edge function in read-only mode throws an error', async () => { + const { callTool } = await setup({ readOnly: true }); + + const org = await createOrganization({ + name: 'test-org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + + const project = await createProject({ + name: 'test-app', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + const functionName = 'hello-world'; + const functionCode = 'console.log("Hello, world!");'; + + const result = callTool({ + name: 'deploy_edge_function', + arguments: { + project_id: project.id, + name: functionName, + files: [ + { + name: 'index.ts', + content: functionCode, + }, + ], + }, + }); + + await expect(result).rejects.toThrow( + 'Cannot deploy an edge function in read-only mode.' + ); + }); + test('deploy new version of existing edge function', async () => { const { callTool } = await setup(); const org = await createOrganization({ @@ -1646,6 +1812,49 @@ describe('tools', () => { }); }); + test('create branch in read-only mode throws an error', async () => { + const { callTool } = await setup({ + readOnly: true, + features: ['account', 'branching'], + }); + + 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'; + + const confirm_cost_id = await callTool({ + name: 'confirm_cost', + arguments: { + type: 'branch', + recurrence: 'hourly', + amount: BRANCH_COST_HOURLY, + }, + }); + + const branchName = 'test-branch'; + const result = callTool({ + name: 'create_branch', + arguments: { + project_id: project.id, + name: branchName, + confirm_cost_id, + }, + }); + + await expect(result).rejects.toThrow( + 'Cannot create a branch in read-only mode.' + ); + }); + test('create branch without cost confirmation fails', async () => { const { callTool } = await setup({ features: ['branching'] }); @@ -1757,6 +1966,54 @@ describe('tools', () => { ); }); + test('delete branch in read-only mode throws an error', async () => { + const { callTool } = await setup({ + readOnly: true, + features: ['account', 'branching'], + }); + + 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'; + + const branch = await createBranch({ + name: 'test-branch', + parent_project_ref: project.id, + }); + + const listBranchesResult = await callTool({ + name: 'list_branches', + arguments: { + project_id: project.id, + }, + }); + + expect(listBranchesResult).toHaveLength(1); + expect(listBranchesResult).toContainEqual( + expect.objectContaining({ id: branch.id }) + ); + + const result = callTool({ + name: 'delete_branch', + arguments: { + branch_id: branch.id, + }, + }); + + await expect(result).rejects.toThrow( + 'Cannot delete a branch in read-only mode.' + ); + }); + test('list branches', async () => { const { callTool } = await setup({ features: ['branching'] }); @@ -1852,6 +2109,42 @@ describe('tools', () => { }); }); + test('merge branch in read-only mode throws an error', async () => { + const { callTool } = await setup({ + readOnly: true, + features: ['account', 'branching', 'database'], + }); + + 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'; + + const branch = await createBranch({ + name: 'test-branch', + parent_project_ref: project.id, + }); + + const result = callTool({ + name: 'merge_branch', + arguments: { + branch_id: branch.id, + }, + }); + + await expect(result).rejects.toThrow( + 'Cannot merge a branch in read-only mode.' + ); + }); + test('reset branch', async () => { const { callTool } = await setup({ features: ['account', 'branching', 'database'], @@ -1930,6 +2223,42 @@ describe('tools', () => { ); }); + test('reset branch in read-only mode throws an error', async () => { + const { callTool } = await setup({ + readOnly: true, + features: ['account', 'branching', 'database'], + }); + + 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'; + + const branch = await createBranch({ + name: 'test-branch', + parent_project_ref: project.id, + }); + + const result = callTool({ + name: 'reset_branch', + arguments: { + branch_id: branch.id, + }, + }); + + await expect(result).rejects.toThrow( + 'Cannot reset a branch in read-only mode.' + ); + }); + test('revert migrations', async () => { const { callTool } = await setup({ features: ['account', 'branching', 'database'], @@ -2101,6 +2430,42 @@ describe('tools', () => { }); }); + test('rebase branch in read-only mode throws an error', async () => { + const { callTool } = await setup({ + readOnly: true, + features: ['account', 'branching', 'database'], + }); + + 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'; + + const branch = await createBranch({ + name: 'test-branch', + parent_project_ref: project.id, + }); + + const result = callTool({ + name: 'rebase_branch', + arguments: { + branch_id: branch.id, + }, + }); + + await expect(result).rejects.toThrow( + 'Cannot rebase a branch in read-only mode.' + ); + }); + // We use snake_case because it aligns better with most MCP clients test('all tools follow snake_case naming convention', async () => { const { client } = await setup(); diff --git a/packages/mcp-server-supabase/src/server.ts b/packages/mcp-server-supabase/src/server.ts index 4fc4343..d48bb81 100644 --- a/packages/mcp-server-supabase/src/server.ts +++ b/packages/mcp-server-supabase/src/server.ts @@ -122,7 +122,7 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { } if (!projectId && account && enabledFeatures.has('account')) { - Object.assign(tools, getAccountTools({ account })); + Object.assign(tools, getAccountTools({ account, readOnly })); } if (database && enabledFeatures.has('database')) { @@ -145,15 +145,21 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { } if (functions && enabledFeatures.has('functions')) { - Object.assign(tools, getEdgeFunctionTools({ functions, projectId })); + Object.assign( + tools, + getEdgeFunctionTools({ functions, projectId, readOnly }) + ); } if (branching && enabledFeatures.has('branching')) { - Object.assign(tools, getBranchingTools({ branching, projectId })); + Object.assign( + tools, + getBranchingTools({ branching, projectId, readOnly }) + ); } if (storage && enabledFeatures.has('storage')) { - Object.assign(tools, getStorageTools({ storage, projectId })); + Object.assign(tools, getStorageTools({ storage, projectId, readOnly })); } return tools; diff --git a/packages/mcp-server-supabase/src/tools/account-tools.ts b/packages/mcp-server-supabase/src/tools/account-tools.ts index dfa3b8b..17793aa 100644 --- a/packages/mcp-server-supabase/src/tools/account-tools.ts +++ b/packages/mcp-server-supabase/src/tools/account-tools.ts @@ -7,9 +7,10 @@ import { hashObject } from '../util.js'; export type AccountToolsOptions = { account: AccountOperations; + readOnly?: boolean; }; -export function getAccountTools({ account }: AccountToolsOptions) { +export function getAccountTools({ account, readOnly }: AccountToolsOptions) { return { list_organizations: tool({ description: 'Lists all organizations that the user is a member of.', @@ -101,6 +102,10 @@ export function getAccountTools({ account }: AccountToolsOptions) { .describe('The cost confirmation ID. Call `confirm_cost` first.'), }), execute: async ({ name, region, organization_id, confirm_cost_id }) => { + if (readOnly) { + throw new Error('Cannot create a project in read-only mode.'); + } + const cost = await getNextProjectCost(account, organization_id); const costHash = await hashObject(cost); if (costHash !== confirm_cost_id) { @@ -122,6 +127,10 @@ export function getAccountTools({ account }: AccountToolsOptions) { project_id: z.string(), }), execute: async ({ project_id }) => { + if (readOnly) { + throw new Error('Cannot pause a project in read-only mode.'); + } + return await account.pauseProject(project_id); }, }), @@ -131,6 +140,10 @@ export function getAccountTools({ account }: AccountToolsOptions) { project_id: z.string(), }), execute: async ({ project_id }) => { + if (readOnly) { + throw new Error('Cannot restore a project in read-only mode.'); + } + return await account.restoreProject(project_id); }, }), diff --git a/packages/mcp-server-supabase/src/tools/branching-tools.ts b/packages/mcp-server-supabase/src/tools/branching-tools.ts index 8504a99..fd1bf7f 100644 --- a/packages/mcp-server-supabase/src/tools/branching-tools.ts +++ b/packages/mcp-server-supabase/src/tools/branching-tools.ts @@ -8,11 +8,13 @@ import { injectableTool } from './util.js'; export type BranchingToolsOptions = { branching: BranchingOperations; projectId?: string; + readOnly?: boolean; }; export function getBranchingTools({ branching, projectId, + readOnly, }: BranchingToolsOptions) { const project_id = projectId; @@ -35,6 +37,10 @@ export function getBranchingTools({ }), inject: { project_id }, execute: async ({ project_id, name, confirm_cost_id }) => { + if (readOnly) { + throw new Error('Cannot create a branch in read-only mode.'); + } + const cost = getBranchCost(); const costHash = await hashObject(cost); if (costHash !== confirm_cost_id) { @@ -62,6 +68,10 @@ export function getBranchingTools({ branch_id: z.string(), }), execute: async ({ branch_id }) => { + if (readOnly) { + throw new Error('Cannot delete a branch in read-only mode.'); + } + return await branching.deleteBranch(branch_id); }, }), @@ -72,6 +82,10 @@ export function getBranchingTools({ branch_id: z.string(), }), execute: async ({ branch_id }) => { + if (readOnly) { + throw new Error('Cannot merge a branch in read-only mode.'); + } + return await branching.mergeBranch(branch_id); }, }), @@ -88,6 +102,10 @@ export function getBranchingTools({ ), }), execute: async ({ branch_id, migration_version }) => { + if (readOnly) { + throw new Error('Cannot reset a branch in read-only mode.'); + } + return await branching.resetBranch(branch_id, { migration_version, }); @@ -100,6 +118,10 @@ export function getBranchingTools({ branch_id: z.string(), }), execute: async ({ branch_id }) => { + if (readOnly) { + throw new Error('Cannot rebase a branch in read-only mode.'); + } + return await branching.rebaseBranch(branch_id); }, }), diff --git a/packages/mcp-server-supabase/src/tools/edge-function-tools.ts b/packages/mcp-server-supabase/src/tools/edge-function-tools.ts index 15a154e..c554957 100644 --- a/packages/mcp-server-supabase/src/tools/edge-function-tools.ts +++ b/packages/mcp-server-supabase/src/tools/edge-function-tools.ts @@ -6,11 +6,13 @@ import { injectableTool } from './util.js'; export type EdgeFunctionToolsOptions = { functions: EdgeFunctionsOperations; projectId?: string; + readOnly?: boolean; }; export function getEdgeFunctionTools({ functions, projectId, + readOnly, }: EdgeFunctionToolsOptions) { const project_id = projectId; @@ -69,6 +71,10 @@ export function getEdgeFunctionTools({ import_map_path, files, }) => { + if (readOnly) { + throw new Error('Cannot deploy an edge function in read-only mode.'); + } + return await functions.deployEdgeFunction(project_id, { name, entrypoint_path, diff --git a/packages/mcp-server-supabase/src/tools/storage-tools.ts b/packages/mcp-server-supabase/src/tools/storage-tools.ts index 24a6b07..03d5ef6 100644 --- a/packages/mcp-server-supabase/src/tools/storage-tools.ts +++ b/packages/mcp-server-supabase/src/tools/storage-tools.ts @@ -5,9 +5,14 @@ import { injectableTool } from './util.js'; export type StorageToolsOptions = { storage: StorageOperations; projectId?: string; + readOnly?: boolean; }; -export function getStorageTools({ storage, projectId }: StorageToolsOptions) { +export function getStorageTools({ + storage, + projectId, + readOnly, +}: StorageToolsOptions) { const project_id = projectId; return { @@ -45,6 +50,10 @@ export function getStorageTools({ storage, projectId }: StorageToolsOptions) { }), inject: { project_id }, execute: async ({ project_id, config }) => { + if (readOnly) { + throw new Error('Cannot update storage config in read-only mode.'); + } + await storage.updateStorageConfig(project_id, config); return { success: true }; }, diff --git a/packages/mcp-server-supabase/test/e2e/projects.e2e.ts b/packages/mcp-server-supabase/test/e2e/projects.e2e.ts index 60f8d4c..baf17c0 100644 --- a/packages/mcp-server-supabase/test/e2e/projects.e2e.ts +++ b/packages/mcp-server-supabase/test/e2e/projects.e2e.ts @@ -115,7 +115,7 @@ describe('project management e2e tests', () => { ); await expect(text).toMatchCriteria( - `Describes the a single todos table available in the project.` + `Describes the single todos table available in the project.` ); }); });