- 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
211 lines
9.0 KiB
TypeScript
211 lines
9.0 KiB
TypeScript
/**
|
|
* 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}`);
|
|
}
|
|
},
|
|
};
|