mcp-db-analyzer 0.2.5 → 0.2.7
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 +13 -0
- package/build/analyzers/connections.js +3 -3
- package/build/analyzers/query.js +16 -9
- package/build/analyzers/schema.js +8 -5
- package/build/analyzers/slow-queries.js +14 -0
- package/build/db-mysql.js +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,6 +21,19 @@ Other analytical MCP servers (CrystalDBA, pg-dash, MCP-PostgreSQL-Ops) cover Pos
|
|
|
21
21
|
- **Markdown output** optimized for LLM consumption
|
|
22
22
|
- **Zero configuration** — just set `DATABASE_URL`
|
|
23
23
|
|
|
24
|
+
## Pro Tier
|
|
25
|
+
|
|
26
|
+
**Generate exportable diagnostic reports (HTML + PDF)** with a Pro license key.
|
|
27
|
+
|
|
28
|
+
- Full JVM thread dump analysis report with actionable recommendations
|
|
29
|
+
- PDF export for sharing with your team
|
|
30
|
+
- Priority support
|
|
31
|
+
|
|
32
|
+
<!-- TODO: replace placeholder Stripe Payment Link once STRIPE_SECRET_KEY is configured -->
|
|
33
|
+
**$9.99/month** — [Get Pro License](https://buy.stripe.com/PLACEHOLDER)
|
|
34
|
+
|
|
35
|
+
Pro license key activates the `generate_report` MCP tool in mcp-jvm-diagnostics.
|
|
36
|
+
|
|
24
37
|
## Installation
|
|
25
38
|
|
|
26
39
|
```bash
|
|
@@ -63,7 +63,7 @@ async function analyzePostgresConnections() {
|
|
|
63
63
|
lines.push("| PID | User | Duration | Query |");
|
|
64
64
|
lines.push("|-----|------|----------|-------|");
|
|
65
65
|
for (const row of idleTxn.rows) {
|
|
66
|
-
lines.push(`| ${row.pid} | ${row.usename} | ${row.duration} | ${row.query} |`);
|
|
66
|
+
lines.push(`| ${row.pid} | ${row.usename} | ${row.duration} | ${row.query.replace(/\|/g, "\\|")} |`);
|
|
67
67
|
}
|
|
68
68
|
lines.push("");
|
|
69
69
|
}
|
|
@@ -83,7 +83,7 @@ async function analyzePostgresConnections() {
|
|
|
83
83
|
lines.push("| PID | User | Duration | Wait | Query |");
|
|
84
84
|
lines.push("|-----|------|----------|------|-------|");
|
|
85
85
|
for (const row of longQueries.rows) {
|
|
86
|
-
lines.push(`| ${row.pid} | ${row.usename} | ${row.duration} | ${row.wait_event_type || "-"} | ${row.query} |`);
|
|
86
|
+
lines.push(`| ${row.pid} | ${row.usename} | ${row.duration} | ${row.wait_event_type || "-"} | ${row.query.replace(/\|/g, "\\|")} |`);
|
|
87
87
|
}
|
|
88
88
|
lines.push("");
|
|
89
89
|
}
|
|
@@ -112,7 +112,7 @@ async function analyzePostgresConnections() {
|
|
|
112
112
|
lines.push("| Blocked PID | Blocking PID | Blocked Query | Blocking Query |");
|
|
113
113
|
lines.push("|-------------|--------------|---------------|----------------|");
|
|
114
114
|
for (const row of blocked.rows) {
|
|
115
|
-
lines.push(`| ${row.blocked_pid} | ${row.blocking_pid} | ${row.blocked_query} | ${row.blocking_query} |`);
|
|
115
|
+
lines.push(`| ${row.blocked_pid} | ${row.blocking_pid} | ${row.blocked_query.replace(/\|/g, "\\|")} | ${row.blocking_query.replace(/\|/g, "\\|")} |`);
|
|
116
116
|
}
|
|
117
117
|
lines.push("");
|
|
118
118
|
}
|
package/build/analyzers/query.js
CHANGED
|
@@ -10,7 +10,9 @@ export async function explainQuery(sql, analyze = false) {
|
|
|
10
10
|
// EXPLAIN ANALYZE actually executes the query, so we must reject anything
|
|
11
11
|
// that could modify data — including CTEs with write operations.
|
|
12
12
|
if (analyze) {
|
|
13
|
-
|
|
13
|
+
// Strip single-quoted string literals before scanning for DML keywords so that
|
|
14
|
+
// a query like `SELECT ... WHERE status = 'DELETE me'` is not falsely rejected.
|
|
15
|
+
const upperSql = sql.trim().toUpperCase().replace(/'[^']*'/g, "''");
|
|
14
16
|
const DML_KEYWORDS = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE", "CREATE", "GRANT", "REVOKE", "COPY"];
|
|
15
17
|
const containsDml = DML_KEYWORDS.some((kw) => upperSql.includes(kw + " ") || upperSql.includes(kw + "\n") || upperSql.includes(kw + "\t") || upperSql.endsWith(kw));
|
|
16
18
|
if (containsDml) {
|
|
@@ -235,14 +237,19 @@ function collectWarnings(node) {
|
|
|
235
237
|
if (node["Sort Method"] === "external merge") {
|
|
236
238
|
warnings.push(`**Disk sort** detected. Increase \`work_mem\` or add an index to avoid sorting.`);
|
|
237
239
|
}
|
|
238
|
-
// Stale statistics: actual rows deviate significantly from planner estimate
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
if (
|
|
244
|
-
|
|
245
|
-
|
|
240
|
+
// Stale statistics: actual rows deviate significantly from planner estimate.
|
|
241
|
+
// Handle Plan Rows = 0 separately — the planner expected an empty result but
|
|
242
|
+
// got rows, which is the most misleading case for query planning.
|
|
243
|
+
if (node["Actual Rows"] !== undefined) {
|
|
244
|
+
const relation = node["Relation Name"] ?? node["Node Type"];
|
|
245
|
+
if (node["Plan Rows"] === 0 && node["Actual Rows"] > 0) {
|
|
246
|
+
warnings.push(`**Stale statistics** on \`${relation}\`: planner estimated 0 rows but got ${node["Actual Rows"]}. Run \`ANALYZE ${node["Relation Name"] ?? ""}\` to refresh table statistics.`);
|
|
247
|
+
}
|
|
248
|
+
else if (node["Plan Rows"] > 0 && node["Actual Rows"] > 0) {
|
|
249
|
+
const ratio = node["Actual Rows"] / node["Plan Rows"];
|
|
250
|
+
if (ratio > 10 || ratio < 0.1) {
|
|
251
|
+
warnings.push(`**Stale statistics** on \`${relation}\`: planner estimated ${node["Plan Rows"]} rows but got ${node["Actual Rows"]} (${ratio > 1 ? ratio.toFixed(0) + "× over" : (1 / ratio).toFixed(0) + "× under"}estimate). Run \`ANALYZE ${node["Relation Name"] ?? ""}\` to refresh table statistics.`);
|
|
252
|
+
}
|
|
246
253
|
}
|
|
247
254
|
}
|
|
248
255
|
if (node.Plans) {
|
|
@@ -187,13 +187,16 @@ export async function inspectTable(tableName, schema = "public") {
|
|
|
187
187
|
return lines.join("\n");
|
|
188
188
|
}
|
|
189
189
|
async function inspectTableSqlite(tableName) {
|
|
190
|
-
|
|
190
|
+
// Escape double-quote characters so a table name containing `"` (e.g. `weird"table`)
|
|
191
|
+
// does not break the PRAGMA queries or the row-count SELECT.
|
|
192
|
+
const escaped = tableName.replace(/"/g, '""');
|
|
193
|
+
const cols = await query(`PRAGMA table_info("${escaped}")`);
|
|
191
194
|
if (cols.rows.length === 0) {
|
|
192
195
|
return `Table '${tableName}' not found.`;
|
|
193
196
|
}
|
|
194
197
|
const lines = [`## Table: main.${tableName}\n`];
|
|
195
198
|
// Row count
|
|
196
|
-
const countResult = await query(`SELECT count(*) as cnt FROM "${
|
|
199
|
+
const countResult = await query(`SELECT count(*) as cnt FROM "${escaped}"`);
|
|
197
200
|
lines.push(`- **Rows**: ${countResult.rows[0]?.cnt ?? 0}`);
|
|
198
201
|
lines.push("");
|
|
199
202
|
lines.push("### Columns\n");
|
|
@@ -203,7 +206,7 @@ async function inspectTableSqlite(tableName) {
|
|
|
203
206
|
lines.push(`| ${col.cid + 1} | ${col.name} | ${col.type || 'ANY'} | ${col.notnull ? 'NO' : 'YES'} | ${col.dflt_value ?? '-'} | ${col.pk ? 'YES' : '-'} |`);
|
|
204
207
|
}
|
|
205
208
|
// Foreign keys
|
|
206
|
-
const fks = await query(`PRAGMA foreign_key_list("${
|
|
209
|
+
const fks = await query(`PRAGMA foreign_key_list("${escaped}")`);
|
|
207
210
|
if (fks.rows.length > 0) {
|
|
208
211
|
lines.push("\n### Foreign Keys\n");
|
|
209
212
|
lines.push("| Column | References |");
|
|
@@ -213,13 +216,13 @@ async function inspectTableSqlite(tableName) {
|
|
|
213
216
|
}
|
|
214
217
|
}
|
|
215
218
|
// Indexes
|
|
216
|
-
const indexes = await query(`PRAGMA index_list("${
|
|
219
|
+
const indexes = await query(`PRAGMA index_list("${escaped}")`);
|
|
217
220
|
if (indexes.rows.length > 0) {
|
|
218
221
|
lines.push("\n### Indexes\n");
|
|
219
222
|
lines.push("| Name | Unique | Columns |");
|
|
220
223
|
lines.push("|------|--------|---------|");
|
|
221
224
|
for (const idx of indexes.rows) {
|
|
222
|
-
const idxCols = await query(`PRAGMA index_info("${idx.name}")`);
|
|
225
|
+
const idxCols = await query(`PRAGMA index_info("${idx.name.replace(/"/g, '""')}")`);
|
|
223
226
|
const colNames = idxCols.rows.map(c => c.name).join(", ");
|
|
224
227
|
lines.push(`| ${idx.name} | ${idx.unique ? 'YES' : 'NO'} | ${colNames} |`);
|
|
225
228
|
}
|
|
@@ -119,6 +119,20 @@ async function analyzeMysqlSlowQueries(limit) {
|
|
|
119
119
|
const truncated = r.DIGEST_TEXT.length > 80 ? r.DIGEST_TEXT.slice(0, 77) + "..." : r.DIGEST_TEXT;
|
|
120
120
|
sections.push(`| ${i + 1} | ${r.AVG_TIMER_WAIT.toFixed(1)}ms | ${r.SUM_TIMER_WAIT.toFixed(0)}ms | ${r.COUNT_STAR} | \`${truncated.replace(/\|/g, "\\|")}\` |`);
|
|
121
121
|
}
|
|
122
|
+
// Recommendations
|
|
123
|
+
sections.push("");
|
|
124
|
+
sections.push("### Recommendations");
|
|
125
|
+
const highCallSlow = result.rows.filter((r) => r.COUNT_STAR > 100 && r.AVG_TIMER_WAIT > 100);
|
|
126
|
+
if (highCallSlow.length > 0) {
|
|
127
|
+
sections.push(`- **${highCallSlow.length} high-impact queries** — called >100 times with >100ms avg. Prioritize these for optimization.`);
|
|
128
|
+
}
|
|
129
|
+
const fewRowsSlow = result.rows.filter((r) => r.AVG_TIMER_WAIT > 50 && r.SUM_ROWS_SENT / Math.max(r.COUNT_STAR, 1) < 10);
|
|
130
|
+
if (fewRowsSlow.length > 0) {
|
|
131
|
+
sections.push(`- **${fewRowsSlow.length} queries returning few rows but slow** — likely missing indexes. Use \`explain_query\` to check.`);
|
|
132
|
+
}
|
|
133
|
+
if (highCallSlow.length === 0 && fewRowsSlow.length === 0) {
|
|
134
|
+
sections.push("- No critical patterns detected. Monitor trends over time.");
|
|
135
|
+
}
|
|
122
136
|
return sections.join("\n");
|
|
123
137
|
}
|
|
124
138
|
catch {
|
package/build/db-mysql.js
CHANGED
|
@@ -49,7 +49,13 @@ export function createMysqlAdapter() {
|
|
|
49
49
|
return { rows: rows };
|
|
50
50
|
}
|
|
51
51
|
catch (err) {
|
|
52
|
-
|
|
52
|
+
try {
|
|
53
|
+
await conn.rollback();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Transaction may not have started (e.g. beginTransaction threw);
|
|
57
|
+
// suppress the secondary error so the original cause is preserved.
|
|
58
|
+
}
|
|
53
59
|
throw err;
|
|
54
60
|
}
|
|
55
61
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-db-analyzer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "MCP server for PostgreSQL, MySQL, and SQLite schema analysis, index optimization, and query plan inspection",
|
|
5
5
|
"mcpName": "io.github.dmitriusan/mcp-db-analyzer",
|
|
6
6
|
"author": "Dmytro Lisnichenko",
|