mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
## 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>
103 lines
3.3 KiB
TypeScript
103 lines
3.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { DEFAULT_EXPOSED_SCHEMAS } from 'lib/api/self-hosted/constants'
|
|
|
|
// Mock getLints to capture what arguments the MCP operations pass
|
|
const mockGetLints = vi.fn()
|
|
vi.mock('lib/api/self-hosted/lints', () => ({
|
|
getLints: (...args: unknown[]) => mockGetLints(...args),
|
|
}))
|
|
|
|
// Mock getProjectSettings to avoid assertSelfHosted() check
|
|
vi.mock('lib/api/self-hosted/settings', () => ({
|
|
getProjectSettings: () => ({
|
|
app_config: {
|
|
db_schema: 'public',
|
|
endpoint: 'localhost',
|
|
protocol: 'http',
|
|
},
|
|
service_api_keys: [{ api_key: 'test', name: 'anon key', tags: 'anon' }],
|
|
}),
|
|
}))
|
|
|
|
// Must import after mock setup
|
|
import { getDebuggingOperations } from 'lib/api/self-hosted/mcp'
|
|
|
|
describe('MCP advisor operations pass exposedSchemas to getLints', () => {
|
|
const headers = { Authorization: 'Bearer test' }
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockGetLints.mockResolvedValue({
|
|
data: [
|
|
{
|
|
name: 'rls_disabled_in_public',
|
|
title: 'RLS Disabled in Public',
|
|
level: 'ERROR',
|
|
categories: ['SECURITY'],
|
|
description: 'test',
|
|
detail: 'test',
|
|
remediation: 'test',
|
|
metadata: {},
|
|
cache_key: 'test',
|
|
},
|
|
{
|
|
name: 'unindexed_foreign_keys',
|
|
title: 'Unindexed foreign keys',
|
|
level: 'INFO',
|
|
categories: ['PERFORMANCE'],
|
|
description: 'test',
|
|
detail: 'test',
|
|
remediation: 'test',
|
|
metadata: {},
|
|
cache_key: 'test',
|
|
},
|
|
],
|
|
error: undefined,
|
|
})
|
|
})
|
|
|
|
it('getSecurityAdvisors should pass exposedSchemas to getLints', async () => {
|
|
const ops = getDebuggingOperations({ headers })
|
|
await ops.getSecurityAdvisors('test-project')
|
|
|
|
expect(mockGetLints).toHaveBeenCalledOnce()
|
|
const callArgs = mockGetLints.mock.calls[0][0]
|
|
expect(callArgs).toHaveProperty('exposedSchemas')
|
|
expect(callArgs.exposedSchemas).toBeTruthy()
|
|
})
|
|
|
|
it('getPerformanceAdvisors should pass exposedSchemas to getLints', async () => {
|
|
const ops = getDebuggingOperations({ headers })
|
|
await ops.getPerformanceAdvisors('test-project')
|
|
|
|
expect(mockGetLints).toHaveBeenCalledOnce()
|
|
const callArgs = mockGetLints.mock.calls[0][0]
|
|
expect(callArgs).toHaveProperty('exposedSchemas')
|
|
expect(callArgs.exposedSchemas).toBeTruthy()
|
|
})
|
|
|
|
it('should use DEFAULT_EXPOSED_SCHEMAS constant', async () => {
|
|
const ops = getDebuggingOperations({ headers })
|
|
await ops.getSecurityAdvisors('test-project')
|
|
|
|
const callArgs = mockGetLints.mock.calls[0][0]
|
|
expect(callArgs.exposedSchemas).toBe(DEFAULT_EXPOSED_SCHEMAS)
|
|
})
|
|
|
|
it('getSecurityAdvisors should filter to SECURITY category', async () => {
|
|
const ops = getDebuggingOperations({ headers })
|
|
const result = await ops.getSecurityAdvisors('test-project')
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect((result as Array<{ name: string }>)[0].name).toBe('rls_disabled_in_public')
|
|
})
|
|
|
|
it('getPerformanceAdvisors should filter to PERFORMANCE category', async () => {
|
|
const ops = getDebuggingOperations({ headers })
|
|
const result = await ops.getPerformanceAdvisors('test-project')
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect((result as Array<{ name: string }>)[0].name).toBe('unindexed_foreign_keys')
|
|
})
|
|
})
|