feat: migrate to docker-compose with selfhosted-supabase-mcp
- Replace legacy directory structure (api/, db/, functions/, logs/, pooler/) with single docker-compose.yml based self-hosted setup - Add selfhosted-supabase-mcp TypeScript MCP server for database management - Add .dockerignore for Docker build context - Update .gitignore to exclude .env files, volumes/, backups, logs
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* explain_query - Gets the execution plan for a SQL query.
|
||||
*
|
||||
* SECURITY WARNING: This tool can execute arbitrary SQL when analyze=true.
|
||||
* The ANALYZE option actually runs the query to get real timing data.
|
||||
* For write queries (INSERT/UPDATE/DELETE), this WILL modify data.
|
||||
*
|
||||
* This tool is marked as 'privileged' because:
|
||||
* 1. It can execute any SQL the user provides
|
||||
* 2. With ANALYZE, it runs the query (not just plans it)
|
||||
* 3. Even without ANALYZE, it reveals schema information
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolContext, ToolPrivilegeLevel } from './types.js';
|
||||
import { executeSqlWithFallback, isSqlErrorResponse } from './utils.js';
|
||||
|
||||
// Output schema for query explanation
|
||||
const ExplainQueryOutputSchema = z.object({
|
||||
query: z.string(),
|
||||
plan: z.unknown(), // JSON plan output
|
||||
format: z.string(),
|
||||
analyzed: z.boolean(),
|
||||
planning_time_ms: z.number().optional(),
|
||||
execution_time_ms: z.number().optional(),
|
||||
warnings: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// Input schema
|
||||
const ExplainQueryInputSchema = z.object({
|
||||
sql: z.string().describe('The SQL query to analyze.'),
|
||||
analyze: z.boolean().optional().default(false).describe('Actually execute the query to get real timing. WARNING: This WILL execute the query and modify data for write operations!'),
|
||||
format: z.enum(['json', 'text', 'yaml', 'xml']).optional().default('json').describe('Output format for the plan.'),
|
||||
verbose: z.boolean().optional().default(false).describe('Include additional details in the plan.'),
|
||||
costs: z.boolean().optional().default(true).describe('Include estimated costs.'),
|
||||
buffers: z.boolean().optional().default(false).describe('Include buffer usage statistics (requires ANALYZE).'),
|
||||
timing: z.boolean().optional().default(true).describe('Include actual timing (requires ANALYZE).'),
|
||||
settings: z.boolean().optional().default(false).describe('Include non-default configuration settings.'),
|
||||
});
|
||||
type ExplainQueryInput = z.infer<typeof ExplainQueryInputSchema>;
|
||||
|
||||
// Static JSON Schema for MCP capabilities
|
||||
const mcpInputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sql: {
|
||||
type: 'string',
|
||||
description: 'The SQL query to analyze.',
|
||||
},
|
||||
analyze: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Actually execute the query to get real timing. WARNING: This WILL execute the query and modify data for write operations!',
|
||||
},
|
||||
format: {
|
||||
type: 'string',
|
||||
enum: ['json', 'text', 'yaml', 'xml'],
|
||||
default: 'json',
|
||||
description: 'Output format for the plan.',
|
||||
},
|
||||
verbose: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Include additional details in the plan.',
|
||||
},
|
||||
costs: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Include estimated costs.',
|
||||
},
|
||||
buffers: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Include buffer usage statistics (requires ANALYZE).',
|
||||
},
|
||||
timing: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Include actual timing (requires ANALYZE).',
|
||||
},
|
||||
settings: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Include non-default configuration settings.',
|
||||
},
|
||||
},
|
||||
required: ['sql'],
|
||||
};
|
||||
|
||||
// Helper to detect dangerous write patterns
|
||||
// Note: This is best-effort detection - complex queries may bypass this
|
||||
function detectWriteQuery(sql: string): { isWrite: boolean; queryType: string; confidence: 'high' | 'medium' | 'low' } {
|
||||
const upperSql = sql.toUpperCase().trim();
|
||||
|
||||
// High confidence - starts with known write keywords
|
||||
if (upperSql.startsWith('INSERT')) return { isWrite: true, queryType: 'INSERT', confidence: 'high' };
|
||||
if (upperSql.startsWith('UPDATE')) return { isWrite: true, queryType: 'UPDATE', confidence: 'high' };
|
||||
if (upperSql.startsWith('DELETE')) return { isWrite: true, queryType: 'DELETE', confidence: 'high' };
|
||||
if (upperSql.startsWith('TRUNCATE')) return { isWrite: true, queryType: 'TRUNCATE', confidence: 'high' };
|
||||
if (upperSql.startsWith('DROP')) return { isWrite: true, queryType: 'DROP', confidence: 'high' };
|
||||
if (upperSql.startsWith('ALTER')) return { isWrite: true, queryType: 'ALTER', confidence: 'high' };
|
||||
if (upperSql.startsWith('CREATE')) return { isWrite: true, queryType: 'CREATE', confidence: 'high' };
|
||||
|
||||
// Medium confidence - contains write keywords (could be in CTEs, subqueries)
|
||||
if (/\b(INSERT|UPDATE|DELETE|TRUNCATE|DROP|ALTER|CREATE)\b/.test(upperSql)) {
|
||||
return { isWrite: true, queryType: 'MIXED/CTE', confidence: 'medium' };
|
||||
}
|
||||
|
||||
// Low confidence - appears to be a SELECT
|
||||
return { isWrite: false, queryType: 'SELECT', confidence: 'low' };
|
||||
}
|
||||
|
||||
export const explainQueryTool = {
|
||||
name: 'explain_query',
|
||||
description: 'Gets the execution plan for a SQL query. WARNING: With ANALYZE enabled, the query is actually executed which will modify data for write operations.',
|
||||
// SECURITY: This tool is privileged because it can execute arbitrary SQL
|
||||
privilegeLevel: 'privileged' as ToolPrivilegeLevel,
|
||||
inputSchema: ExplainQueryInputSchema,
|
||||
mcpInputSchema: mcpInputSchema,
|
||||
outputSchema: ExplainQueryOutputSchema,
|
||||
|
||||
execute: async (input: ExplainQueryInput, context: ToolContext) => {
|
||||
const client = context.selfhostedClient;
|
||||
const { sql, analyze, format, verbose, costs, buffers, timing, settings } = input;
|
||||
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check for write queries when using ANALYZE
|
||||
const { isWrite, queryType, confidence } = detectWriteQuery(sql);
|
||||
|
||||
if (analyze) {
|
||||
if (isWrite) {
|
||||
if (confidence === 'high') {
|
||||
warnings.push(`⚠️ CRITICAL: ANALYZE will EXECUTE this ${queryType} query and MODIFY DATA!`);
|
||||
} else {
|
||||
warnings.push(`⚠️ WARNING: Query may contain ${queryType} operations. ANALYZE will execute the query.`);
|
||||
}
|
||||
} else {
|
||||
warnings.push('Note: ANALYZE will execute the query to measure actual timing.');
|
||||
}
|
||||
}
|
||||
|
||||
// Build the EXPLAIN options
|
||||
const options: string[] = [];
|
||||
options.push(`FORMAT ${format.toUpperCase()}`);
|
||||
if (analyze) options.push('ANALYZE true');
|
||||
if (verbose) options.push('VERBOSE true');
|
||||
options.push(`COSTS ${costs}`);
|
||||
if (analyze && buffers) options.push('BUFFERS true');
|
||||
if (analyze) options.push(`TIMING ${timing}`);
|
||||
if (settings) options.push('SETTINGS true');
|
||||
|
||||
const explainSql = `EXPLAIN (${options.join(', ')}) ${sql}`;
|
||||
|
||||
// SECURITY: Use read_only=false when ANALYZE is true since it executes the query
|
||||
// Use read_only=true when just planning (ANALYZE=false)
|
||||
const isReadOnly = !analyze;
|
||||
|
||||
try {
|
||||
const result = await executeSqlWithFallback(client, explainSql, isReadOnly);
|
||||
|
||||
if (isSqlErrorResponse(result)) {
|
||||
throw new Error(result.error.message || 'Failed to explain query');
|
||||
}
|
||||
|
||||
const rows = result as unknown[];
|
||||
|
||||
// Parse the result based on format
|
||||
let plan: unknown;
|
||||
let planningTime: number | undefined;
|
||||
let executionTime: number | undefined;
|
||||
|
||||
if (format === 'json') {
|
||||
// JSON format returns an array with a single object containing 'QUERY PLAN'
|
||||
if (rows.length > 0) {
|
||||
const firstRow = rows[0] as Record<string, unknown>;
|
||||
const queryPlan = firstRow['QUERY PLAN'] || firstRow['query plan'];
|
||||
if (Array.isArray(queryPlan)) {
|
||||
plan = queryPlan;
|
||||
// Extract timing from JSON plan
|
||||
const planObj = queryPlan[0] as Record<string, unknown> | undefined;
|
||||
planningTime = planObj?.['Planning Time'] as number | undefined;
|
||||
executionTime = planObj?.['Execution Time'] as number | undefined;
|
||||
} else {
|
||||
plan = queryPlan;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Text/YAML/XML format returns multiple rows
|
||||
plan = rows.map(row => {
|
||||
const r = row as Record<string, unknown>;
|
||||
return r['QUERY PLAN'] || r['query plan'] || row;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
query: sql,
|
||||
plan,
|
||||
format,
|
||||
analyzed: analyze,
|
||||
planning_time_ms: planningTime,
|
||||
execution_time_ms: executionTime,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Failed to explain query: ${errorMessage}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user