@indiekitai/pg-dash 0.5.2 → 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/README.md +25 -3
- package/README.zh-CN.md +10 -3
- package/dist/cli.js +787 -1
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +692 -0
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
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
|