import { z } from 'zod'; import { executeSqlWithFallback, isSqlErrorResponse } from './utils.js'; import type { ToolContext } from './types.js'; // Advisor types const AdvisorTypeSchema = z.enum(['security', 'performance']); type AdvisorType = z.infer; // Schema for advisor issue output const AdvisorIssueSchema = z.object({ code: z.string(), name: z.string(), level: z.enum(['warning', 'error', 'info']), description: z.string(), detail: z.string().nullable(), remediation: z.string().nullable(), affected_object: z.string().nullable(), }); const GetAdvisorsOutputSchema = z.object({ issues: z.array(AdvisorIssueSchema), type: AdvisorTypeSchema, total_count: z.number(), }); type GetAdvisorsOutput = z.infer; // Input schema const GetAdvisorsInputSchema = z.object({ type: AdvisorTypeSchema.describe('The type of advisors to retrieve (security or performance)'), }); type GetAdvisorsInput = z.infer; // Static JSON Schema for MCP capabilities const mcpInputSchema = { type: 'object', properties: { type: { type: 'string', enum: ['security', 'performance'], description: 'The type of advisors to retrieve (security or performance)', }, }, required: ['type'], }; // SQL queries for security checks (ported from Supabase Splinter) const securityChecks = { // 0013 - RLS disabled in public schema rls_disabled_in_public: ` SELECT '0013' as code, 'rls_disabled_in_public' as name, 'warning' as level, 'Tables in the public schema without Row Level Security enabled' as description, format('Table: %I.%I', n.nspname, c.relname) as detail, 'Enable RLS with: ALTER TABLE ' || quote_ident(n.nspname) || '.' || quote_ident(c.relname) || ' ENABLE ROW LEVEL SECURITY;' as remediation, format('%I.%I', n.nspname, c.relname) as affected_object FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'r' AND n.nspname = 'public' AND NOT c.relrowsecurity AND c.relname NOT LIKE 'pg_%' AND c.relname NOT LIKE '_pg_%' `, // 0007 - Policy exists but RLS disabled policy_exists_rls_disabled: ` SELECT '0007' as code, 'policy_exists_rls_disabled' as name, 'warning' as level, 'Tables with RLS policies defined but RLS is disabled' as description, format('Table: %I.%I has policies but RLS is disabled', n.nspname, c.relname) as detail, 'Enable RLS with: ALTER TABLE ' || quote_ident(n.nspname) || '.' || quote_ident(c.relname) || ' ENABLE ROW LEVEL SECURITY;' as remediation, format('%I.%I', n.nspname, c.relname) as affected_object FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'r' AND NOT c.relrowsecurity AND EXISTS ( SELECT 1 FROM pg_catalog.pg_policy p WHERE p.polrelid = c.oid ) `, // 0008 - RLS enabled but no policy rls_enabled_no_policy: ` SELECT '0008' as code, 'rls_enabled_no_policy' as name, 'error' as level, 'Tables with RLS enabled but no policies defined (blocks all access)' as description, format('Table: %I.%I has RLS enabled but no policies', n.nspname, c.relname) as detail, 'Add a policy or disable RLS if not needed' as remediation, format('%I.%I', n.nspname, c.relname) as affected_object FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'r' AND c.relrowsecurity AND NOT EXISTS ( SELECT 1 FROM pg_catalog.pg_policy p WHERE p.polrelid = c.oid ) AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') `, // 0002 - Auth users exposed via view auth_users_exposed: ` SELECT '0002' as code, 'auth_users_exposed' as name, 'error' as level, 'Views exposing auth.users data' as description, format('View: %I.%I may expose auth.users', n.nspname, c.relname) as detail, 'Review and restrict the view definition or add proper RLS' as remediation, format('%I.%I', n.nspname, c.relname) as affected_object FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace JOIN pg_catalog.pg_depend d ON d.objid = c.oid JOIN pg_catalog.pg_class dep_c ON dep_c.oid = d.refobjid JOIN pg_catalog.pg_namespace dep_n ON dep_n.oid = dep_c.relnamespace WHERE c.relkind IN ('v', 'm') AND n.nspname = 'public' AND dep_n.nspname = 'auth' AND dep_c.relname = 'users' `, }; // SQL queries for performance checks (ported from Supabase Splinter) const performanceChecks = { // 0001 - Unindexed foreign keys unindexed_foreign_keys: ` SELECT '0001' as code, 'unindexed_foreign_keys' as name, 'warning' as level, 'Foreign keys without covering indexes can impact performance' as description, format('FK on %I.%I (%s) lacks an index', cn.nspname, c.conrelid::regclass::text, array_to_string(ARRAY( SELECT a.attname FROM unnest(c.conkey) WITH ORDINALITY AS u(attnum, i) JOIN pg_attribute a ON a.attnum = u.attnum AND a.attrelid = c.conrelid ORDER BY u.i ), ', ')) as detail, 'Create an index on the foreign key columns' as remediation, c.conrelid::regclass::text as affected_object FROM pg_catalog.pg_constraint c JOIN pg_catalog.pg_namespace cn ON cn.oid = c.connamespace WHERE c.contype = 'f' AND NOT EXISTS ( SELECT 1 FROM pg_catalog.pg_index i WHERE i.indrelid = c.conrelid AND c.conkey <@ i.indkey::int2[] ) AND cn.nspname NOT IN ('pg_catalog', 'information_schema') `, // 0004 - Missing primary keys missing_primary_keys: ` SELECT '0004' as code, 'missing_primary_key' as name, 'warning' as level, 'Tables without primary keys are inefficient at scale' as description, format('Table: %I.%I has no primary key', n.nspname, c.relname) as detail, 'Add a primary key to the table' as remediation, format('%I.%I', n.nspname, c.relname) as affected_object FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'r' AND NOT EXISTS ( SELECT 1 FROM pg_catalog.pg_constraint con WHERE con.conrelid = c.oid AND con.contype = 'p' ) AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'cron', 'extensions', 'graphql', 'graphql_public', 'realtime', 'storage', 'supabase_functions', 'supabase_migrations', 'vault', 'pgsodium', 'pgsodium_masks', 'auth', 'net', '_realtime') `, // 0005 - Unused indexes unused_indexes: ` SELECT '0005' as code, 'unused_index' as name, 'info' as level, 'Indexes with zero scans that may be candidates for removal' as description, format('Index: %I.%I on %I.%I has had 0 scans', sn.nspname, i.relname, tn.nspname, t.relname) as detail, 'Consider dropping the index if it is not needed' as remediation, format('%I.%I', sn.nspname, i.relname) as affected_object FROM pg_catalog.pg_stat_user_indexes s JOIN pg_catalog.pg_index ix ON ix.indexrelid = s.indexrelid JOIN pg_catalog.pg_class i ON i.oid = s.indexrelid JOIN pg_catalog.pg_class t ON t.oid = s.relid JOIN pg_catalog.pg_namespace sn ON sn.oid = i.relnamespace JOIN pg_catalog.pg_namespace tn ON tn.oid = t.relnamespace WHERE s.idx_scan = 0 AND NOT ix.indisunique AND NOT ix.indisprimary AND sn.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') `, // 0009 - Duplicate indexes duplicate_indexes: ` SELECT '0009' as code, 'duplicate_index' as name, 'warning' as level, 'Duplicate indexes waste storage and slow writes' as description, format('Indexes %I and %I on %I.%I have identical definitions', i1.relname, i2.relname, tn.nspname, t.relname) as detail, 'Consider dropping one of the duplicate indexes' as remediation, format('%I.%I', sn.nspname, i1.relname) as affected_object FROM pg_catalog.pg_index x1 JOIN pg_catalog.pg_index x2 ON x1.indrelid = x2.indrelid AND x1.indexrelid < x2.indexrelid JOIN pg_catalog.pg_class i1 ON i1.oid = x1.indexrelid JOIN pg_catalog.pg_class i2 ON i2.oid = x2.indexrelid JOIN pg_catalog.pg_class t ON t.oid = x1.indrelid JOIN pg_catalog.pg_namespace sn ON sn.oid = i1.relnamespace JOIN pg_catalog.pg_namespace tn ON tn.oid = t.relnamespace WHERE x1.indkey = x2.indkey AND x1.indclass = x2.indclass AND x1.indoption = x2.indoption AND sn.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') `, }; // Tool definition export const getAdvisorsTool = { name: 'get_advisors', description: 'Gets security or performance advisory notices for the database. Based on Supabase Splinter linting rules. Helps identify issues like missing RLS policies, unindexed foreign keys, and other common problems.', inputSchema: GetAdvisorsInputSchema, mcpInputSchema: mcpInputSchema, outputSchema: GetAdvisorsOutputSchema, execute: async (input: GetAdvisorsInput, context: ToolContext): Promise => { const client = context.selfhostedClient; const { type } = input; const checks = type === 'security' ? securityChecks : performanceChecks; const allIssues: z.infer[] = []; for (const [checkName, sql] of Object.entries(checks)) { try { const result = await executeSqlWithFallback(client, sql, true); if (isSqlErrorResponse(result)) { context.log(`Error running ${checkName}: ${result.error.message}`, 'warn'); continue; } if (Array.isArray(result)) { for (const row of result) { allIssues.push({ code: String(row.code || ''), name: String(row.name || checkName), level: (row.level as 'warning' | 'error' | 'info' | undefined) ?? 'warning', description: String(row.description || ''), detail: row.detail ? String(row.detail) : null, remediation: row.remediation ? String(row.remediation) : null, affected_object: row.affected_object ? String(row.affected_object) : null, }); } } } catch (error) { context.log(`Failed to run ${checkName}: ${error}`, 'warn'); } } // Sort by level (error first, then warning, then info) const levelOrder = { error: 0, warning: 1, info: 2 }; allIssues.sort((a, b) => levelOrder[a.level] - levelOrder[b.level]); return { issues: allIssues, type, total_count: allIssues.length, }; }, };