Files
supabase/apps/studio/lib/ai/tools/mock-tools.ts
T
Pedro RodriguesandClaude Opus 4.8 47595f8ac7 feat(self-hosted): implement queryLogs for the MCP debugging tools (#48900)
> [!IMPORTANT]  
>
> Only merge this when (https://github.com/supabase/platform/pull/36804)
is merged, as the AI assistant will not have access to the `query_logs`
tool for the remote MCP server

## 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 (self-hosted / CLI Studio MCP server).

## What is the current behavior?

Self-hosted `getDebuggingOperations`
(`apps/studio/lib/api/self-hosted/mcp.ts`) implements only `getLogs`, so
the MCP `debugging` group exposes `get_logs` — a fixed per-service log
dump built by `getLogQuery`. Logs are served by Logflare, which speaks
BigQuery SQL.

## What is the new behavior?

Bumps `@supabase/mcp-server-supabase` to `^0.10.0` (adds `query_logs` +
`logsDialect`, and hides `get_logs` wherever a platform declares
`queryLogs`) and moves logs over to it.

- **Self-hosted `query_logs`:** declares `logsDialect: 'bigquery'` and
implements `queryLogs`, passing the model's SQL straight through to the
same Logflare `logs.all` endpoint (arbitrary `sql` param) — no new
endpoint, no dialect translation.
- **Drops `get_logs` from self-hosted:** `getLogs` throws (the server
hides it once `queryLogs` exists) and the per-service `getLogQuery`
builder is deleted; the model now writes its own BigQuery SQL, guided by
the dialect schema hint.
- **Honors no-logs mode:** `query_logs` throws when `logs:all` is
disabled — the self-hosted default, enabled via the
`docker-compose.logs.yml` override.
- **Assistant:** switches the dashboard assistant from `get_logs` to
`query_logs` (allowlist, drift guard, prompt, mocks, evals).

Refs AI-1046


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

* **New Features**
  * AI debugging can query recent project logs using read-only SQL.
* Log queries support optional time-range filters, filtering,
aggregation, and joins.
* Self-hosted debugging checks whether logging is enabled before running
queries.

* **Bug Fixes**
* Updated debugging workflows and validation to consistently use the new
log-query capability.
* Removed reliance on legacy service-specific log filtering and query
behavior.

* **Documentation**
* Updated MCP debugging tool guidance to describe SQL-based log queries.

* **Tests**
* Expanded coverage for enabled, disabled, and unsupported logging
scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 13:11:23 +01:00

314 lines
9.6 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 queryLogsInputSchema = z.object({
sql: z.string().min(1),
iso_timestamp_start: z.string().optional(),
iso_timestamp_end: 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 createMockQueryLogsTool() {
return tool({
description:
'Runs a read-only SQL query against recent project logs for debugging or health checks (mocked).',
inputSchema: queryLogsInputSchema,
// Deterministic mock: returns static log data regardless of the SQL passed.
execute: async () => MOCK_LOGS_DATA,
})
}
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(),
query_logs: createMockQueryLogsTool(),
list_policies: createMockListPoliciesTool(),
}
}