mcp-db-analyzer 0.2.9 → 0.2.11
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
CHANGED
|
@@ -30,7 +30,7 @@ Other analytical MCP servers (CrystalDBA, pg-dash, MCP-PostgreSQL-Ops) cover Pos
|
|
|
30
30
|
- Priority support
|
|
31
31
|
|
|
32
32
|
<!-- TODO: replace placeholder Stripe Payment Link once STRIPE_SECRET_KEY is configured -->
|
|
33
|
-
**$9.
|
|
33
|
+
**$9.00/month** — [Get Pro License](https://buy.stripe.com/PLACEHOLDER)
|
|
34
34
|
|
|
35
35
|
Pro license key activates the `generate_report` MCP tool in mcp-jvm-diagnostics.
|
|
36
36
|
|
|
@@ -162,6 +162,23 @@ async function analyzeMysqlConnections() {
|
|
|
162
162
|
}
|
|
163
163
|
lines.push(`| **Total** | **${total}** |`);
|
|
164
164
|
lines.push("");
|
|
165
|
+
// 2. Max connections utilization
|
|
166
|
+
try {
|
|
167
|
+
const maxConn = await query(`SELECT @@max_connections AS max_connections`);
|
|
168
|
+
if (maxConn.rows.length > 0) {
|
|
169
|
+
const max = parseInt(maxConn.rows[0].max_connections, 10);
|
|
170
|
+
const utilization = total / max;
|
|
171
|
+
lines.push(`**Max connections**: ${max}`);
|
|
172
|
+
lines.push(`**Utilization**: ${(utilization * 100).toFixed(1)}%`);
|
|
173
|
+
if (utilization > 0.8) {
|
|
174
|
+
lines.push(`\n**WARNING**: Connection pool is ${(utilization * 100).toFixed(0)}% utilized. Consider increasing max_connections or using a connection pooler (e.g. ProxySQL).`);
|
|
175
|
+
}
|
|
176
|
+
lines.push("");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// Supplemental info — skip silently if unavailable
|
|
181
|
+
}
|
|
165
182
|
const longQueries = await query(`SELECT ID AS id, USER AS user, TIME AS time, STATE AS state, LEFT(INFO, 100) AS info
|
|
166
183
|
FROM information_schema.PROCESSLIST
|
|
167
184
|
WHERE COMMAND = 'Query' AND TIME > 30
|
|
@@ -72,8 +72,9 @@ async function analyzeIndexUsageMysql(schema) {
|
|
|
72
72
|
`, [schema]);
|
|
73
73
|
return formatIndexUsage(result.rows, schema);
|
|
74
74
|
}
|
|
75
|
-
catch {
|
|
76
|
-
|
|
75
|
+
catch (err) {
|
|
76
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
77
|
+
return `## Index Usage Analysis — schema '${schema}'\n\nUnable to query performance_schema. Ensure performance_schema is enabled (it is ON by default in MySQL 5.7+) and the user has SELECT privilege on performance_schema tables.\n\nDetails: ${detail}`;
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
function formatIndexUsage(rows, schema) {
|
|
@@ -226,8 +227,9 @@ async function findMissingIndexesMysql(schema) {
|
|
|
226
227
|
}
|
|
227
228
|
return lines.join("\n");
|
|
228
229
|
}
|
|
229
|
-
catch {
|
|
230
|
-
|
|
230
|
+
catch (err) {
|
|
231
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
232
|
+
return `## Missing Index Analysis\n\nUnable to query performance_schema. Ensure performance_schema is enabled (it is ON by default in MySQL 5.7+) and the user has SELECT privilege on performance_schema tables.\n\nDetails: ${detail}`;
|
|
231
233
|
}
|
|
232
234
|
}
|
|
233
235
|
function formatMissingIndexes(rows, schema) {
|
|
@@ -99,6 +99,59 @@ async function analyzeSqliteRelationships() {
|
|
|
99
99
|
}
|
|
100
100
|
return formatRelationshipReport(tablesResult.rows.map(r => r.name), fks);
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Detect cycles in the FK dependency graph using iterative DFS.
|
|
104
|
+
* Returns each unique cycle as an ordered list of table names with the
|
|
105
|
+
* first node repeated at the end to show closure (e.g. ["a","b","c","a"]).
|
|
106
|
+
*/
|
|
107
|
+
function findFkCycles(allTables, graph) {
|
|
108
|
+
const visited = new Set();
|
|
109
|
+
const onStack = new Set();
|
|
110
|
+
const stackPath = [];
|
|
111
|
+
const cycles = [];
|
|
112
|
+
const seenCycleKeys = new Set();
|
|
113
|
+
function dfs(node) {
|
|
114
|
+
visited.add(node);
|
|
115
|
+
onStack.add(node);
|
|
116
|
+
stackPath.push(node);
|
|
117
|
+
const nodeData = graph.get(node);
|
|
118
|
+
if (nodeData) {
|
|
119
|
+
for (const fk of nodeData.outgoing) {
|
|
120
|
+
const neighbor = fk.target_table;
|
|
121
|
+
if (onStack.has(neighbor)) {
|
|
122
|
+
// Back edge found — extract the cycle
|
|
123
|
+
const cycleStartIdx = stackPath.indexOf(neighbor);
|
|
124
|
+
if (cycleStartIdx !== -1) {
|
|
125
|
+
const cycleNodes = stackPath.slice(cycleStartIdx);
|
|
126
|
+
// Normalize: rotate so lexicographically smallest node is first
|
|
127
|
+
const minNode = cycleNodes.reduce((a, b) => (a < b ? a : b));
|
|
128
|
+
const minIdx = cycleNodes.indexOf(minNode);
|
|
129
|
+
const normalized = [
|
|
130
|
+
...cycleNodes.slice(minIdx),
|
|
131
|
+
...cycleNodes.slice(0, minIdx),
|
|
132
|
+
];
|
|
133
|
+
const key = normalized.join("\0");
|
|
134
|
+
if (!seenCycleKeys.has(key)) {
|
|
135
|
+
seenCycleKeys.add(key);
|
|
136
|
+
cycles.push([...normalized, normalized[0]]); // close the cycle
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else if (!visited.has(neighbor)) {
|
|
141
|
+
dfs(neighbor);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
stackPath.pop();
|
|
146
|
+
onStack.delete(node);
|
|
147
|
+
}
|
|
148
|
+
for (const table of allTables) {
|
|
149
|
+
if (!visited.has(table)) {
|
|
150
|
+
dfs(table);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return cycles;
|
|
154
|
+
}
|
|
102
155
|
function formatRelationshipReport(allTables, foreignKeys) {
|
|
103
156
|
const lines = [];
|
|
104
157
|
lines.push("## Table Relationships\n");
|
|
@@ -161,30 +214,57 @@ function formatRelationshipReport(allTables, foreignKeys) {
|
|
|
161
214
|
lines.push("*Orphan tables may be lookup tables, denormalized tables, or tables missing FK constraints.*");
|
|
162
215
|
lines.push("");
|
|
163
216
|
}
|
|
217
|
+
// Circular FK dependencies
|
|
218
|
+
const cycles = findFkCycles(allTables, graph);
|
|
219
|
+
if (cycles.length > 0) {
|
|
220
|
+
lines.push("### Circular FK Dependencies\n");
|
|
221
|
+
lines.push("These tables form circular foreign key references. Cycles complicate cascade operations, " +
|
|
222
|
+
"schema migrations, and may cause issues with certain ORMs and migration tools:\n");
|
|
223
|
+
for (const cycle of cycles) {
|
|
224
|
+
lines.push(`- ${cycle.join(" → ")}`);
|
|
225
|
+
}
|
|
226
|
+
lines.push("\n**Note**: Circular FKs are sometimes intentional (e.g., a 'current_record' pointer back to a parent). " +
|
|
227
|
+
"Review each cycle to confirm the design is correct.\n");
|
|
228
|
+
}
|
|
164
229
|
// Cascading delete chains
|
|
165
230
|
const cascadeDeletes = foreignKeys.filter(fk => fk.on_delete === "CASCADE");
|
|
166
231
|
if (cascadeDeletes.length > 0) {
|
|
167
232
|
lines.push("### Cascading Delete Chains\n");
|
|
168
233
|
lines.push("Deleting a row from these parent tables will cascade-delete rows in child tables:\n");
|
|
169
|
-
//
|
|
170
|
-
const
|
|
234
|
+
// Build parent→children map for cascade edges only
|
|
235
|
+
const cascadeChildren = new Map();
|
|
171
236
|
for (const fk of cascadeDeletes) {
|
|
172
|
-
const existing =
|
|
173
|
-
existing.
|
|
174
|
-
|
|
237
|
+
const existing = cascadeChildren.get(fk.target_table) || [];
|
|
238
|
+
if (!existing.includes(fk.source_table))
|
|
239
|
+
existing.push(fk.source_table);
|
|
240
|
+
cascadeChildren.set(fk.target_table, existing);
|
|
175
241
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
242
|
+
// Identify root cascade tables: have cascade children but are not cascade children themselves
|
|
243
|
+
const allCascadeChildTables = new Set(cascadeDeletes.map(fk => fk.source_table));
|
|
244
|
+
const cascadeRoots = [...cascadeChildren.keys()].filter(t => !allCascadeChildTables.has(t));
|
|
245
|
+
// Fall back to all parents if every parent is also a child (full cycle)
|
|
246
|
+
const parentsToShow = cascadeRoots.length > 0 ? cascadeRoots : [...cascadeChildren.keys()];
|
|
247
|
+
// Recursively render the cascade chain under a given parent
|
|
248
|
+
function renderCascadeChain(parent, indent, visited) {
|
|
249
|
+
const children = cascadeChildren.get(parent) || [];
|
|
250
|
+
const prefix = " ".repeat(indent);
|
|
251
|
+
for (const child of children) {
|
|
252
|
+
if (visited.has(child)) {
|
|
253
|
+
lines.push(`${prefix}- **${child}** *(circular)*`);
|
|
254
|
+
}
|
|
255
|
+
else if (cascadeChildren.has(child)) {
|
|
256
|
+
lines.push(`${prefix}- **${child}** →`);
|
|
257
|
+
renderCascadeChain(child, indent + 1, new Set([...visited, child]));
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
lines.push(`${prefix}- **${child}**`);
|
|
185
261
|
}
|
|
186
262
|
}
|
|
187
263
|
}
|
|
264
|
+
for (const root of parentsToShow) {
|
|
265
|
+
lines.push(`- **${root}** → cascades to:`);
|
|
266
|
+
renderCascadeChain(root, 1, new Set([root]));
|
|
267
|
+
}
|
|
188
268
|
lines.push("");
|
|
189
269
|
lines.push("**WARNING**: Cascading deletes can cause unexpected data loss. Ensure all CASCADE rules are intentional.\n");
|
|
190
270
|
}
|
|
@@ -200,6 +280,9 @@ function formatRelationshipReport(allTables, foreignKeys) {
|
|
|
200
280
|
if (hubs.length > 0) {
|
|
201
281
|
issues.push(`Hub tables (${hubs.map(h => h.name).join(", ")}) have 5+ FK connections — changes to these tables affect many others`);
|
|
202
282
|
}
|
|
283
|
+
if (cycles.length > 0) {
|
|
284
|
+
issues.push(`${cycles.length} circular FK reference(s) detected — review for unintended schema design`);
|
|
285
|
+
}
|
|
203
286
|
if (issues.length > 0) {
|
|
204
287
|
lines.push("### Recommendations\n");
|
|
205
288
|
for (const issue of issues) {
|
|
@@ -127,8 +127,9 @@ async function suggestMissingIndexesMysql(schema) {
|
|
|
127
127
|
`, [schema]);
|
|
128
128
|
return formatSuggestions(needsIndex.rows, unused.rows, schema);
|
|
129
129
|
}
|
|
130
|
-
catch {
|
|
131
|
-
|
|
130
|
+
catch (err) {
|
|
131
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
132
|
+
return `## Index Suggestions\n\nUnable to query performance_schema. Ensure performance_schema is enabled (it is ON by default in MySQL 5.7+) and the user has SELECT privilege on performance_schema tables.\n\nDetails: ${detail}`;
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
function formatSuggestions(needsIndex, unused, schema) {
|
package/build/index.js
CHANGED
|
@@ -248,7 +248,7 @@ server.tool("analyze_slow_queries", "Find the slowest queries using pg_stat_stat
|
|
|
248
248
|
}
|
|
249
249
|
});
|
|
250
250
|
// Tool 7: analyze_connections
|
|
251
|
-
server.tool("analyze_connections", "Analyze active database connections. Detects idle-in-transaction sessions, long-running queries, lock contention, and connection pool utilization. PostgreSQL and MySQL only.", {
|
|
251
|
+
server.tool("analyze_connections", "Analyze active database connections. Detects idle-in-transaction sessions (which hold locks and block other queries), long-running queries (flagged at >30 seconds), lock contention between sessions, and connection pool utilization. PostgreSQL and MySQL only.", {
|
|
252
252
|
timeout_ms: timeoutParam,
|
|
253
253
|
}, async ({ timeout_ms }) => {
|
|
254
254
|
applyTimeout(timeout_ms);
|
|
@@ -268,7 +268,7 @@ server.tool("analyze_connections", "Analyze active database connections. Detects
|
|
|
268
268
|
}
|
|
269
269
|
});
|
|
270
270
|
// Tool 8: analyze_table_relationships
|
|
271
|
-
server.tool("analyze_table_relationships", "Analyze foreign key relationships between tables. Builds a dependency graph showing entity connectivity, orphan tables (no FKs), cascading delete chains, and
|
|
271
|
+
server.tool("analyze_table_relationships", "Analyze foreign key relationships between tables. Builds a dependency graph showing entity connectivity, orphan tables (no FKs), cascading delete chains (shown at full depth), hub entities (tables with 5+ FK connections), and circular FK dependencies. Useful for understanding schema design, planning migrations, and impact analysis.", {
|
|
272
272
|
schema: z
|
|
273
273
|
.string()
|
|
274
274
|
.default("public")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-db-analyzer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
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",
|