From 862a116fb22b57233c3c71e78b75f302a83292d9 Mon Sep 17 00:00:00 2001
From: Greg Richardson <greg.nmr@gmail.com>
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<string, never>[];
-            messages: Record<string, never>[];
-            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<string, never>;
+            custom_jwks?: unknown;
         };
         ThirdPartyAuth: {
+            /** Format: uuid */
             id: string;
             type: string;
             oidc_issuer_url?: string | null;
             jwks_url?: string | null;
-            custom_jwks?: Record<string, never> | null;
-            resolved_jwks?: Record<string, never> | 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 <greg.nmr@gmail.com>
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=<personal-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._