@indiekitai/pg-dash 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js CHANGED
@@ -2390,6 +2390,514 @@ async function getConfigReport(pool2) {
2390
2390
  };
2391
2391
  }
2392
2392
 
2393
+ // src/server/queries/db-context.ts
2394
+ function inferBusinessIntent(tableName, columnNames) {
2395
+ const name = tableName.toLowerCase();
2396
+ const cols = columnNames.map((c) => c.toLowerCase());
2397
+ const patterns = [
2398
+ [/^(user|users?|account|accounts?|customer|customers?|member|members?)$/, "\u7528\u6237/\u4F1A\u5458\u7BA1\u7406"],
2399
+ [/^(order|orders?|purchase|purchases?)$/, "\u8BA2\u5355/\u8D2D\u4E70\u8BB0\u5F55"],
2400
+ [/^(product|products?|item|items?|goods?)$/, "\u5546\u54C1/\u4EA7\u54C1\u76EE\u5F55"],
2401
+ [/^(payment|payments?|transaction|transactions?|invoice|invoices?)$/, "\u652F\u4ED8/\u4EA4\u6613\u8BB0\u5F55"],
2402
+ [/^(session|sessions?|auth|authentication|token|tokens?)$/, "\u8BA4\u8BC1/\u4F1A\u8BDD\u7BA1\u7406"],
2403
+ [/^(log|logs?|audit|audits?|history|histories?)$/, "\u65E5\u5FD7/\u5BA1\u8BA1\u8BB0\u5F55"],
2404
+ [/^(config|configuration|settings?)$/, "\u914D\u7F6E/\u8BBE\u7F6E"],
2405
+ [/^(category|categories?|tag|tags?|group|groups?)$/, "\u5206\u7C7B/\u6807\u7B7E/\u5206\u7EC4"],
2406
+ [/^(comment|comments?|review|reviews?|feedback)$/, "\u8BC4\u8BBA/\u53CD\u9988"],
2407
+ [/^(notification|notifications?|message|messages?)$/, "\u901A\u77E5/\u6D88\u606F"],
2408
+ [/^(file|files?|attachment|attachments?|media)$/, "\u6587\u4EF6/\u5A92\u4F53"],
2409
+ [/^(api[_-]?key|api[_-]?key|key|keys?|credential|credentials?)$/, "API \u5BC6\u94A5/\u51ED\u8BC1"],
2410
+ [/^(job|jobs?|queue|queues?|task|tasks?)$/, "\u4EFB\u52A1/\u961F\u5217"],
2411
+ [/^(subscription|subscriptions?|plan|plans?)$/, "\u8BA2\u9605/\u5957\u9910"],
2412
+ [/^(coupon|coupons?|promo|promotion|promotions?)$/, "\u4F18\u60E0/\u4FC3\u9500"],
2413
+ [/^(analytics?|statistic|statistics?|metric|metrics?)$/, "\u5206\u6790/\u7EDF\u8BA1"]
2414
+ ];
2415
+ for (const [pattern, intent] of patterns) {
2416
+ if (pattern.test(name)) return intent;
2417
+ }
2418
+ const colPatterns = [
2419
+ [/user_id|customer_id|member_id/, "\u7528\u6237\u5173\u8054"],
2420
+ [/order_id|purchase_id/, "\u8BA2\u5355\u5173\u8054"],
2421
+ [/product_id|item_id/, "\u5546\u54C1\u5173\u8054"],
2422
+ [/status|state/, "\u72B6\u6001\u7BA1\u7406"],
2423
+ [/created_at|updated_at|deleted_at/, "\u65F6\u95F4\u6233/\u8F6F\u5220\u9664"],
2424
+ [/email|phone|address/, "\u8054\u7CFB\u4FE1\u606F"],
2425
+ [/price|amount|total|cost/, "\u91D1\u989D/\u4EF7\u683C"],
2426
+ [/quantity|count|qty/, "\u6570\u91CF"],
2427
+ [/latitude|longitude|location/, "\u5730\u7406\u4F4D\u7F6E"],
2428
+ [/ip|user_agent|browser/, "\u8BBF\u95EE\u4FE1\u606F"]
2429
+ ];
2430
+ const matchedColHints = colPatterns.filter(
2431
+ ([pattern]) => cols.some((col) => pattern.test(col))
2432
+ ).map(([, hint]) => hint);
2433
+ if (matchedColHints.length > 0) {
2434
+ return `\u6570\u636E\u8868 (\u53EF\u80FD\u7528\u9014: ${matchedColHints.slice(0, 2).join("\u3001")})`;
2435
+ }
2436
+ return "\u901A\u7528\u6570\u636E\u8868";
2437
+ }
2438
+ async function getDbContext(pool2) {
2439
+ const client = await pool2.connect();
2440
+ try {
2441
+ const tablesResult = await client.query(`
2442
+ SELECT
2443
+ n.nspname AS schema,
2444
+ c.relname AS table_name,
2445
+ pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
2446
+ pg_total_relation_size(c.oid) AS total_size_bytes,
2447
+ pg_relation_size(c.oid) AS table_size_bytes,
2448
+ pg_indexes_size(c.oid) AS index_size_bytes,
2449
+ s.n_live_tup AS row_count,
2450
+ s.n_dead_tup AS dead_tuples,
2451
+ obj_description(c.oid) AS description
2452
+ FROM pg_class c
2453
+ JOIN pg_namespace n ON c.relnamespace = n.oid
2454
+ LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
2455
+ WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog', 'information_schema')
2456
+ ORDER BY pg_total_relation_size(c.oid) DESC
2457
+ `);
2458
+ const columnsResult = await client.query(`
2459
+ SELECT
2460
+ n.nspname AS schema,
2461
+ c.relname AS table_name,
2462
+ a.attname AS column_name,
2463
+ pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
2464
+ NOT a.attnotnull AS is_nullable,
2465
+ pg_get_expr(d.adbin, d.adrelid) AS default_value,
2466
+ col_description(a.attrelid, a.attnum) AS description,
2467
+ a.attnum AS ordinal_position
2468
+ FROM pg_attribute a
2469
+ JOIN pg_class c ON a.attrelid = c.oid
2470
+ JOIN pg_namespace n ON c.relnamespace = n.oid
2471
+ LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum
2472
+ WHERE a.attnum > 0 AND NOT a.attisdropped
2473
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
2474
+ ORDER BY n.nspname, c.relname, a.attnum
2475
+ `);
2476
+ const pkResult = await client.query(`
2477
+ SELECT
2478
+ n.nspname AS schema,
2479
+ c.relname AS table_name,
2480
+ a.attname AS column_name
2481
+ FROM pg_index idx
2482
+ JOIN pg_class c ON idx.indrelid = c.oid
2483
+ JOIN pg_namespace n ON c.relnamespace = n.oid
2484
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(idx.indkey)
2485
+ WHERE idx.indisprimary = true
2486
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
2487
+ ORDER BY n.nspname, c.relname, a.attnum
2488
+ `);
2489
+ const fkResult = await client.query(`
2490
+ SELECT
2491
+ n.nspname AS schema,
2492
+ c.relname AS table_name,
2493
+ a.attname AS column_name,
2494
+ ref_n.nspname AS referenced_schema,
2495
+ ref_c.relname AS referenced_table,
2496
+ ref_a.attname AS referenced_column,
2497
+ conname AS constraint_name
2498
+ FROM pg_constraint con
2499
+ JOIN pg_class c ON con.conrelid = c.oid
2500
+ JOIN pg_namespace n ON c.relnamespace = n.oid
2501
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(con.conkey)
2502
+ JOIN pg_class ref_c ON con.confrelid = ref_c.oid
2503
+ JOIN pg_namespace ref_n ON ref_c.relnamespace = ref_n.oid
2504
+ JOIN pg_attribute ref_a ON ref_a.attrelid = ref_c.oid AND ref_a.attnum = ANY(con.confkey)
2505
+ WHERE con.contype = 'f'
2506
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
2507
+ ORDER BY n.nspname, c.relname, con.conname
2508
+ `);
2509
+ const indexesResult = await client.query(`
2510
+ SELECT
2511
+ n.nspname AS schema,
2512
+ t.relname AS table_name,
2513
+ i.relname AS index_name,
2514
+ am.amname AS index_type,
2515
+ pg_get_indexdef(idx.indexrelid) AS definition,
2516
+ idx.indisunique AS is_unique,
2517
+ idx.indisprimary AS is_primary,
2518
+ pg_relation_size(i.oid) AS size_bytes
2519
+ FROM pg_index idx
2520
+ JOIN pg_class i ON idx.indexrelid = i.oid
2521
+ JOIN pg_class t ON idx.indrelid = t.oid
2522
+ JOIN pg_namespace n ON t.relnamespace = n.oid
2523
+ JOIN pg_am am ON i.relam = am.oid
2524
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
2525
+ ORDER BY t.relname, i.relname
2526
+ `);
2527
+ const tables = tablesResult.rows;
2528
+ const allColumns = columnsResult.rows;
2529
+ const primaryKeys = pkResult.rows;
2530
+ const foreignKeys = fkResult.rows;
2531
+ const indexes = indexesResult.rows;
2532
+ const columnsByTable = /* @__PURE__ */ new Map();
2533
+ for (const col of allColumns) {
2534
+ const key = `${col.schema}.${col.table_name}`;
2535
+ if (!columnsByTable.has(key)) columnsByTable.set(key, []);
2536
+ columnsByTable.get(key).push(col);
2537
+ }
2538
+ const pkByTable = /* @__PURE__ */ new Map();
2539
+ for (const pk of primaryKeys) {
2540
+ const key = `${pk.schema}.${pk.table_name}`;
2541
+ if (!pkByTable.has(key)) pkByTable.set(key, []);
2542
+ pkByTable.get(key).push(pk.column_name);
2543
+ }
2544
+ const fkByTable = /* @__PURE__ */ new Map();
2545
+ for (const fk of foreignKeys) {
2546
+ const key = `${fk.schema}.${fk.table_name}`;
2547
+ if (!fkByTable.has(key)) fkByTable.set(key, []);
2548
+ const fks = fkByTable.get(key);
2549
+ if (!fks.some((existing) => existing.constraint_name === fk.constraint_name)) {
2550
+ fks.push(fk);
2551
+ }
2552
+ }
2553
+ const indexesByTable = /* @__PURE__ */ new Map();
2554
+ for (const idx of indexes) {
2555
+ const key = `${idx.schema}.${idx.table_name}`;
2556
+ if (!indexesByTable.has(key)) indexesByTable.set(key, []);
2557
+ indexesByTable.get(key).push(idx);
2558
+ }
2559
+ const tableSummaries = tables.map((table) => {
2560
+ const key = `${table.schema}.${table.table_name}`;
2561
+ const columns = columnsByTable.get(key) || [];
2562
+ const pks = pkByTable.get(key) || [];
2563
+ const fks = fkByTable.get(key) || [];
2564
+ const tableIndexes = indexesByTable.get(key) || [];
2565
+ return {
2566
+ schema: table.schema,
2567
+ name: table.table_name,
2568
+ description: table.description,
2569
+ rowCount: table.row_count || 0,
2570
+ totalSize: table.total_size,
2571
+ tableSizeBytes: parseInt(table.table_size_bytes) || 0,
2572
+ indexSizeBytes: parseInt(table.index_size_bytes) || 0,
2573
+ deadTuples: table.dead_tuples || 0,
2574
+ businessIntent: inferBusinessIntent(
2575
+ table.table_name,
2576
+ columns.map((c) => c.column_name)
2577
+ ),
2578
+ columns: columns.map((col) => ({
2579
+ name: col.column_name,
2580
+ type: col.data_type,
2581
+ nullable: col.is_nullable,
2582
+ defaultValue: col.default_value,
2583
+ description: col.description,
2584
+ isPrimaryKey: pks.includes(col.column_name),
2585
+ isForeignKey: fks.some((fk) => fk.column_name === col.column_name),
2586
+ referencedTable: fks.find((fk) => fk.column_name === col.column_name)?.referenced_table,
2587
+ referencedColumn: fks.find((fk) => fk.column_name === col.column_name)?.referenced_column
2588
+ })),
2589
+ primaryKeys: pks,
2590
+ foreignKeys: fks.map((fk) => ({
2591
+ column: fk.column_name,
2592
+ references: `${fk.referenced_schema}.${fk.referenced_table}.${fk.referenced_column}`
2593
+ })),
2594
+ indexes: tableIndexes.map((idx) => ({
2595
+ name: idx.index_name,
2596
+ type: idx.index_type,
2597
+ definition: idx.definition,
2598
+ isUnique: idx.is_unique,
2599
+ isPrimary: idx.is_primary,
2600
+ sizeBytes: parseInt(idx.size_bytes) || 0
2601
+ }))
2602
+ };
2603
+ });
2604
+ const indexSummary = tables.map((table) => {
2605
+ const key = `${table.schema}.${table.table_name}`;
2606
+ const tableIndexes = indexesByTable.get(key) || [];
2607
+ return {
2608
+ table: `${table.schema}.${table.table_name}`,
2609
+ hasIndexes: tableIndexes.length > 0,
2610
+ indexCount: tableIndexes.length,
2611
+ indexTypes: [...new Set(tableIndexes.map((i) => i.index_type))],
2612
+ primaryIndex: tableIndexes.some((i) => i.is_primary),
2613
+ uniqueIndexes: tableIndexes.filter((i) => i.is_unique).length
2614
+ };
2615
+ });
2616
+ return {
2617
+ database: {
2618
+ schema: tables[0]?.schema || "public",
2619
+ tableCount: tables.length,
2620
+ totalSize: tables.reduce((sum, t) => sum + (parseInt(t.total_size_bytes) || 0), 0),
2621
+ totalRows: tables.reduce((sum, t) => sum + (t.row_count || 0), 0)
2622
+ },
2623
+ tables: tableSummaries,
2624
+ indexSummary
2625
+ };
2626
+ } finally {
2627
+ client.release();
2628
+ }
2629
+ }
2630
+
2631
+ // src/server/llm.ts
2632
+ function getLLMConfig() {
2633
+ const provider = process.env.PG_DASH_LLM_PROVIDER || "openai";
2634
+ return {
2635
+ provider,
2636
+ apiKey: process.env.PG_DASH_LLM_API_KEY || process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.GOOGLE_API_KEY,
2637
+ baseUrl: process.env.PG_DASH_LLM_BASE_URL,
2638
+ model: process.env.PG_DASH_LLM_MODEL
2639
+ };
2640
+ }
2641
+ async function buildDatabaseContext(pool2) {
2642
+ const dbContext = await getDbContext(pool2);
2643
+ const tableInfos = dbContext.tables.slice(0, 30).map((table) => {
2644
+ const columns = table.columns.map(
2645
+ (col) => ` - ${col.name}: ${col.type}${col.isPrimaryKey ? " (PK)" : ""}${col.isForeignKey ? ` (FK -> ${col.references?.table}.${col.references?.column})` : ""}`
2646
+ ).join("\n");
2647
+ return `### ${table.schema}.${table.name} (${table.rowCount || "?"} rows, ${table.totalSize || "?"})
2648
+ ${columns}`;
2649
+ }).join("\n\n");
2650
+ return `Database Schema (top tables by size):
2651
+ ${tableInfos}
2652
+
2653
+ Generate a PostgreSQL SELECT query to answer the user's question.
2654
+ Rules:
2655
+ 1. Only generate SELECT queries - no INSERT, UPDATE, DELETE, or DDL
2656
+ 2. Use proper JOINs if needed
2657
+ 3. Use LIMIT to cap results at 100 rows unless user specifies otherwise
2658
+ 4. Use table aliases for clarity
2659
+ 5. For time-based queries, use NOW() - INTERVAL syntax
2660
+ 6. Use pg_ prefix system tables only if necessary
2661
+
2662
+ Return ONLY the SQL query, no explanations.`;
2663
+ }
2664
+ async function callLLM(config, systemPrompt, userPrompt) {
2665
+ const { provider, apiKey, baseUrl, model } = config;
2666
+ if (!apiKey) {
2667
+ throw new Error(`API key not configured. Set PG_DASH_LLM_API_KEY (or OPENAI_API_KEY/ANTHROPIC_API_KEY/GOOGLE_API_KEY)`);
2668
+ }
2669
+ const headers = {
2670
+ "Content-Type": "application/json"
2671
+ };
2672
+ let url;
2673
+ let body;
2674
+ switch (provider) {
2675
+ case "openai":
2676
+ url = (baseUrl || "https://api.openai.com/v1") + "/chat/completions";
2677
+ headers["Authorization"] = `Bearer ${apiKey}`;
2678
+ body = {
2679
+ model: model || "gpt-4o-mini",
2680
+ messages: [
2681
+ { role: "system", content: systemPrompt },
2682
+ { role: "user", content: userPrompt }
2683
+ ],
2684
+ temperature: 0
2685
+ };
2686
+ break;
2687
+ case "anthropic":
2688
+ url = (baseUrl || "https://api.anthropic.com/v1") + "/messages";
2689
+ headers["x-api-key"] = apiKey;
2690
+ headers["anthropic-version"] = "2023-06-01";
2691
+ body = {
2692
+ model: model || "claude-3-haiku-20240307",
2693
+ system: systemPrompt,
2694
+ messages: [{ role: "user", content: userPrompt }],
2695
+ max_tokens: 1024
2696
+ };
2697
+ break;
2698
+ case "google":
2699
+ url = (baseUrl || "https://generativelanguage.googleapis.com/v1beta") + `/models/${model || "gemini-2.0-flash-exp"}:generateContent?key=${apiKey}`;
2700
+ body = {
2701
+ contents: [{ parts: [{ text: `System: ${systemPrompt}
2702
+
2703
+ User: ${userPrompt}` }] }],
2704
+ generationConfig: { temperature: 0 }
2705
+ };
2706
+ break;
2707
+ case "ollama":
2708
+ url = (baseUrl || "http://localhost:11434") + "/api/generate";
2709
+ body = {
2710
+ model: model || "llama3.2",
2711
+ prompt: `System: ${systemPrompt}
2712
+
2713
+ User: ${userPrompt}`,
2714
+ stream: false
2715
+ };
2716
+ break;
2717
+ default:
2718
+ throw new Error(`Unknown LLM provider: ${provider}`);
2719
+ }
2720
+ const response = await fetch(url, {
2721
+ method: "POST",
2722
+ headers,
2723
+ body: JSON.stringify(body)
2724
+ });
2725
+ if (!response.ok) {
2726
+ const errorText = await response.text();
2727
+ throw new Error(`LLM API error (${response.status}): ${errorText}`);
2728
+ }
2729
+ const data = await response.json();
2730
+ switch (provider) {
2731
+ case "openai":
2732
+ return data.choices?.[0]?.message?.content?.trim() || "";
2733
+ case "anthropic":
2734
+ return data.content?.[0]?.text?.trim() || "";
2735
+ case "google":
2736
+ return data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || "";
2737
+ case "ollama":
2738
+ return data.response?.trim() || "";
2739
+ default:
2740
+ return "";
2741
+ }
2742
+ }
2743
+ function validateSQL(sql) {
2744
+ const trimmed = sql.trim();
2745
+ if (!/^\s*SELECT\b/i.test(trimmed)) {
2746
+ return { valid: false, error: "Only SELECT queries are allowed" };
2747
+ }
2748
+ const dangerous = [
2749
+ /;\s*(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE)/i,
2750
+ /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE)\b/i,
2751
+ /pg_terminate_backend/i,
2752
+ /pg_cancel_backend/i,
2753
+ /\bCOPY\b/i,
2754
+ /\bEXPLAIN\b.*\b(SELECT|INSERT|UPDATE|DELETE)\b/i
2755
+ // Allow EXPLAIN but wrap it
2756
+ ];
2757
+ for (const pattern of dangerous) {
2758
+ if (pattern.test(trimmed)) {
2759
+ return { valid: false, error: `Disallowed pattern in query: ${pattern.source}` };
2760
+ }
2761
+ }
2762
+ let finalSql = trimmed;
2763
+ if (!/\bLIMIT\b/i.test(trimmed)) {
2764
+ finalSql = `${trimmed} LIMIT 100`;
2765
+ }
2766
+ return { valid: true, sql: finalSql };
2767
+ }
2768
+ async function executeNaturalQuery(pool2, naturalQuery, config) {
2769
+ const llmConfig = config || getLLMConfig();
2770
+ const contextPrompt = await buildDatabaseContext(pool2);
2771
+ const fullPrompt = `${contextPrompt}
2772
+
2773
+ User's question: ${naturalQuery}
2774
+
2775
+ Generate the SQL query now:`;
2776
+ let sql;
2777
+ try {
2778
+ sql = await callLLM(
2779
+ llmConfig,
2780
+ "You are a PostgreSQL expert. Generate only SELECT queries based on the schema provided.",
2781
+ fullPrompt
2782
+ );
2783
+ } catch (err) {
2784
+ return {
2785
+ answer: "",
2786
+ sql: "",
2787
+ error: `LLM call failed: ${err.message}`
2788
+ };
2789
+ }
2790
+ const sqlMatch = sql.match(/```sql\n?([\s\S]*?)```/) || sql.match(/```\n?([\s\S]*?)```/) || [null, sql];
2791
+ let extractedSql = sqlMatch[1]?.trim() || sql.trim();
2792
+ const validation = validateSQL(extractedSql);
2793
+ if (!validation.valid) {
2794
+ return {
2795
+ answer: "",
2796
+ sql: extractedSql,
2797
+ error: `SQL validation failed: ${validation.error}`
2798
+ };
2799
+ }
2800
+ extractedSql = validation.sql;
2801
+ let result;
2802
+ const client = await pool2.connect();
2803
+ try {
2804
+ const queryResult = await client.query(extractedSql);
2805
+ result = {
2806
+ rows: queryResult.rows,
2807
+ rowCount: queryResult.rowCount || 0,
2808
+ columns: queryResult.fields?.map((f) => f.name) || []
2809
+ };
2810
+ } catch (err) {
2811
+ return {
2812
+ answer: "",
2813
+ sql: extractedSql,
2814
+ error: `SQL execution failed: ${err.message}`
2815
+ };
2816
+ } finally {
2817
+ client.release();
2818
+ }
2819
+ let answer = "";
2820
+ if (result.rows.length === 0) {
2821
+ answer = "No results found for your query.";
2822
+ } else if (result.rows.length === 1) {
2823
+ answer = `Found 1 result: ${JSON.stringify(result.rows[0])}`;
2824
+ } else {
2825
+ answer = `Found ${result.rowCount} results. Showing first ${Math.min(result.rows.length, 10)}:`;
2826
+ answer += "\n\n" + JSON.stringify(result.rows.slice(0, 10), null, 2);
2827
+ if (result.rows.length > 10) {
2828
+ answer += `
2829
+
2830
+ ... and ${result.rows.length - 10} more rows (limited to 100)`;
2831
+ }
2832
+ }
2833
+ return {
2834
+ answer,
2835
+ sql: extractedSql,
2836
+ result
2837
+ };
2838
+ }
2839
+ async function generateAISuggestions(report, config) {
2840
+ const llmConfig = config || getLLMConfig();
2841
+ if (!llmConfig.apiKey) {
2842
+ return {
2843
+ summary: `Health Score: ${report.score}/100 (${report.grade}). ${report.issues.length} issues found.`,
2844
+ suggestions: report.issues.map((issue) => ({
2845
+ issue: issue.title,
2846
+ suggestion: issue.description,
2847
+ priority: issue.severity
2848
+ }))
2849
+ };
2850
+ }
2851
+ const issuesText = report.issues.map(
2852
+ (i) => `- [${i.severity}] ${i.title}: ${i.description}`
2853
+ ).join("\n");
2854
+ const prompt = `You are a PostgreSQL database expert. Analyze this health check report and provide:
2855
+ 1. A one-sentence summary of the overall database health status
2856
+ 2. Prioritized fix suggestions for each issue (most critical first)
2857
+
2858
+ Health Report:
2859
+ - Score: ${report.score}/100 (Grade: ${report.grade})
2860
+ - Issues: ${report.issues.length}
2861
+
2862
+ Issues:
2863
+ ${issuesText}
2864
+
2865
+ Return a JSON object with this exact structure:
2866
+ {
2867
+ "summary": "one sentence summary",
2868
+ "suggestions": [
2869
+ { "issue": "issue title", "suggestion": "what to do", "priority": "critical|warning|info" }
2870
+ ]
2871
+ }
2872
+
2873
+ Only include issues that have actionable suggestions. Prioritize by severity (critical > warning > info).`;
2874
+ try {
2875
+ const response = await callLLM(
2876
+ llmConfig,
2877
+ "You are a PostgreSQL expert. Return only valid JSON.",
2878
+ prompt
2879
+ );
2880
+ const jsonMatch = response.match(/\{[\s\S]*\}/);
2881
+ if (jsonMatch) {
2882
+ const parsed = JSON.parse(jsonMatch[0]);
2883
+ return {
2884
+ summary: parsed.summary || `Health Score: ${report.score}/100 (${report.grade})`,
2885
+ suggestions: parsed.suggestions || []
2886
+ };
2887
+ }
2888
+ } catch (err) {
2889
+ console.error("[llm] AI suggestions error:", err);
2890
+ }
2891
+ return {
2892
+ summary: `Health Score: ${report.score}/100 (${report.grade}). ${report.issues.length} issues found.`,
2893
+ suggestions: report.issues.map((issue) => ({
2894
+ issue: issue.title,
2895
+ suggestion: issue.description,
2896
+ priority: issue.severity
2897
+ }))
2898
+ };
2899
+ }
2900
+
2393
2901
  // src/mcp.ts
