mcp-db-analyzer 0.2.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/build/index.js ADDED
@@ -0,0 +1,380 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { listTables, inspectTable } from "./analyzers/schema.js";
6
+ import { analyzeIndexUsage, findMissingIndexes } from "./analyzers/indexes.js";
7
+ import { explainQuery } from "./analyzers/query.js";
8
+ import { analyzeTableBloat } from "./analyzers/bloat.js";
9
+ import { suggestMissingIndexes } from "./analyzers/suggestions.js";
10
+ import { analyzeSlowQueries } from "./analyzers/slow-queries.js";
11
+ import { analyzeConnections } from "./analyzers/connections.js";
12
+ import { analyzeTableRelationships } from "./analyzers/relationships.js";
13
+ import { analyzeVacuum } from "./analyzers/vacuum.js";
14
+ import { closePool, initDriver } from "./db.js";
15
+ import { formatToolError } from "./errors.js";
16
+ import { validateLicense, formatUpgradePrompt } from "./license.js";
17
+ // License check (reads MCP_LICENSE_KEY env var once at startup)
18
+ const license = validateLicense(process.env.MCP_LICENSE_KEY, "db-analyzer");
19
+ // Handle --help
20
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
21
+ console.log(`mcp-db-analyzer v0.1.0 — MCP server for database analysis
22
+
23
+ Usage:
24
+ mcp-db-analyzer [options]
25
+
26
+ Options:
27
+ --driver <type> Database driver: postgres (default), mysql, sqlite
28
+ --help, -h Show this help message
29
+
30
+ Environment:
31
+ DATABASE_URL Connection string (required)
32
+ DB_DRIVER Alternative to --driver flag
33
+ PGHOST/PGPORT/... PostgreSQL individual variables
34
+ MYSQL_HOST/... MySQL individual variables
35
+
36
+ Tools provided:
37
+ inspect_schema List tables or inspect a specific table
38
+ analyze_indexes Find unused and missing indexes
39
+ explain_query EXPLAIN/EXPLAIN ANALYZE for SQL queries
40
+ analyze_table_bloat Detect dead tuples and fragmentation
41
+ suggest_missing_indexes Actionable index recommendations
42
+ analyze_slow_queries Find slowest queries from pg_stat_statements
43
+ analyze_connections Detect idle-in-transaction, long queries, lock waits
44
+ analyze_table_relationships FK dependency graph, orphans, cascade chains
45
+ analyze_vacuum PostgreSQL vacuum health, dead tuples, autovacuum config`);
46
+ process.exit(0);
47
+ }
48
+ // Parse --driver flag from CLI args or DB_DRIVER env var
49
+ const VALID_DRIVERS = new Set(["postgres", "mysql", "sqlite"]);
50
+ function detectDriver() {
51
+ const driverArg = process.argv.find((a) => a.startsWith("--driver="));
52
+ if (driverArg) {
53
+ const val = driverArg.split("=")[1];
54
+ if (VALID_DRIVERS.has(val))
55
+ return val;
56
+ console.error(`Unknown driver: ${val}. Use 'postgres', 'mysql', or 'sqlite'.`);
57
+ process.exit(1);
58
+ }
59
+ const driverIdx = process.argv.indexOf("--driver");
60
+ if (driverIdx !== -1 && process.argv[driverIdx + 1]) {
61
+ const val = process.argv[driverIdx + 1];
62
+ if (VALID_DRIVERS.has(val))
63
+ return val;
64
+ console.error(`Unknown driver: ${val}. Use 'postgres', 'mysql', or 'sqlite'.`);
65
+ process.exit(1);
66
+ }
67
+ const envDriver = process.env.DB_DRIVER;
68
+ if (envDriver && VALID_DRIVERS.has(envDriver))
69
+ return envDriver;
70
+ return "postgres";
71
+ }
72
+ const server = new McpServer({
73
+ name: "mcp-db-analyzer",
74
+ version: "0.1.0",
75
+ });
76
+ // --- Tool: inspect_schema ---
77
+ server.tool("inspect_schema", "List all tables in a schema with row counts and sizes, or inspect a specific table's columns, types, constraints, and foreign keys.", {
78
+ table: z
79
+ .string()
80
+ .optional()
81
+ .describe("Specific table name to inspect. Omit to list all tables."),
82
+ schema: z
83
+ .string()
84
+ .default("public")
85
+ .describe("Database schema to inspect (default: public)"),
86
+ }, async ({ table, schema }) => {
87
+ try {
88
+ const result = table
89
+ ? await inspectTable(table, schema)
90
+ : await listTables(schema);
91
+ return { content: [{ type: "text", text: result }] };
92
+ }
93
+ catch (err) {
94
+ return {
95
+ content: [
96
+ {
97
+ type: "text",
98
+ text: formatToolError("inspecting schema", err),
99
+ },
100
+ ],
101
+ };
102
+ }
103
+ });
104
+ // --- Tool: analyze_indexes ---
105
+ server.tool("analyze_indexes", "Analyze index usage statistics to find unused indexes wasting space and missing indexes causing slow sequential scans. Also detects unindexed foreign keys.", {
106
+ schema: z
107
+ .string()
108
+ .default("public")
109
+ .describe("Database schema to analyze (default: public)"),
110
+ mode: z
111
+ .enum(["usage", "missing", "all"])
112
+ .default("all")
113
+ .describe("Analysis mode: 'usage' for unused index detection, 'missing' for missing index suggestions, 'all' for both"),
114
+ }, async ({ schema, mode }) => {
115
+ try {
116
+ const parts = [];
117
+ if (mode === "usage" || mode === "all") {
118
+ parts.push(await analyzeIndexUsage(schema));
119
+ }
120
+ if (mode === "missing" || mode === "all") {
121
+ parts.push(await findMissingIndexes(schema));
122
+ }
123
+ return { content: [{ type: "text", text: parts.join("\n\n---\n\n") }] };
124
+ }
125
+ catch (err) {
126
+ return {
127
+ content: [
128
+ {
129
+ type: "text",
130
+ text: formatToolError("analyzing indexes", err),
131
+ },
132
+ ],
133
+ };
134
+ }
135
+ });
136
+ // --- Tool: explain_query ---
137
+ server.tool("explain_query", "Run EXPLAIN on a SQL query and return a formatted plan with cost estimates, node types, and optimization warnings. Optionally runs EXPLAIN ANALYZE for actual execution statistics (read-only queries only).", {
138
+ sql: z.string().describe("The SQL query to explain"),
139
+ analyze: z
140
+ .boolean()
141
+ .default(false)
142
+ .describe("Run EXPLAIN ANALYZE to get actual execution times (executes the query). Only allowed for SELECT queries."),
143
+ }, async ({ sql, analyze }) => {
144
+ try {
145
+ const result = await explainQuery(sql, analyze);
146
+ return { content: [{ type: "text", text: result }] };
147
+ }
148
+ catch (err) {
149
+ return {
150
+ content: [
151
+ {
152
+ type: "text",
153
+ text: formatToolError("explaining query", err),
154
+ },
155
+ ],
156
+ };
157
+ }
158
+ });
159
+ // --- Tool: analyze_table_bloat ---
160
+ server.tool("analyze_table_bloat", "Analyze table bloat by checking dead tuple ratios (PostgreSQL) or InnoDB fragmentation (MySQL), vacuum history, and table sizes.", {
161
+ schema: z
162
+ .string()
163
+ .default("public")
164
+ .describe("Database schema to analyze (default: public)"),
165
+ }, async ({ schema }) => {
166
+ try {
167
+ const result = await analyzeTableBloat(schema);
168
+ return { content: [{ type: "text", text: result }] };
169
+ }
170
+ catch (err) {
171
+ return {
172
+ content: [
173
+ {
174
+ type: "text",
175
+ text: formatToolError("analyzing table bloat", err),
176
+ },
177
+ ],
178
+ };
179
+ }
180
+ });
181
+ // --- Tool: suggest_missing_indexes ---
182
+ server.tool("suggest_missing_indexes", "Find tables with high sequential scan counts and zero index usage, cross-referenced with unused indexes wasting space. Provides actionable CREATE INDEX and DROP INDEX recommendations.", {
183
+ schema: z
184
+ .string()
185
+ .default("public")
186
+ .describe("Database schema to analyze (default: public)"),
187
+ }, async ({ schema }) => {
188
+ if (!license.isPro) {
189
+ return {
190
+ content: [{
191
+ type: "text",
192
+ text: formatUpgradePrompt("suggest_missing_indexes", "Actionable index recommendations with:\n" +
193
+ "- Tables with high sequential scan counts\n" +
194
+ "- Cross-referenced unused indexes wasting space\n" +
195
+ "- Ready-to-run CREATE INDEX and DROP INDEX statements"),
196
+ }],
197
+ };
198
+ }
199
+ try {
200
+ const result = await suggestMissingIndexes(schema);
201
+ return { content: [{ type: "text", text: result }] };
202
+ }
203
+ catch (err) {
204
+ return {
205
+ content: [
206
+ {
207
+ type: "text",
208
+ text: formatToolError("suggesting indexes", err),
209
+ },
210
+ ],
211
+ };
212
+ }
213
+ });
214
+ // --- Tool: analyze_slow_queries ---
215
+ 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.", {
216
+ schema: z
217
+ .string()
218
+ .default("public")
219
+ .describe("Database schema (default: public)"),
220
+ limit: z
221
+ .number()
222
+ .default(10)
223
+ .describe("Number of slow queries to return (default: 10)"),
224
+ }, async ({ schema, limit }) => {
225
+ if (!license.isPro) {
226
+ return {
227
+ content: [{
228
+ type: "text",
229
+ text: formatUpgradePrompt("analyze_slow_queries", "Slow query analysis with:\n" +
230
+ "- Top queries ranked by total execution time\n" +
231
+ "- Call counts, mean/max times, rows returned\n" +
232
+ "- Optimization recommendations"),
233
+ }],
234
+ };
235
+ }
236
+ try {
237
+ const result = await analyzeSlowQueries(schema, limit);
238
+ return { content: [{ type: "text", text: result }] };
239
+ }
240
+ catch (err) {
241
+ return {
242
+ content: [
243
+ {
244
+ type: "text",
245
+ text: formatToolError("analyzing slow queries", err),
246
+ },
247
+ ],
248
+ };
249
+ }
250
+ });
251
+ // Tool 7: analyze_connections
252
+ 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.", {}, async () => {
253
+ if (!license.isPro) {
254
+ return {
255
+ content: [{
256
+ type: "text",
257
+ text: formatUpgradePrompt("analyze_connections", "Connection analysis with:\n" +
258
+ "- Idle-in-transaction session detection\n" +
259
+ "- Long-running query identification\n" +
260
+ "- Lock contention analysis\n" +
261
+ "- Connection pool utilization metrics"),
262
+ }],
263
+ };
264
+ }
265
+ try {
266
+ const result = await analyzeConnections();
267
+ return { content: [{ type: "text", text: result }] };
268
+ }
269
+ catch (err) {
270
+ return {
271
+ content: [
272
+ {
273
+ type: "text",
274
+ text: formatToolError("analyzing connections", err),
275
+ },
276
+ ],
277
+ };
278
+ }
279
+ });
280
+ // Tool 8: analyze_table_relationships
281
+ 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.", {
282
+ schema: z
283
+ .string()
284
+ .default("public")
285
+ .describe("Database schema to analyze (default: public)"),
286
+ }, async ({ schema }) => {
287
+ if (!license.isPro) {
288
+ return {
289
+ content: [{
290
+ type: "text",
291
+ text: formatUpgradePrompt("analyze_table_relationships", "Table relationship analysis with:\n" +
292
+ "- Foreign key dependency graph\n" +
293
+ "- Orphan table detection\n" +
294
+ "- Cascading delete chain analysis\n" +
295
+ "- Hub entity identification"),
296
+ }],
297
+ };
298
+ }
299
+ try {
300
+ const result = await analyzeTableRelationships(schema);
301
+ return { content: [{ type: "text", text: result }] };
302
+ }
303
+ catch (err) {
304
+ return {
305
+ content: [
306
+ {
307
+ type: "text",
308
+ text: formatToolError("analyzing relationships", err),
309
+ },
310
+ ],
311
+ };
312
+ }
313
+ });
314
+ // --- Tool: analyze_vacuum ---
315
+ server.tool("analyze_vacuum", "Analyze PostgreSQL VACUUM maintenance status. Checks dead tuple ratios, vacuum staleness, autovacuum configuration, and identifies tables needing manual VACUUM. PostgreSQL only.", {
316
+ schema: z
317
+ .string()
318
+ .default("public")
319
+ .describe("Database schema to analyze (default: public)"),
320
+ }, async ({ schema }) => {
321
+ try {
322
+ const result = await analyzeVacuum(schema);
323
+ return { content: [{ type: "text", text: result }] };
324
+ }
325
+ catch (err) {
326
+ return {
327
+ content: [
328
+ {
329
+ type: "text",
330
+ text: formatToolError("analyzing vacuum status", err),
331
+ },
332
+ ],
333
+ };
334
+ }
335
+ });
336
+ // --- Start server ---
337
+ async function main() {
338
+ const driver = detectDriver();
339
+ await initDriver(driver);
340
+ console.error(`MCP DB Analyzer running on stdio (driver: ${driver})`);
341
+ // Test database connectivity early — warn on stderr if unreachable
342
+ try {
343
+ const { query: testQuery } = await import("./db.js");
344
+ await testQuery("SELECT 1");
345
+ console.error("Database connection: OK");
346
+ }
347
+ catch (err) {
348
+ const msg = err instanceof Error ? err.message : String(err);
349
+ const sanitized = msg.replace(/\/\/[^@]+@/g, "//****:****@");
350
+ console.error(`WARNING: Database connection failed: ${sanitized}`);
351
+ if (driver === "postgres") {
352
+ console.error("Configure via DATABASE_URL or PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE environment variables.");
353
+ }
354
+ else if (driver === "mysql") {
355
+ console.error("Configure via DATABASE_URL or MYSQL_HOST/MYSQL_PORT/MYSQL_USER/MYSQL_PASSWORD/MYSQL_DATABASE environment variables.");
356
+ }
357
+ else {
358
+ console.error("Configure via DATABASE_URL, SQLITE_PATH, or DB_PATH environment variable.");
359
+ }
360
+ console.error("The server will start, but tools will return errors until the database is reachable.");
361
+ }
362
+ const transport = new StdioServerTransport();
363
+ await server.connect(transport);
364
+ }
365
+ // Graceful shutdown
366
+ process.on("SIGINT", async () => {
367
+ await closePool();
368
+ process.exit(0);
369
+ });
370
+ process.on("SIGTERM", async () => {
371
+ await closePool();
372
+ process.exit(0);
373
+ });
374
+ main().catch((error) => {
375
+ // Sanitize error to avoid leaking credentials from connection strings
376
+ const msg = error instanceof Error ? error.message : String(error);
377
+ const sanitized = msg.replace(/\/\/[^@]+@/g, "//****:****@");
378
+ console.error("Fatal error:", sanitized);
379
+ process.exit(1);
380
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * License validation for MCP Migration Advisor (Pro features).
3
+ *
4
+ * Validates license keys offline using HMAC-SHA256.
5
+ * Missing or invalid keys gracefully degrade to free mode — never errors.
6
+ *
7
+ * Key format: MCPJBS-XXXXX-XXXXX-XXXXX-XXXXX
8
+ * Payload (12 bytes = 20 base32 chars):
9
+ * [0] product mask (8 bits)
10
+ * [1-2] expiry days since 2026-01-01 (16 bits)
11
+ * [3-5] customer ID (24 bits)
12
+ * [6-11] HMAC-SHA256 truncated (48 bits)
13
+ */
14
+ import { createHmac } from "node:crypto";
15
+ const KEY_PREFIX = "MCPJBS-";
16
+ const EPOCH = new Date("2026-01-01T00:00:00Z");
17
+ const BASE32_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
18
+ const HMAC_SECRET = "mcp-java-backend-suite-license-v1";
19
+ const PRODUCTS = {
20
+ "db-analyzer": 0,
21
+ "jvm-diagnostics": 1,
22
+ "migration-advisor": 2,
23
+ "spring-boot-actuator": 3,
24
+ "redis-diagnostics": 4,
25
+ };
26
+ export function validateLicense(key, product) {
27
+ const FREE = {
28
+ isPro: false,
29
+ expiresAt: null,
30
+ customerId: null,
31
+ reason: "No license key provided",
32
+ };
33
+ if (!key || key.trim().length === 0)
34
+ return FREE;
35
+ const trimmed = key.trim().toUpperCase();
36
+ if (!trimmed.startsWith(KEY_PREFIX)) {
37
+ return { ...FREE, reason: "Invalid key format: missing MCPJBS- prefix" };
38
+ }
39
+ const body = trimmed.slice(KEY_PREFIX.length).replace(/-/g, "");
40
+ if (body.length < 20) {
41
+ return { ...FREE, reason: "Invalid key format: too short" };
42
+ }
43
+ let decoded;
44
+ try {
45
+ decoded = base32Decode(body.slice(0, 20));
46
+ }
47
+ catch {
48
+ return { ...FREE, reason: "Invalid key format: bad base32 encoding" };
49
+ }
50
+ if (decoded.length < 12) {
51
+ return { ...FREE, reason: "Invalid key format: decoded data too short" };
52
+ }
53
+ const payload = decoded.subarray(0, 6);
54
+ const providedSignature = decoded.subarray(6, 12);
55
+ const expectedHmac = createHmac("sha256", HMAC_SECRET)
56
+ .update(payload)
57
+ .digest();
58
+ const expectedSignature = expectedHmac.subarray(0, 6);
59
+ if (!providedSignature.equals(expectedSignature)) {
60
+ return { ...FREE, reason: "Invalid license key: signature mismatch" };
61
+ }
62
+ const productMask = payload[0];
63
+ const daysSinceEpoch = (payload[1] << 8) | payload[2];
64
+ const customerId = (payload[3] << 16) | (payload[4] << 8) | payload[5];
65
+ const productBit = PRODUCTS[product];
66
+ if (productBit === undefined) {
67
+ return { ...FREE, reason: `Unknown product: ${product}` };
68
+ }
69
+ if ((productMask & (1 << productBit)) === 0) {
70
+ return { ...FREE, customerId, reason: `License does not include ${product}` };
71
+ }
72
+ const expiresAt = new Date(EPOCH.getTime() + daysSinceEpoch * 24 * 60 * 60 * 1000);
73
+ if (new Date() > expiresAt) {
74
+ return {
75
+ isPro: false,
76
+ expiresAt,
77
+ customerId,
78
+ reason: `License expired on ${expiresAt.toISOString().slice(0, 10)}`,
79
+ };
80
+ }
81
+ return { isPro: true, expiresAt, customerId, reason: "Valid Pro license" };
82
+ }
83
+ export function formatUpgradePrompt(toolName, featureDescription) {
84
+ return [
85
+ `## ${toolName} (Pro Feature)`,
86
+ "",
87
+ "This analysis is available with MCP Java Backend Suite Pro.",
88
+ "",
89
+ `**What you'll get:**`,
90
+ featureDescription,
91
+ "",
92
+ "**Upgrade**: https://mcpjbs.dev/pricing",
93
+ "**Price**: $19/month or $190/year",
94
+ "",
95
+ "> Already have a key? Set `MCP_LICENSE_KEY` in your Claude Desktop config.",
96
+ ].join("\n");
97
+ }
98
+ function base32Decode(encoded) {
99
+ const bytes = [];
100
+ let bits = 0;
101
+ let value = 0;
102
+ for (const char of encoded) {
103
+ const idx = BASE32_CHARS.indexOf(char);
104
+ if (idx === -1)
105
+ throw new Error(`Invalid base32 character: ${char}`);
106
+ value = (value << 5) | idx;
107
+ bits += 5;
108
+ if (bits >= 8) {
109
+ bits -= 8;
110
+ bytes.push((value >> bits) & 0xff);
111
+ }
112
+ }
113
+ return Buffer.from(bytes);
114
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "mcp-db-analyzer",
3
+ "version": "0.2.0",
4
+ "description": "MCP server for PostgreSQL, MySQL, and SQLite schema analysis, index optimization, and query plan inspection",
5
+ "author": "Dmytro Lisnichenko",
6
+ "type": "module",
7
+ "main": "./build/index.js",
8
+ "bin": {
9
+ "mcp-db-analyzer": "./build/index.js"
10
+ },
11
+ "scripts": {
12
+ "build": "tsc && chmod 755 build/index.js",
13
+ "test": "vitest run --exclude 'tests/integration/**' --exclude 'build/**'",
14
+ "test:integration": "vitest run tests/integration/",
15
+ "dev": "tsc --watch",
16
+ "start": "node build/index.js",
17
+ "prepublishOnly": "npm run build && npm test"
18
+ },
19
+ "files": [
20
+ "build/**/*.js",
21
+ "!build/__tests__",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "keywords": [
26
+ "mcp",
27
+ "model-context-protocol",
28
+ "postgresql",
29
+ "mysql",
30
+ "sqlite",
31
+ "database",
32
+ "schema-analyzer",
33
+ "index-optimization",
34
+ "query-plan",
35
+ "ai",
36
+ "claude"
37
+ ],
38
+ "license": "MIT",
39
+ "engines": {
40
+ "node": ">=18.0.0"
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/Dmitriusan/mcp-db-analyzer"
45
+ },
46
+ "homepage": "https://github.com/Dmitriusan/mcp-db-analyzer#readme",
47
+ "bugs": {
48
+ "url": "https://github.com/Dmitriusan/mcp-db-analyzer/issues"
49
+ },
50
+ "dependencies": {
51
+ "@modelcontextprotocol/sdk": "^1.27.1",
52
+ "better-sqlite3": "^12.6.2",
53
+ "mysql2": "^3.19.0",
54
+ "pg": "^8.13.0",
55
+ "zod": "^3.24.2"
56
+ },
57
+ "devDependencies": {
58
+ "@types/better-sqlite3": "^7.6.13",
59
+ "@types/mysql": "^2.15.27",
60
+ "@types/node": "^22.0.0",
61
+ "@types/pg": "^8.11.0",
62
+ "typescript": "^5.8.2",
63
+ "vitest": "^4.0.18"
64
+ }
65
+ }