From 862a116fb22b57233c3c71e78b75f302a83292d9 Mon Sep 17 00:00:00 2001 From: Greg Richardson Date: Thu, 10 Apr 2025 13:22:59 -0600 Subject: [PATCH 1/2] feat: global read only db mode --- .../src/management-api/types.ts | 153 +++++++------- .../mcp-server-supabase/src/server.test.ts | 193 +++++++++++++----- packages/mcp-server-supabase/src/server.ts | 22 +- packages/mcp-server-supabase/src/stdio.ts | 6 + packages/mcp-server-supabase/test/mocks.ts | 37 +++- 5 files changed, 268 insertions(+), 143 deletions(-) diff --git a/packages/mcp-server-supabase/src/management-api/types.ts b/packages/mcp-server-supabase/src/management-api/types.ts index 46e481d..ecce2d6 100644 --- a/packages/mcp-server-supabase/src/management-api/types.ts +++ b/packages/mcp-server-supabase/src/management-api/types.ts @@ -985,7 +985,15 @@ export interface paths { path?: never; cookie?: never; }; - /** Gets project's logs */ + /** + * Gets project's logs + * @description Executes a SQL query on the project's logs. + * + * Either the 'iso_timestamp_start' and 'iso_timestamp_end' parameters must be provided. + * If both are not provided, only the last 1 minute of logs will be queried. + * The timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown. + * + */ get: operations["getLogs"]; put?: never; post?: never; @@ -1568,43 +1576,37 @@ export interface components { [key: string]: string; }; }; - ValidationRecord: { - txt_name: string; - txt_value: string; - }; - ValidationError: { - message: string; - }; - SslValidation: { - status: string; - validation_records: components["schemas"]["ValidationRecord"][]; - validation_errors?: components["schemas"]["ValidationError"][]; - }; - OwnershipVerification: { - type: string; - name: string; - value: string; - }; - CustomHostnameDetails: { - id: string; - hostname: string; - ssl: components["schemas"]["SslValidation"]; - ownership_verification: components["schemas"]["OwnershipVerification"]; - custom_origin_server: string; - verification_errors?: string[]; - status: string; - }; - CfResponse: { - success: boolean; - errors: Record[]; - messages: Record[]; - result: components["schemas"]["CustomHostnameDetails"]; - }; UpdateCustomHostnameResponse: { /** @enum {string} */ status: "1_not_started" | "2_initiated" | "3_challenge_verified" | "4_origin_setup_completed" | "5_services_reconfigured"; custom_hostname: string; - data: components["schemas"]["CfResponse"]; + data: { + success: boolean; + errors: unknown[]; + messages: unknown[]; + result: { + id: string; + hostname: string; + ssl: { + status: string; + validation_records: { + txt_name: string; + txt_value: string; + }[]; + validation_errors?: { + message: string; + }[]; + }; + ownership_verification: { + type: string; + name: string; + value: string; + }; + custom_origin_server: string; + verification_errors?: string[]; + status: string; + }; + }; }; UpdateCustomHostnameBody: { custom_hostname: string; @@ -1817,25 +1819,27 @@ export interface components { /** @enum {string} */ status: "in_use" | "previously_used" | "revoked" | "standby"; }; - StorageFeatureImageTransformation: { - enabled: boolean; - }; - StorageFeatureS3Protocol: { - enabled: boolean; - }; - StorageFeatures: { - imageTransformation: components["schemas"]["StorageFeatureImageTransformation"]; - s3Protocol: components["schemas"]["StorageFeatureS3Protocol"]; - }; StorageConfigResponse: { - /** Format: int64 */ fileSizeLimit: number; - features: components["schemas"]["StorageFeatures"]; + features: { + imageTransformation: { + enabled: boolean; + }; + s3Protocol: { + enabled: boolean; + }; + }; }; UpdateStorageConfigBody: { - /** Format: int64 */ fileSizeLimit?: number; - features?: components["schemas"]["StorageFeatures"]; + features?: { + imageTransformation: { + enabled: boolean; + }; + s3Protocol: { + enabled: boolean; + }; + }; }; PostgresConfigResponse: { effective_cache_size?: string; @@ -1899,29 +1903,25 @@ export interface components { connection_string?: string; }; SupavisorConfigResponse: { + identifier: string; /** @enum {string} */ database_type: "PRIMARY" | "READ_REPLICA"; + is_using_scram_auth: boolean; + db_user: string; + db_host: string; db_port: number; - /** - * @deprecated - * @description Use connection_string instead - */ + db_name: string; + connection_string: string; + /** @description Use connection_string instead */ connectionString: string; default_pool_size: number | null; max_client_conn: number | null; /** @enum {string} */ pool_mode: "transaction" | "session"; - identifier: string; - is_using_scram_auth: boolean; - db_user: string; - db_host: string; - db_name: string; - connection_string: string; }; UpdateSupavisorConfigBody: { default_pool_size?: number | null; /** - * @deprecated * @description Dedicated pooler mode for the project * @enum {string} */ @@ -2278,15 +2278,16 @@ export interface components { CreateThirdPartyAuthBody: { oidc_issuer_url?: string; jwks_url?: string; - custom_jwks?: Record; + custom_jwks?: unknown; }; ThirdPartyAuth: { + /** Format: uuid */ id: string; type: string; oidc_issuer_url?: string | null; jwks_url?: string | null; - custom_jwks?: Record | null; - resolved_jwks?: Record | null; + custom_jwks?: unknown; + resolved_jwks?: unknown; inserted_at: string; updated_at: string; resolved_at?: string | null; @@ -2317,9 +2318,8 @@ export interface components { interval: "monthly" | "hourly"; amount: number; }; - meta?: { - [key: string]: number | boolean | string | string[]; - }; + /** @description Any JSON-serializable value */ + meta?: unknown; }; }[]; available_addons: { @@ -2337,9 +2337,8 @@ export interface components { interval: "monthly" | "hourly"; amount: number; }; - meta?: { - [key: string]: number | boolean | string | string[]; - }; + /** @description Any JSON-serializable value */ + meta?: unknown; }[]; }[]; }; @@ -2373,6 +2372,7 @@ export interface components { }; V1RunQueryBody: { query: string; + read_only?: boolean; }; GetProjectDbMetadataResponseDto: { databases: ({ @@ -3290,7 +3290,6 @@ export interface operations { query?: never; header?: never; path: { - /** @description Project ref */ ref: string; }; cookie?: never; @@ -3325,7 +3324,6 @@ export interface operations { query?: never; header?: never; path: { - /** @description Project ref */ ref: string; }; cookie?: never; @@ -3358,7 +3356,6 @@ export interface operations { query?: never; header?: never; path: { - /** @description Project ref */ ref: string; }; cookie?: never; @@ -3397,7 +3394,6 @@ export interface operations { query?: never; header?: never; path: { - /** @description Project ref */ ref: string; }; cookie?: never; @@ -3432,7 +3428,6 @@ export interface operations { query?: never; header?: never; path: { - /** @description Project ref */ ref: string; }; cookie?: never; @@ -4609,7 +4604,6 @@ export interface operations { query?: never; header?: never; path: { - /** @description Project ref */ ref: string; }; cookie?: never; @@ -4644,7 +4638,6 @@ export interface operations { query?: never; header?: never; path: { - /** @description Project ref */ ref: string; }; cookie?: never; @@ -4790,7 +4783,6 @@ export interface operations { query?: never; header?: never; path: { - /** @description Project ref */ ref: string; }; cookie?: never; @@ -4819,7 +4811,6 @@ export interface operations { query?: never; header?: never; path: { - /** @description Project ref */ ref: string; }; cookie?: never; @@ -4932,7 +4923,6 @@ export interface operations { query?: never; header?: never; path: { - /** @description Project ref */ ref: string; }; cookie?: never; @@ -4960,7 +4950,6 @@ export interface operations { query?: never; header?: never; path: { - /** @description Project ref */ ref: string; }; cookie?: never; @@ -4992,9 +4981,8 @@ export interface operations { query?: never; header?: never; path: { - /** @description Project ref */ - ref: string; tpa_id: string; + ref: string; }; cookie?: never; }; @@ -5021,9 +5009,8 @@ export interface operations { query?: never; header?: never; path: { - /** @description Project ref */ - ref: string; tpa_id: string; + ref: string; }; cookie?: never; }; diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index 8deff74..a757a43 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -33,13 +33,14 @@ beforeEach(async () => { type SetupOptions = { accessToken?: string; + readOnly?: boolean; }; /** * Sets up an MCP client and server for testing. */ async function setup(options: SetupOptions = {}) { - const { accessToken = ACCESS_TOKEN } = options; + const { accessToken = ACCESS_TOKEN, readOnly } = options; const clientTransport = new StreamTransport(); const serverTransport = new StreamTransport(); @@ -61,6 +62,7 @@ async function setup(options: SetupOptions = {}) { apiUrl: API_URL, accessToken, }, + readOnly, }); await server.connect(serverTransport); @@ -564,6 +566,67 @@ describe('tools', () => { expect(result).toEqual([{ sum: 2 }]); }); + test('can run read queries in read-only mode', 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 query = 'select 1+1 as sum'; + + const result = await callTool({ + name: 'execute_sql', + arguments: { + project_id: project.id, + query, + }, + }); + + expect(result).toEqual([{ sum: 2 }]); + }); + + test('cannot run write queries in read-only mode', 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 query = + 'create table test (id integer generated always as identity primary key)'; + + const resultPromise = callTool({ + name: 'execute_sql', + arguments: { + project_id: project.id, + query, + }, + }); + + await expect(resultPromise).rejects.toThrow( + 'permission denied for schema public' + ); + }); + test('apply migration, list migrations, check tables', async () => { const { callTool } = await setup(); @@ -617,54 +680,86 @@ describe('tools', () => { }, }); - expect(listTablesResult).toMatchInlineSnapshot(` - [ - { - "bytes": 8192, - "columns": [ - { - "check": null, - "comment": null, - "data_type": "integer", - "default_value": null, - "enums": [], - "format": "int4", - "id": "16385.1", - "identity_generation": "ALWAYS", - "is_generated": false, - "is_identity": true, - "is_nullable": false, - "is_unique": false, - "is_updatable": true, - "name": "id", - "ordinal_position": 1, - "schema": "public", - "table": "test", - "table_id": 16385, - }, - ], - "comment": null, - "dead_rows_estimate": 0, - "id": 16385, - "live_rows_estimate": 0, - "name": "test", - "primary_keys": [ - { - "name": "id", - "schema": "public", - "table_id": 16385, - "table_name": "test", - }, - ], - "relationships": [], - "replica_identity": "DEFAULT", - "rls_enabled": false, - "rls_forced": false, - "schema": "public", - "size": "8192 bytes", - }, - ] - `); + expect(listTablesResult).toEqual([ + { + bytes: 8192, + columns: [ + { + check: null, + comment: null, + data_type: 'integer', + default_value: null, + enums: [], + format: 'int4', + id: expect.stringMatching(/^\d+\.\d+$/), + identity_generation: 'ALWAYS', + is_generated: false, + is_identity: true, + is_nullable: false, + is_unique: false, + is_updatable: true, + name: 'id', + ordinal_position: 1, + schema: 'public', + table: 'test', + table_id: expect.any(Number), + }, + ], + comment: null, + dead_rows_estimate: 0, + id: expect.any(Number), + live_rows_estimate: 0, + name: 'test', + primary_keys: [ + { + name: 'id', + schema: 'public', + table_id: expect.any(Number), + table_name: 'test', + }, + ], + relationships: [], + replica_identity: 'DEFAULT', + rls_enabled: false, + rls_forced: false, + schema: 'public', + size: '8192 bytes', + }, + ]); + }); + + test('cannot apply migration in read-only mode', 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 name = 'test-migration'; + const query = + 'create table test (id integer generated always as identity primary key)'; + + const resultPromise = callTool({ + name: 'apply_migration', + arguments: { + project_id: project.id, + name, + query, + }, + }); + + await expect(resultPromise).rejects.toThrow( + 'Cannot apply migration in read-only mode.' + ); }); test('list tables only under a specific schema', async () => { diff --git a/packages/mcp-server-supabase/src/server.ts b/packages/mcp-server-supabase/src/server.ts index e2a4814..a7d7fb5 100644 --- a/packages/mcp-server-supabase/src/server.ts +++ b/packages/mcp-server-supabase/src/server.ts @@ -20,12 +20,27 @@ import { import { hashObject } from './util.js'; export type SupabasePlatformOptions = { - apiUrl?: string; + /** + * The access token for the Supabase Management API. + */ accessToken: string; + + /** + * The API URL for the Supabase Management API. + */ + apiUrl?: string; }; export type SupabaseMcpServerOptions = { + /** + * Platform options for Supabase. + */ platform: SupabasePlatformOptions; + + /** + * Executes database queries in read-only mode if true. + */ + readOnly?: boolean; }; /** @@ -48,6 +63,7 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { }, body: { query, + read_only: options.readOnly, }, } ); @@ -331,6 +347,10 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { query: z.string().describe('The SQL query to apply'), }), execute: async ({ project_id, name, query }) => { + if (options.readOnly) { + throw new Error('Cannot apply migration in read-only mode.'); + } + const response = await managementApiClient.POST( '/v1/projects/{ref}/database/migrations', { diff --git a/packages/mcp-server-supabase/src/stdio.ts b/packages/mcp-server-supabase/src/stdio.ts index 07de350..06838ef 100644 --- a/packages/mcp-server-supabase/src/stdio.ts +++ b/packages/mcp-server-supabase/src/stdio.ts @@ -9,6 +9,7 @@ async function main() { const { values: { ['access-token']: cliAccessToken, + ['read-only']: readOnly, ['api-url']: apiUrl, ['version']: showVersion, }, @@ -17,6 +18,10 @@ async function main() { ['access-token']: { type: 'string', }, + ['read-only']: { + type: 'boolean', + default: false, + }, ['api-url']: { type: 'string', }, @@ -46,6 +51,7 @@ async function main() { accessToken, apiUrl, }, + readOnly, }); const transport = new StdioServerTransport(); diff --git a/packages/mcp-server-supabase/test/mocks.ts b/packages/mcp-server-supabase/test/mocks.ts index 0a40af7..f55059f 100644 --- a/packages/mcp-server-supabase/test/mocks.ts +++ b/packages/mcp-server-supabase/test/mocks.ts @@ -182,7 +182,7 @@ export const mockManagementApi = [ /** * Execute a SQL query on a project's database */ - http.post<{ projectId: string }, { query: string }>( + http.post<{ projectId: string }, { query: string; read_only?: boolean }>( `${API_URL}/v1/projects/:projectId/database/query`, async ({ params, request }) => { const project = mockProjects.get(params.projectId); @@ -193,17 +193,30 @@ export const mockManagementApi = [ ); } const { db } = project; - const { query } = await request.json(); - const [results] = await db.exec(query); + const { query, read_only } = await request.json(); - if (!results) { + // Not secure, but good enough for testing + const wrappedQuery = ` + SET ROLE ${read_only ? 'supabase_read_only_role' : 'postgres'}; + ${query}; + RESET ROLE; + `; + + const statementResults = await db.exec(wrappedQuery); + + // Remove last result, which is for the "RESET ROLE" statement + statementResults.pop(); + + const lastStatementResults = statementResults.at(-1); + + if (!lastStatementResults) { return HttpResponse.json( { message: 'Failed to execute query' }, { status: 500 } ); } - return HttpResponse.json(results.rows); + return HttpResponse.json(lastStatementResults.rows); } ), @@ -657,12 +670,18 @@ export class MockProject { migrations: Migration[] = []; - #db: PGliteInterface; + #db?: PGliteInterface; // Lazy load the database connection get db() { if (!this.#db) { this.#db = new PGlite(); + this.#db.waitReady.then(() => { + this.#db!.exec(` + CREATE ROLE supabase_read_only_role; + GRANT pg_read_all_data TO supabase_read_only_role; + `); + }); } return this.#db; } @@ -694,8 +713,6 @@ export class MockProject { postgres_engine: '15', release_channel: 'ga', }; - - this.#db = new PGlite(); } async applyMigrations() { @@ -711,8 +728,8 @@ export class MockProject { if (this.#db) { await this.#db.close(); } - this.#db = new PGlite(); - return this.#db; + this.#db = undefined; + return this.db; } async destroy() { From 685ca1a20ceae5676e264e245928fcfd39f137f8 Mon Sep 17 00:00:00 2001 From: Greg Richardson Date: Thu, 10 Apr 2025 13:36:20 -0600 Subject: [PATCH 2/2] docs: read only mode --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index a235ff2..cb66827 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,16 @@ Make sure Node.js is available in your system `PATH` environment variable. If yo 3. Restart your MCP client. +### Read-only mode + +If you wish to restrict the Supabase MCP server to read-only queries, set the `--read-only` flag on the CLI command: + +```shell +npx -y @supabase/mcp-server-supabase@latest --access-token= --read-only +``` + +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`. + ## Tools _**Note:** This server is pre-1.0, so expect some breaking changes between versions. Since LLMs will automatically adapt to the tools available, this shouldn't affect most users._