2394
2902
  import Database2 from "better-sqlite3";
2395
2903
  import path3 from "path";
@@ -2781,6 +3289,190 @@ server.tool("pg_dash_config_check", "Audit PostgreSQL configuration settings and
2781
3289
  return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
2782
3290
  }
2783
3291
  });
3292
+ server.tool(
3293
+ "fetch_db_context",
3294
+ "Get comprehensive database context for AI agents: table structures, columns, types, primary/foreign keys, indexes, business intent inference, and health summary",
3295
+ {},
3296
+ async () => {
3297
+ try {
3298
+ const dbContext = await getDbContext(pool);
3299
+ const healthReport = await getAdvisorReport(pool, longQueryThreshold);
3300
+ const result = {
3301
+ // Database overview
3302
+ database: dbContext.database,
3303
+ // Tables with full structure
3304
+ tables: dbContext.tables.map((table) => ({
3305
+ schema: table.schema,
3306
+ name: table.name,
3307
+ description: table.description,
3308
+ rowCount: table.rowCount,
3309
+ totalSize: table.totalSize,
3310
+ businessIntent: table.businessIntent,
3311
+ columnCount: table.columns.length,
3312
+ columns: table.columns.map((col) => ({
3313
+ name: col.name,
3314
+ type: col.type,
3315
+ nullable: col.nullable,
3316
+ isPrimaryKey: col.isPrimaryKey,
3317
+ isForeignKey: col.isForeignKey,
3318
+ references: col.isForeignKey ? {
3319
+ table: col.referencedTable,
3320
+ column: col.referencedColumn
3321
+ } : null
3322
+ })),
3323
+ primaryKeys: table.primaryKeys,
3324
+ foreignKeys: table.foreignKeys,
3325
+ indexCount: table.indexes.length
3326
+ })),
3327
+ // Index summary per table
3328
+ indexSummary: dbContext.indexSummary,
3329
+ // Health summary
3330
+ health: {
3331
+ score: healthReport.score,
3332
+ grade: healthReport.grade,
3333
+ categoryScores: Object.fromEntries(
3334
+ Object.entries(healthReport.breakdown).map(([cat, data]) => [cat, { grade: data.grade, score: data.score }])
3335
+ ),
3336
+ issueCount: healthReport.issues.length,
3337
+ criticalIssues: healthReport.issues.filter((i) => i.severity === "critical").length,
3338
+ warningIssues: healthReport.issues.filter((i) => i.severity === "warning").length,
3339
+ topIssues: healthReport.issues.slice(0, 5).map((i) => ({
3340
+ severity: i.severity,
3341
+ title: i.title,
3342
+ category: i.category
3343
+ }))
3344
+ },
3345
+ // Metadata
3346
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3347
+ version: pkg.version
3348
+ };
3349
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
3350
+ } catch (err) {
3351
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
3352
+ }
3353
+ }
3354
+ );
3355
+ server.tool(
3356
+ "pg_dash_query_natural",
3357
+ "Query database using natural language. The LLM converts your question to SQL and returns results. Example: 'show me slow queries last hour', 'find missing indexes', 'what's the health score', 'list all tables with their sizes'",
3358
+ {
3359
+ query: z.string().describe("Natural language query (e.g., 'show me the top 10 largest tables')"),
3360
+ includeSql: z.boolean().optional().default(true).describe("Include the generated SQL in the response")
3361
+ },
3362
+ async ({ query, includeSql }) => {
3363
+ const llmConfig = getLLMConfig();
3364
+ if (!llmConfig.apiKey) {
3365
+ return {
3366
+ content: [{
3367
+ type: "text",
3368
+ text: JSON.stringify({
3369
+ error: "LLM not configured",
3370
+ message: "Please set PG_DASH_LLM_API_KEY environment variable (or OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY)",
3371
+ usage: {
3372
+ openai: "export PG_DASH_LLM_PROVIDER=openai && export PG_DASH_LLM_API_KEY=sk-...",
3373
+ anthropic: "export PG_DASH_LLM_PROVIDER=anthropic && export ANTHROPIC_API_KEY=sk-ant-...",
3374
+ google: "export PG_DASH_LLM_PROVIDER=google && export GOOGLE_API_KEY=...",
3375
+ ollama: "export PG_DASH_LLM_PROVIDER=ollama && export PG_DASH_LLM_BASE_URL=http://localhost:11434"
3376
+ },
3377
+ provider: llmConfig.provider,
3378
+ model: llmConfig.model || "default"
3379
+ }, null, 2)
3380
+ }],
3381
+ isError: true
3382
+ };
3383
+ }
3384
+ try {
3385
+ const result = await executeNaturalQuery(pool, query, llmConfig);
3386
+ if (result.error) {
3387
+ return {
3388
+ content: [{
3389
+ type: "text",
3390
+ text: JSON.stringify({
3391
+ error: result.error,
3392
+ generatedSql: includeSql ? result.sql : void 0,
3393
+ message: "Failed to execute natural language query"
3394
+ }, null, 2)
3395
+ }],
3396
+ isError: true
3397
+ };
3398
+ }
3399
+ return {
3400
+ content: [{
3401
+ type: "text",
3402
+ text: JSON.stringify({
3403
+ answer: result.answer,
3404
+ generatedSql: includeSql ? result.sql : void 0,
3405
+ rowCount: result.result?.rowCount,
3406
+ columns: result.result?.columns,
3407
+ data: result.result?.rows
3408
+ }, null, 2)
3409
+ }]
3410
+ };
3411
+ } catch (err) {
3412
+ return {
3413
+ content: [{ type: "text", text: `Error: ${err.message}` }],
3414
+ isError: true
3415
+ };
3416
+ }
3417
+ }
3418
+ );
3419
+ server.tool(
3420
+ "ci_health_summary",
3421
+ "Generate a CI-friendly health summary with AI-powered prioritization. Input: health check result (from pg_dash_health). Output: one-sentence summary + prioritized issue list. Perfect for GitHub Actions/GitLab CI integration.",
3422
+ {
3423
+ healthReport: z.string().describe("JSON string of health report from pg_dash_health tool")
3424
+ },
3425
+ async ({ healthReport }) => {
3426
+ try {
3427
+ const report = JSON.parse(healthReport);
3428
+ if (!report.score || !report.issues) {
3429
+ return {
3430
+ content: [{
3431
+ type: "text",
3432
+ text: JSON.stringify({
3433
+ error: "Invalid health report format",
3434
+ message: "Provide the JSON output from pg_dash_health tool"
3435
+ }, null, 2)
3436
+ }],
3437
+ isError: true
3438
+ };
3439
+ }
3440
+ const llmConfig = getLLMConfig();
3441
+ const aiResult = await generateAISuggestions(report, llmConfig);
3442
+ const summary = {
3443
+ // One-sentence summary
3444
+ summary: aiResult.summary,
3445
+ // Health score
3446
+ score: report.score,
3447
+ grade: report.grade,
3448
+ // Prioritized issues (already sorted by severity)
3449
+ prioritizedIssues: aiResult.suggestions.map((s, idx) => ({
3450
+ priority: idx + 1,
3451
+ severity: s.priority,
3452
+ issue: s.issue,
3453
+ suggestion: s.suggestion
3454
+ })),
3455
+ // Metadata
3456
+ totalIssues: report.issues.length,
3457
+ criticalCount: report.issues.filter((i) => i.severity === "critical").length,
3458
+ warningCount: report.issues.filter((i) => i.severity === "warning").length,
3459
+ // For CI exit code guidance
3460
+ wouldFailCheck: report.score < 70
3461
+ };
3462
+ return {
3463
+ content: [{
3464
+ type: "text",
3465
+ text: JSON.stringify(summary, null, 2)
3466
+ }]
3467
+ };
3468
+ } catch (err) {
3469
+ return {
3470
+ content: [{ type: "text", text: `Error: ${err.message}` }],
3471
+ isError: true
3472
+ };
3473
+ }
3474
+ }
3475
+ );
2784
3476
  var transport = new StdioServerTransport();
2785
3477
  await server.connect(transport);
2786
3478
  //# sourceMappingURL=mcp.js.map