/** * 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; // 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; 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 | 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; 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}`); } }, };