Files
supabase/apps/studio/lib/ai/tools/mock-tools.ts
Pedro RodriguesandClaude Opus 4.8 c4c213ce3d feat(studio): switch dashboard assistant to remote MCP server (#47479)
## I have read the
[CONTRIBUTING.md](<https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md>)
file.

YES

## What kind of change does this PR introduce?

Feature / refactor.

## What is the current behavior?

The dashboard assistant runs `@supabase/mcp-server-supabase` in-process
over an in-memory transport (`lib/ai/supabase-mcp.ts`).

## What is the new behavior?

The assistant connects to the **remote MCP server** over HTTP
(`@ai-sdk/mcp`), forwarding the dashboard session token as a bearer. URL
comes from `NEXT_PUBLIC_MCP_URL` with a local-dev fallback;
platform-only, and Nimbus works via the same env var.

* **Tool model unchanged:** UI-controlled `execute_sql` (with
`needsApproval`) and `deploy_edge_function` still come from Studio; the
allowlist (`TOOL_CATEGORY_MAP`) remains the gate keeping the remote's
write tools away from the assistant (`read_only` is defense-in-depth).
* **Attribution:** sends `x-source-name: supabase-studio` (+
`x-source-version`) → logged as `source_name`/`client_name`.
* **Connection lifecycle:** the HTTP client is closed via the request's
`AbortSignal` (tools execute later during streaming); `signal` is
required on `getTools`/`getMcpTools`.
* **Resilience:** a remote-MCP failure degrades to the remaining tools
instead of failing the assistant.
* **Drift protection:** relied-upon tools are typed against `keyof
typeof supabaseMcpToolSchemas`, so a package bump that renames/removes
one fails `pnpm typecheck`; a runtime check also warns if the deployed
server returns fewer tools.
* Adds unit tests for the above.

## Additional context

* Verified end-to-end against a local remote MCP server with a dashboard
token: `initialize` 200, tools listed, a tool executed, client closed
cleanly.
* The remote MCP (mgmt-api) already accepts dashboard session tokens
(GoTrue-JWT auth path) — no backend change needed. `NEXT_PUBLIC_MCP_URL`
must point at each env's `/mcp`.
* `@supabase/mcp-server-supabase` is kept — still used by the
self-hosted `/api/mcp` routes.

Closes
[AI-137](https://linear.app/supabase/issue/AI-137/switch-dashboard-assistant-to-remote-mcp)

## Rollout

* **Rollout:** merges with `USE_REMOTE_MCP` off (in-process); flip it to
`true` per environment (staging → prod → Nimbus) once each one's
prerequisites land.
* **Rollback:** unset `USE_REMOTE_MCP` and redeploy to fall back to the
in-process client — no revert needed.

## Summary by CodeRabbit

* **Bug Fixes**
* Improved AI request handling so tool loading and generation clean up
properly when a request is cancelled or the browser connection closes.
* Added safer fallback behavior when remote tool loading fails, so AI
features can continue with available tools instead of stopping entirely.
* Updated remote tool access to use the current project reference and
preserve the correct access headers.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* AI tools now connect more reliably to remote services and stop cleanly
when requests end or are canceled.
* Tool loading is more resilient, continuing with available tools if
remote access is unavailable.

* **Bug Fixes**
* Improved cleanup to prevent lingering connections during SQL
generation and policy workflows.
  * Added safer handling for remote tool changes and invalid responses.

* **Tests**
* Expanded automated coverage for remote tool setup, cancellation, and
fallback behavior.


<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:38:21 +01:00

342 lines
10 KiB
TypeScript

import assert from 'node:assert'
import { tool, type ToolSet } from 'ai'
import { z } from 'zod'
import { getStudioTools } from '../tools/studio-tools'
import { createInProcessSupabaseMCPClient } from '@/lib/ai/supabase-mcp'
const listTablesInputSchema = z.object({
schemas: z.array(z.string()).describe('The schema names to list.'),
})
const getAdvisorsInputSchema = z.object({
type: z.enum(['security', 'performance']).optional(),
})
const getLogsInputSchema = z.object({
limit: z.number().min(1).max(100).optional(),
level: z.enum(['debug', 'info', 'warning', 'error']).optional(),
source: z.enum(['postgres', 'auth', 'storage', 'edge_function']).optional(),
search: z.string().optional(),
})
const listPoliciesInputSchema = z.object({
schemas: z.array(z.string()).describe('The schema names to get the policies for'),
})
export const MOCK_TABLES_DATA = [
{
name: 'user_documents',
rls_enabled: false,
columns: [
{ name: 'id', data_type: 'bigint' },
{ name: 'user_id', data_type: 'uuid' },
{ name: 'title', data_type: 'text' },
],
},
{
name: 'customers',
rls_enabled: true,
columns: [
{ name: 'id', data_type: 'uuid' },
{ name: 'tenant_id', data_type: 'uuid' },
{ name: 'email', data_type: 'text' },
],
},
{
name: 'projects',
rls_enabled: false,
columns: [
{ name: 'id', data_type: 'uuid' },
{ name: 'organization_id', data_type: 'uuid' },
{ name: 'name', data_type: 'text' },
],
},
{
name: 'user_organizations',
rls_enabled: true,
columns: [
{ name: 'user_id', data_type: 'uuid' },
{ name: 'organization_id', data_type: 'uuid' },
],
},
]
const MOCK_EXTENSIONS_DATA = [
{ name: 'pgcrypto', schema: 'extensions', installed_version: '1.3' },
{ name: 'uuid-ossp', schema: 'extensions', installed_version: '1.1' },
{ name: 'pg_cron', schema: 'pg_catalog', installed_version: '1.6.4' },
]
const MOCK_EDGE_FUNCTIONS_DATA = [
{ name: 'hello-world', last_deployed_at: '2024-06-10T12:30:00Z' },
{ name: 'daily-metrics-sync', last_deployed_at: '2024-06-18T08:15:00Z' },
{ name: 'select-from-table-with-auth-rls', last_deployed_at: '2024-06-19T09:20:00Z' },
]
const MOCK_ADVISORIES_DATA = [
{
id: '0016_materialized_view_in_api',
level: 'warning',
category: 'security',
message: 'Materialized views in API schema can bypass RLS. Move them to private schema.',
remediationUrl:
'https://supabase.com/docs/guides/database/database-advisors?queryGroups=lint&lint=0016_materialized_view_in_api',
},
{
id: '0031_functions_no_rls_guard',
level: 'notice',
category: 'security',
message: 'Function api.health_check should verify auth context before querying tables.',
remediationUrl:
'https://supabase.com/docs/guides/database/database-advisors?queryGroups=lint&lint=0031_functions_no_rls_guard',
},
{
id: '1012_slow_query',
level: 'info',
category: 'performance',
message:
'Query on table edge_function_logs exceeded 3s average execution time over the last hour.',
remediationUrl: 'https://supabase.com/docs/guides/platform/performance-advisors#slow-queries',
},
]
const MOCK_LOGS_DATA = [
{
id: 'log-001',
timestamp: '2024-06-20T14:12:00Z',
level: 'error',
source: 'edge_function' as const,
target: 'hello-world',
message: "TypeError: fetch failed at await supabase.functions.invoke('analytics')",
},
{
id: 'log-002',
timestamp: '2024-06-20T14:05:30Z',
level: 'warning',
source: 'postgres' as const,
target: 'connection_pool',
message: 'Query timeout exceeded for statement SELECT * FROM public.audit_log_entries',
},
{
id: 'log-003',
timestamp: '2024-06-20T13:59:10Z',
level: 'info',
source: 'edge_function' as const,
target: 'daily-metrics-sync',
message: 'Invocation completed in 520ms',
},
{
id: 'log-004',
timestamp: '2024-06-20T13:50:00Z',
level: 'error',
source: 'postgres' as const,
target: 'trigger:refresh_materialized_views',
message: 'permission denied for relation user_documents',
},
{
id: 'log-005',
timestamp: '2024-06-20T13:45:00Z',
level: 'info',
source: 'auth' as const,
target: 'email-confirmation',
message: 'Sent verification email to alex@example.com',
},
]
function createMockedStudioTools() {
const studioTools = getStudioTools()
return Object.fromEntries(
Object.entries(studioTools).map(([name, baseTool]) => {
// Always mock execute_sql and deploy_edge_function with needsApproval disabled
if (name === 'execute_sql') {
return [name, { ...baseTool, needsApproval: false, execute: async () => [] as unknown[] }]
}
if (name === 'deploy_edge_function') {
return [
name,
{ ...baseTool, needsApproval: false, execute: async () => ({ success: true }) },
]
}
if (typeof baseTool.execute === 'function') {
return [name, baseTool]
}
return [
name,
{ ...baseTool, execute: async () => ({ status: 'Tool call mocked successfully.' }) },
]
})
) as typeof studioTools
}
function createMockListTablesTool(overrideData?: Record<string, typeof MOCK_TABLES_DATA>) {
return tool({
description: 'Lists tables and columns for the provided schemas.',
inputSchema: listTablesInputSchema,
execute: async ({ schemas }: { schemas: string[] }) => {
const effectiveSchemas = schemas?.length ? schemas : ['public']
return effectiveSchemas.map((schema) => ({
schema,
tables: overrideData?.[schema] ?? MOCK_TABLES_DATA,
}))
},
})
}
function createMockListExtensionsTool() {
return tool({
description: 'Lists installed database extensions.',
inputSchema: z.object({}),
execute: async () => {
return MOCK_EXTENSIONS_DATA
},
})
}
function createMockListEdgeFunctionsTool() {
return tool({
description: 'Lists available Supabase Edge Functions.',
inputSchema: z.object({}),
execute: async () => {
return MOCK_EDGE_FUNCTIONS_DATA
},
})
}
function createMockGetAdvisorsTool() {
return tool({
description: 'Returns advisory notices for the project (mocked).',
inputSchema: getAdvisorsInputSchema,
execute: async ({ type }: { type?: 'security' | 'performance' }) => {
if (type) {
return MOCK_ADVISORIES_DATA.filter((advisory) => advisory.category === type)
}
return MOCK_ADVISORIES_DATA
},
})
}
function createMockGetLogsTool() {
return tool({
description: 'Fetches recent project logs for debugging or health checks (mocked).',
inputSchema: getLogsInputSchema,
execute: async ({
limit = 10,
level,
source,
search,
}: {
limit?: number
level?: 'debug' | 'info' | 'warning' | 'error'
source?: 'postgres' | 'auth' | 'storage' | 'edge_function'
search?: string
}) => {
let filtered = MOCK_LOGS_DATA
if (level) {
filtered = filtered.filter((entry) => entry.level === level)
}
if (source) {
filtered = filtered.filter((entry) => entry.source === source)
}
if (search) {
const needle = search.toLowerCase()
filtered = filtered.filter((entry) =>
`${entry.message} ${entry.target}`.toLowerCase().includes(needle)
)
}
return filtered.slice(0, limit)
},
})
}
function createMockListPoliciesTool() {
return tool({
description: 'Get existing RLS policies for provided schemas.',
inputSchema: listPoliciesInputSchema,
execute: async ({ schemas }: { schemas: string[] }) => {
const effectiveSchemas = schemas?.length ? schemas : ['public']
const results = [] as Array<{
schema: string
table: string
policies: Array<{
name: string
command: 'select' | 'insert' | 'update' | 'delete'
using?: string
check?: string
}>
}>
for (const schema of effectiveSchemas) {
if (schema !== 'public') continue
results.push(
{
schema,
table: 'customers',
policies: [
{
name: 'customers_tenant_select',
command: 'select',
using: "(auth.jwt() ->> 'tenant_id')::uuid = tenant_id",
},
],
},
{ schema, table: 'user_documents', policies: [] },
{ schema, table: 'projects', policies: [] }
)
}
return results
},
})
}
export type MockToolOverrides = {
list_tables?: Record<string, typeof MOCK_TABLES_DATA>
}
/**
* Deterministic mock implementations of MCP/platform tools for evals.
* These mirror tool names used in prompts so the model can call them,
* but return stable, static data for repeatable tests.
*
* Note: search_docs uses the real implementation
*/
export async function getMockTools(overrides: MockToolOverrides | undefined, signal: AbortSignal) {
const mockedStudioTools = createMockedStudioTools()
// Every tool here is a deterministic mock except `search_docs`, which uses the
// real implementation. We source it from an in-process MCP server directly
// (rather than `getMcpTools`) so the eval harness stays hermetic and decoupled
// from the assistant's transport gate (`USE_REMOTE_MCP`): the in-process server
// needs no live remote endpoint or real access token. See AI-897 for how to
// point evals at the remote MCP server instead.
const mcpClient = await createInProcessSupabaseMCPClient({
accessToken: 'mock-access-token',
projectRef: 'mock-project-ref',
})
// The caller owns this signal and aborts it once generation is done, which
// closes the client opened here (search_docs executes during generation, so
// the connection must stay open until then).
signal.addEventListener('abort', () => void mcpClient.close().catch(() => {}), { once: true })
const { search_docs } = (await mcpClient.tools()) as ToolSet
assert(search_docs, 'search_docs tool not available from MCP server')
return {
...mockedStudioTools,
search_docs,
list_tables: createMockListTablesTool(overrides?.list_tables),
list_extensions: createMockListExtensionsTool(),
list_edge_functions: createMockListEdgeFunctionsTool(),
get_advisors: createMockGetAdvisorsTool(),
get_logs: createMockGetLogsTool(),
list_policies: createMockListPoliciesTool(),
}
}