feat: read-only mode for self-hosted MCP (#39041)

* feat: add `crypto-js`, `encryptString` with sample key

* feat: include POSTGRES_PASSWORD in generated .env.test

* feat: include POSTGRES_PASSWORD in turbo.json for studio

* feat: read only query support

* feat: configurable `POSTGRES_HOST`, `POSTGRES_DB`, `POSTGRES_PORT`

* chore: rename POSTGRES_USER to clarify write permission

* feat: configurable `PG_META_CRYPTO_KEY`

* chore: add `PG_META_CRYPTO_KEY` to generateLocalEnv

* feat: add 'postgres-meta' to linter dictionary

* feat: restore read-only toggle in local MCP URL builder
This commit is contained in:
Matt Rossman authored and GitHub committed 2025-10-06 16:14:27 -04:00
1 parent d5d8d954cb
commit 31b6368049
15 files changed
+83 -22

No files matched your search

@@ -220,6 +220,7 @@ Update the `./docker/.env` file with your own secrets. In particular, these are
- `SITE_URL`: the base URL of your site.
- `SMTP_*`: mail server credentials. You can use any SMTP server.
- `POOLER_TENANT_ID`: the tenant-id that will be used by Supavisor pooler for your connection string
- `PG_META_CRYPTO_KEY`: encryption key for securing connection strings between Studio and postgres-meta
You will need to [restart](#restarting-all-services) the services for the changes to take effect.
@@ -0,0 +1,9 @@
// Constants specific to self-hosted environments
export const ENCRYPTION_KEY = process.env.PG_META_CRYPTO_KEY || 'SAMPLE_KEY'
export const POSTGRES_PORT = process.env.POSTGRES_PORT || 5432
export const POSTGRES_HOST = process.env.POSTGRES_HOST || 'db'
export const POSTGRES_DATABASE = process.env.POSTGRES_DB || 'postgres'
export const POSTGRES_PASSWORD = process.env.POSTGRES_PASSWORD || 'postgres'
export const POSTGRES_USER_READ_WRITE = 'postgres'
export const POSTGRES_USER_READ_ONLY = 'supabase_read_only_user'
+2 -2
View File
@@ -23,8 +23,8 @@ export function getDatabaseOperations({
}: GetDatabaseOperationsOptions): DatabaseOperations {
return {
async executeSql<T>(_projectRef: string, options: ExecuteSqlOptions) {
const { query } = options
const { data, error } = await executeQuery<T>({ query, headers })
const { query, read_only: readOnly } = options
const { data, error } = await executeQuery<T>({ query, headers, readOnly })
if (error) {
throw error
+10 -4
View File
@@ -1,10 +1,11 @@
import { PG_META_URL } from 'lib/constants/index'
import { constructHeaders } from '../apiHelpers'
import { PgMetaDatabaseError, databaseErrorSchema, WrappedResult } from './types'
import { assertSelfHosted } from './util'
import { assertSelfHosted, encryptString, getConnectionString } from './util'
export type QueryOptions = {
query: string
readOnly?: boolean
headers?: HeadersInit
}
@@ -15,16 +16,21 @@ export type QueryOptions = {
*/
export async function executeQuery<T = unknown>({
query,
readOnly = false,
headers,
}: QueryOptions): Promise<WrappedResult<T[]>> {
assertSelfHosted()
const connectionString = getConnectionString({ readOnly })
const connectionStringEncrypted = encryptString(connectionString)
const response = await fetch(`${PG_META_URL}/query`, {
method: 'POST',
headers: {
headers: constructHeaders({
...headers,
'Content-Type': 'application/json',
...constructHeaders(headers ?? {}),
},
'x-connection-encrypted': connectionStringEncrypted,
}),
body: JSON.stringify({ query }),
})
+20
View File
@@ -1,4 +1,14 @@
import crypto from 'crypto-js'
import { IS_PLATFORM } from 'lib/constants'
import {
ENCRYPTION_KEY,
POSTGRES_DATABASE,
POSTGRES_HOST,
POSTGRES_PASSWORD,
POSTGRES_PORT,
POSTGRES_USER_READ_WRITE,
POSTGRES_USER_READ_ONLY,
} from './constants'
/**
* Asserts that the current environment is self-hosted.
@@ -8,3 +18,13 @@ export function assertSelfHosted() {
throw new Error('This function can only be called in self-hosted environments')
}
}
export function encryptString(stringToEncrypt: string): string {
return crypto.AES.encrypt(stringToEncrypt, ENCRYPTION_KEY).toString()
}
export function getConnectionString({ readOnly }: { readOnly: boolean }) {
const postgresUser = readOnly ? POSTGRES_USER_READ_ONLY : POSTGRES_USER_READ_WRITE
return `postgresql://${postgresUser}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DATABASE}`
}
+2
View File
@@ -79,6 +79,7 @@
"config": "workspace:*",
"cron-parser": "^4.9.0",
"cronstrue": "^2.50.0",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.10",
"dnd-core": "^16.0.1",
"file-saver": "^2.0.5",
@@ -159,6 +160,7 @@
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.0.0",
"@types/common-tags": "^1.8.1",
"@types/crypto-js": "^4.2.2",
"@types/file-saver": "^2.0.2",
"@types/json-logic-js": "^1.2.1",
"@types/lodash": "^4.14.172",
+8 -2
View File
@@ -1,7 +1,7 @@
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { createSupabaseMcpServer, SupabasePlatform } from '@supabase/mcp-server-supabase'
import { stripIndent } from 'common-tags'
import { commaSeparatedStringIntoArray, fromNodeHeaders } from 'lib/api/apiHelpers'
import { commaSeparatedStringIntoArray, fromNodeHeaders, zBooleanString } from 'lib/api/apiHelpers'
import { getDatabaseOperations, getDevelopmentOperations } from 'lib/api/self-hosted/mcp'
import { DEFAULT_PROJECT } from 'lib/constants/api'
import { NextApiRequest, NextApiResponse } from 'next'
@@ -22,6 +22,11 @@ const mcpQuerySchema = z.object({
`
)
.pipe(z.array(supportedFeatureGroupSchema).optional()),
read_only: zBooleanString()
.default('false')
.describe(
'Indicates whether or not the MCP server should operate in read-only mode. This prevents write operations on any of your databases by executing SQL as a read-only Postgres user.'
),
})
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
@@ -41,7 +46,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
return res.status(400).json({ error: error.flatten().fieldErrors })
}
const { features } = data
const { features, read_only } = data
const headers = fromNodeHeaders(req.headers)
const platform: SupabasePlatform = {
@@ -54,6 +59,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
platform,
projectId: DEFAULT_PROJECT.ref,
features,
readOnly: read_only,
})
const transport = new StreamableHTTPServerTransport({
+1
View File
@@ -11,6 +11,7 @@ DASHBOARD_USERNAME=supabase
DASHBOARD_PASSWORD=this_password_is_insecure_and_should_be_updated
SECRET_KEY_BASE=UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq
VAULT_ENC_KEY=your-encryption-key-32-chars-min
PG_META_CRYPTO_KEY=your-encryption-key-32-chars-min
############
+2
View File
@@ -30,6 +30,7 @@ services:
environment:
STUDIO_PG_META_URL: http://meta:8080
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
@@ -311,6 +312,7 @@ services:
PG_META_DB_NAME: ${POSTGRES_DB}
PG_META_DB_USER: supabase_admin
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
functions:
container_name: supabase-edge-functions
@@ -34,19 +34,17 @@ export function McpConfigurationOptions({
return (
<div className={cn('flex flex-col gap-4 lg:flex-row lg:gap-12 lg:items-baseline', className)}>
{/* Readonly Mode */}
{isPlatform && (
<div className="space-y-3 lg:flex-shrink-0">
<div className="flex items-center gap-2">
<Label htmlFor="readonly" className="text-sm">
Read-only
</Label>
<InfoTooltip>Only allow read operations on your database</InfoTooltip>
</div>
<div className="flex items-center space-x-2">
<Switch id="readonly" checked={readonly} onCheckedChange={onReadonlyChange} />
</div>
<div className="space-y-3 lg:flex-shrink-0">
<div className="flex items-center gap-2">
<Label htmlFor="readonly" className="text-sm">
Read-only
</Label>
<InfoTooltip>Only allow read operations on your database</InfoTooltip>
</div>
)}
<div className="flex items-center space-x-2">
<Switch id="readonly" checked={readonly} onCheckedChange={onReadonlyChange} />
</div>
</div>
{/* Feature Groups */}
<div className="space-y-3 lg:flex-1">
@@ -28,7 +28,7 @@ export function getMcpUrl({
if (projectRef && isPlatform) {
url.searchParams.set('project_ref', projectRef)
}
if (readonly && isPlatform) {
if (readonly) {
url.searchParams.set('read_only', 'true')
}
if (features.length > 0) {
+6
View File
@@ -876,6 +876,9 @@ importers:
cronstrue:
specifier: ^2.50.0
version: 2.50.0
crypto-js:
specifier: ^4.2.0
version: 4.2.0
dayjs:
specifier: ^1.11.10
version: 1.11.13
@@ -1111,6 +1114,9 @@ importers:
'@types/common-tags':
specifier: ^1.8.1
version: 1.8.4
'@types/crypto-js':
specifier: ^4.2.2
version: 4.2.2
'@types/file-saver':
specifier: ^2.0.2
version: 2.0.5
+5 -1
View File
@@ -8,11 +8,15 @@ const generatedEnv = require('../keys.json')
*/
const defaultEnv = {
// POSTGRES_PASSWORD: 'postgres',
// NEXT_ANALYTICS_BACKEND_PROVIDER: 'postgres',
// SUPABASE_REST_URL: 'http://127.0.0.1:54321/rest/v1/',
// NEXT_PUBLIC_ENABLE_LOGS: 'false',
// NEXT_PUBLIC_IS_PLATFORM: 'false',
PG_META_CRYPTO_KEY: 'SAMPLE_KEY',
POSTGRES_PASSWORD: 'postgres',
POSTGRES_HOST: 'db',
POSTGRES_DB: 'postgres',
POSTGRES_PORT: '5432',
SUPABASE_ANON_KEY: '$ANON_KEY',
SUPABASE_SERVICE_KEY: '$SERVICE_ROLE_KEY',
SUPABASE_URL: '$API_URL',
+1
View File
@@ -246,6 +246,7 @@ allow_list = [
"PostQUEL",
"PostgREST",
"Postgres",
"postgres-meta",
# We prefer Postgres, but check for vocabulary preference in a separate rule
"PostgreSQL",
"ProGuard",
+5
View File
@@ -82,6 +82,11 @@
// These envs are technically passthrough env vars because they're only used on the server side of Nextjs
"PLATFORM_PG_META_URL",
"STUDIO_PG_META_URL",
"PG_META_CRYPTO_KEY",
"POSTGRES_PASSWORD",
"POSTGRES_HOST",
"POSTGRES_DB",
"POSTGRES_PORT",
"READ_ONLY_URL",
"READ_ONLY_API_KEY",
"SUPABASE_SERVICE_KEY",