Files
supabase/apps/studio/lib/api/self-hosted/mcp.ts
T
Pedro RodriguesandClaude Opus 4.6 62426253c3 fix: pass exposedSchemas to getLints in MCP advisor operations (#43790)
## Summary

- MCP `getSecurityAdvisors` and `getPerformanceAdvisors` now pass
`exposedSchemas` to `getLints`, fixing empty advisor results in
local/self-hosted environments
- Extracts `DEFAULT_EXPOSED_SCHEMAS` constant shared between the MCP
handler and the `run-lints` API route (cc @joshenlim related
https://github.com/supabase/supabase/pull/40043)
- Adds unit tests for `enrichLintsQuery` and the MCP advisor operations

## The bug

The MCP advisor tools (`get_advisors`) return empty arrays (`[]`) for
**all** scenarios when running locally via `supabase start`. No security
or performance advisors are surfaced, even when the database has clear
issues (e.g., tables with no RLS).

### Root cause

In `lib/api/self-hosted/mcp.ts`, both `getSecurityAdvisors` and
`getPerformanceAdvisors` call `getLints({ headers })` **without passing
`exposedSchemas`**:

```typescript
// Before (mcp.ts:131)
const { data, error } = await getLints({ headers })
```

When `exposedSchemas` is `undefined`, `enrichLintsQuery` in `lints.ts`
skips the `SET LOCAL pgrst.db_schemas = '...'` SQL statement:

```typescript
// lints.ts:23
${!!exposedSchemas ? `set local pgrst.db_schemas = '${exposedSchemas}';` : ''}
```

Without this GUC being set, the splinter SQL queries filter results
using `current_setting('pgrst.db_schemas', 't')` — which returns an
empty string in local environments. Every schema-filtered lint matches
no schemas and returns zero rows.

### Why this only affects local/self-hosted environments

In **hosted Supabase**, PostgREST sets the `pgrst.db_schemas` GUC on its
own database connections based on the project's API configuration. The
Studio MCP server in production reads the same project configuration, so
the GUC is already available.

**Locally**, PostgREST runs in a separate Docker container and only sets
this GUC on _its own_ connections. Studio connects directly to
PostgreSQL (bypassing PostgREST), so
`current_setting('pgrst.db_schemas', 't')` returns `''`.

The HTTP API endpoint (`/api/platform/.../run-lints`) already worked
because `run-lints.ts` passes `exposedSchemas: 'public, storage'` — this
parameter was simply never added to the MCP code path.

## How we verified the fix

### 1. Tests written to fail against the previous code

We wrote two test files that target the exact bug:

**`tests/unit/lints/enrichLintsQuery.test.ts`** — validates the SQL
generation:
- Confirms `SET LOCAL pgrst.db_schemas` is included when
`exposedSchemas` is provided
- Confirms it's omitted when `undefined` or empty (documenting current
behavior)

**`tests/unit/lints/mcp-advisors.test.ts`** — validates the MCP
operations:
- Asserts `getSecurityAdvisors` passes `exposedSchemas` to `getLints`
- Asserts `getPerformanceAdvisors` passes `exposedSchemas` to `getLints`
- Asserts the value matches `DEFAULT_EXPOSED_SCHEMAS`
- Verifies SECURITY/PERFORMANCE category filtering still works

Before the fix, the two `exposedSchemas` assertions failed:

```
FAIL  getSecurityAdvisors should pass exposedSchemas to getLints
  → expected { Object (headers) } to have property "exposedSchemas"

FAIL  getPerformanceAdvisors should pass exposedSchemas to getLints
  → expected { Object (headers) } to have property "exposedSchemas"
```

### 2. Fix applied, all tests pass

After adding `exposedSchemas: DEFAULT_EXPOSED_SCHEMAS` to both MCP
operations, all 14 tests pass (9 new + 5 existing MCP tests).

## Test plan

run `supabase start`, create a table without RLS, call `get_advisors`
via MCP — should return `rls_disabled_in_public` lint

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 09:48:34 +00:00

157 lines
4.1 KiB
TypeScript

import {
ApiKey,
ApiKeyType,
ApplyMigrationOptions,
DatabaseOperations,
DebuggingOperations,
DevelopmentOperations,
ExecuteSqlOptions,
GetLogsOptions,
} from '@supabase/mcp-server-supabase/platform'
import { ResponseError } from 'types'
import { DEFAULT_EXPOSED_SCHEMAS } from './constants'
import { generateTypescriptTypes } from './generate-types'
import { getLints } from './lints'
import { getLogQuery, retrieveAnalyticsData } from './logs'
import { applyAndTrackMigrations, listMigrationVersions } from './migrations'
import { executeQuery } from './query'
import { getProjectSettings } from './settings'
export type GetDatabaseOperationsOptions = {
headers?: HeadersInit
}
export type GetDevelopmentOperationsOptions = {
headers?: HeadersInit
}
export type GetDebuggingOperationsOptions = {
headers?: HeadersInit
}
export function getDatabaseOperations({
headers,
}: GetDatabaseOperationsOptions): DatabaseOperations {
return {
async executeSql<T>(_projectRef: string, options: ExecuteSqlOptions) {
const { query, parameters, read_only: readOnly } = options
const { data, error } = await executeQuery<T>({ query, parameters, headers, readOnly })
if (error) {
throw error
}
return data
},
async listMigrations() {
const { data, error } = await listMigrationVersions({ headers })
if (error) {
throw error
}
return data
},
async applyMigration(_projectRef: string, options: ApplyMigrationOptions) {
const { query, name } = options
const { error } = await applyAndTrackMigrations({ query, name, headers })
if (error) {
throw error
}
},
}
}
export function getDevelopmentOperations({
headers,
}: GetDevelopmentOperationsOptions): DevelopmentOperations {
return {
async getProjectUrl(_projectRef) {
const settings = getProjectSettings()
return `${settings.app_config.protocol}://${settings.app_config.endpoint}`
},
async getPublishableKeys(_projectRef) {
const settings = getProjectSettings()
const anonKey = settings.service_api_keys.find((key) => key.name === 'anon key')
if (!anonKey) {
throw new Error('Anon key not found in project settings')
}
// For self-hosted, only the legacy anon key is available and returned here.
// There is currently no publishable key variable in self-hosted configuration,
// and the migration to new publishable keys requires additional Auth and service setup.
const publishableKeysArray: ApiKey[] = [
{
api_key: anonKey.api_key,
name: anonKey.name,
type: 'anon' as ApiKeyType,
},
]
return publishableKeysArray
},
async generateTypescriptTypes(_projectRef) {
const response = await generateTypescriptTypes({ headers })
if (response instanceof ResponseError) {
throw response
}
return response
},
}
}
export function getDebuggingOperations({
headers,
}: GetDebuggingOperationsOptions): DebuggingOperations {
return {
async getLogs(projectRef: string, options: GetLogsOptions) {
const sql = getLogQuery(options.service)
const { data, error } = await retrieveAnalyticsData({
name: 'logs.all',
projectRef,
params: {
sql,
iso_timestamp_start: options.iso_timestamp_start,
iso_timestamp_end: options.iso_timestamp_end,
},
})
if (error) {
throw error
}
return data
},
async getSecurityAdvisors(_projectRef) {
const { data, error } = await getLints({
headers,
exposedSchemas: DEFAULT_EXPOSED_SCHEMAS,
})
if (error) {
throw error
}
return data.filter((lint) => lint.categories.includes('SECURITY'))
},
async getPerformanceAdvisors(_projectRef) {
const { data, error } = await getLints({
headers,
exposedSchemas: DEFAULT_EXPOSED_SCHEMAS,
})
if (error) {
throw error
}
return data.filter((lint) => lint.categories.includes('PERFORMANCE'))
},
}
}