mcp-db-analyzer 0.2.8 → 0.2.10
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/build/analyzers/relationships.js +97 -14
- package/build/analyzers/schema.js +4 -1
- package/build/analyzers/vacuum.js +23 -9
- package/build/index.js +3 -3
- package/package.json +6 -6
|
@@ -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) {
|
|
@@ -56,7 +56,10 @@ async function listTablesSqlite() {
|
|
|
56
56
|
lines.push("|-------|-------------|------------|");
|
|
57
57
|
for (const row of result.rows) {
|
|
58
58
|
// SQLite doesn't have built-in row count or size — use count
|
|
59
|
-
|
|
59
|
+
// Escape embedded double-quote characters so table names like `weird"table` don't
|
|
60
|
+
// break the identifier quoting (same convention used in inspectTableSqlite).
|
|
61
|
+
const escapedName = row.name.replace(/"/g, '""');
|
|
62
|
+
const countResult = await query(`SELECT count(*) as cnt FROM "${escapedName}"`);
|
|
60
63
|
const cnt = countResult.rows[0]?.cnt ?? 0;
|
|
61
64
|
lines.push(`| ${row.name} | ${cnt} | - |`);
|
|
62
65
|
}
|
|
@@ -35,15 +35,29 @@ export async function analyzeVacuum(schema = "public") {
|
|
|
35
35
|
if (stats.rows.length === 0) {
|
|
36
36
|
return `No user tables found in schema '${schema}'.`;
|
|
37
37
|
}
|
|
38
|
-
// Get autovacuum settings
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
// Get autovacuum settings. pg_settings requires superuser or pg_read_all_settings
|
|
39
|
+
// in some PostgreSQL configurations — don't let a permission error discard the
|
|
40
|
+
// table stats we already fetched.
|
|
41
|
+
let settingsRows = [];
|
|
42
|
+
let settingsUnavailable = false;
|
|
43
|
+
try {
|
|
44
|
+
const settings = await query(`
|
|
45
|
+
SELECT name, setting
|
|
46
|
+
FROM pg_settings
|
|
47
|
+
WHERE name LIKE 'autovacuum%'
|
|
48
|
+
ORDER BY name
|
|
49
|
+
`);
|
|
50
|
+
settingsRows = settings.rows;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
settingsUnavailable = true;
|
|
54
|
+
}
|
|
55
|
+
const findings = analyzeFindings(stats.rows, settingsRows);
|
|
56
|
+
const report = formatVacuumReport(schema, stats.rows, settingsRows, findings);
|
|
57
|
+
if (settingsUnavailable) {
|
|
58
|
+
return report + "\n\n*Note: Autovacuum configuration could not be read — the database user may need pg_read_all_settings or superuser privileges.*";
|
|
59
|
+
}
|
|
60
|
+
return report;
|
|
47
61
|
}
|
|
48
62
|
export function analyzeFindings(tables, settings) {
|
|
49
63
|
const findings = [];
|
package/build/index.js
CHANGED
|
@@ -220,7 +220,7 @@ server.tool("suggest_missing_indexes", "Find tables with high sequential scan co
|
|
|
220
220
|
}
|
|
221
221
|
});
|
|
222
222
|
// --- Tool: analyze_slow_queries ---
|
|
223
|
-
server.tool("analyze_slow_queries", "Find the slowest queries using pg_stat_statements (PostgreSQL) or performance_schema (MySQL). Shows execution times, call counts, and optimization recommendations.", {
|
|
223
|
+
server.tool("analyze_slow_queries", "Find the slowest queries using pg_stat_statements (PostgreSQL) or performance_schema (MySQL). Shows execution times, call counts, and optimization recommendations. PostgreSQL requires the pg_stat_statements extension to be installed and listed in shared_preload_libraries — the tool returns setup instructions if the extension is missing. Not available for SQLite.", {
|
|
224
224
|
schema: z
|
|
225
225
|
.string()
|
|
226
226
|
.default("public")
|
|
@@ -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.10",
|
|
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",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
],
|
|
45
45
|
"license": "MIT",
|
|
46
46
|
"engines": {
|
|
47
|
-
"node": ">=
|
|
47
|
+
"node": ">=20.0.0"
|
|
48
48
|
},
|
|
49
49
|
"repository": {
|
|
50
50
|
"type": "git",
|
|
@@ -59,14 +59,14 @@
|
|
|
59
59
|
"better-sqlite3": "^12.6.2",
|
|
60
60
|
"mysql2": "^3.19.0",
|
|
61
61
|
"pg": "^8.13.0",
|
|
62
|
-
"zod": "^
|
|
62
|
+
"zod": "^4.0.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@types/better-sqlite3": "^7.6.13",
|
|
66
66
|
"@types/mysql": "^2.15.27",
|
|
67
|
-
"@types/node": "^
|
|
67
|
+
"@types/node": "^25.5.0",
|
|
68
68
|
"@types/pg": "^8.11.0",
|
|
69
|
-
"typescript": "^
|
|
70
|
-
"vitest": "^4.0
|
|
69
|
+
"typescript": "^6.0.0",
|
|
70
|
+
"vitest": "^4.1.0"
|
|
71
71
|
}
|
|
72
72
|
}
|