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.
@@ -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
- // Group by target (parent) table
170
- const cascadeByParent = new Map();
234
+ // Build parent→children map for cascade edges only
235
+ const cascadeChildren = new Map();
171
236
  for (const fk of cascadeDeletes) {
172
- const existing = cascadeByParent.get(fk.target_table) || [];
173
- existing.push(fk);
174
- cascadeByParent.set(fk.target_table, existing);
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
- for (const [parent, fks] of cascadeByParent) {
177
- const children = fks.map(f => f.source_table).join(", ");
178
- lines.push(`- **${parent}** cascades to: ${children}`);
179
- // Check for deep chains (cascade through multiple levels)
180
- for (const fk of fks) {
181
- const grandchildren = cascadeDeletes.filter(gfk => gfk.target_table === fk.source_table);
182
- if (grandchildren.length > 0) {
183
- const gcNames = grandchildren.map(g => g.source_table).join(", ");
184
- lines.push(` - **${fk.source_table}** further cascades to: ${gcNames}`);
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
- const countResult = await query(`SELECT count(*) as cnt FROM "${row.name}"`);
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
- const settings = await query(`
40
- SELECT name, setting
41
- FROM pg_settings
42
- WHERE name LIKE 'autovacuum%'
43
- ORDER BY name
44
- `);
45
- const findings = analyzeFindings(stats.rows, settings.rows);
46
- return formatVacuumReport(schema, stats.rows, settings.rows, findings);
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 hub entities. Useful for understanding schema design and impact analysis.", {
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.8",
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": ">=18.0.0"
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": "^3.24.2"
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": "^22.0.0",
67
+ "@types/node": "^25.5.0",
68
68
  "@types/pg": "^8.11.0",
69
- "typescript": "^5.8.2",
70
- "vitest": "^4.0.18"
69
+ "typescript": "^6.0.0",
70
+ "vitest": "^4.1.0"
71
71
  }
72
72
  }