@berthojoris/mcp-mysql-server 1.16.4 → 1.17.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.
@@ -0,0 +1,325 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.TestDataTools = void 0;
7
+ const connection_1 = __importDefault(require("../db/connection"));
8
+ const config_1 = require("../config/config");
9
+ class TestDataTools {
10
+ constructor(security) {
11
+ this.db = connection_1.default.getInstance();
12
+ this.security = security;
13
+ }
14
+ validateDatabaseAccess(requestedDatabase) {
15
+ const connectedDatabase = config_1.dbConfig.database;
16
+ if (!connectedDatabase) {
17
+ return {
18
+ valid: false,
19
+ database: "",
20
+ error: "No database configured. Please specify a database in your connection settings.",
21
+ };
22
+ }
23
+ if (requestedDatabase && requestedDatabase !== connectedDatabase) {
24
+ return {
25
+ valid: false,
26
+ database: "",
27
+ error: `Access denied: You are connected to '${connectedDatabase}' but requested '${requestedDatabase}'. Cross-database access is not permitted.`,
28
+ };
29
+ }
30
+ return {
31
+ valid: true,
32
+ database: connectedDatabase,
33
+ };
34
+ }
35
+ escapeValue(value) {
36
+ if (value === null || value === undefined)
37
+ return "NULL";
38
+ if (typeof value === "number")
39
+ return String(value);
40
+ if (typeof value === "boolean")
41
+ return value ? "1" : "0";
42
+ if (value instanceof Date) {
43
+ return `'${value.toISOString().slice(0, 19).replace("T", " ")}'`;
44
+ }
45
+ if (Buffer.isBuffer(value)) {
46
+ return `X'${value.toString("hex")}'`;
47
+ }
48
+ const escaped = String(value)
49
+ .replace(/\\/g, "\\\\")
50
+ .replace(/'/g, "\\'")
51
+ .replace(/"/g, '\\"')
52
+ .replace(/\n/g, "\\n")
53
+ .replace(/\r/g, "\\r")
54
+ .replace(/\t/g, "\\t")
55
+ .replace(/\0/g, "\\0");
56
+ return `'${escaped}'`;
57
+ }
58
+ parseEnumValues(columnType) {
59
+ const m = columnType.match(/^enum\((.*)\)$/i);
60
+ if (!m)
61
+ return [];
62
+ // values are single-quoted and may contain escaped quotes
63
+ const inner = m[1];
64
+ const values = [];
65
+ let current = "";
66
+ let inQuote = false;
67
+ for (let i = 0; i < inner.length; i++) {
68
+ const ch = inner[i];
69
+ const prev = inner[i - 1];
70
+ if (ch === "'" && prev !== "\\") {
71
+ inQuote = !inQuote;
72
+ if (!inQuote) {
73
+ values.push(current);
74
+ current = "";
75
+ }
76
+ continue;
77
+ }
78
+ if (inQuote)
79
+ current += ch;
80
+ }
81
+ return values;
82
+ }
83
+ clampString(value, maxLen) {
84
+ if (!maxLen || maxLen <= 0)
85
+ return value;
86
+ if (value.length <= maxLen)
87
+ return value;
88
+ return value.slice(0, Math.max(1, maxLen));
89
+ }
90
+ generateValueForColumn(col, rowIndex, fkSamples) {
91
+ const dataType = (col.DATA_TYPE || "").toLowerCase();
92
+ const colNameLower = col.COLUMN_NAME.toLowerCase();
93
+ // If FK sample values exist, use them
94
+ if (fkSamples && fkSamples.length > 0) {
95
+ const sample = fkSamples[rowIndex % fkSamples.length];
96
+ if (sample !== undefined && sample !== null)
97
+ return sample;
98
+ }
99
+ // Handle enums
100
+ if (dataType === "enum") {
101
+ const values = this.parseEnumValues(col.COLUMN_TYPE);
102
+ if (values.length > 0)
103
+ return values[rowIndex % values.length];
104
+ return col.IS_NULLABLE === "YES" ? null : "";
105
+ }
106
+ // Numeric types
107
+ if ([
108
+ "int",
109
+ "tinyint",
110
+ "smallint",
111
+ "mediumint",
112
+ "bigint",
113
+ ].includes(dataType)) {
114
+ if (colNameLower.includes("is_") || colNameLower.startsWith("is")) {
115
+ return rowIndex % 2;
116
+ }
117
+ if (colNameLower.endsWith("_id")) {
118
+ return rowIndex + 1;
119
+ }
120
+ return rowIndex + 1;
121
+ }
122
+ if (["decimal", "float", "double"].includes(dataType)) {
123
+ return parseFloat(((rowIndex + 1) * 1.11).toFixed(2));
124
+ }
125
+ // Date/time types
126
+ if (["date", "datetime", "timestamp"].includes(dataType)) {
127
+ const now = Date.now();
128
+ const daysAgo = rowIndex % 30;
129
+ const dt = new Date(now - daysAgo * 24 * 60 * 60 * 1000);
130
+ if (dataType === "date")
131
+ return dt.toISOString().slice(0, 10);
132
+ return dt;
133
+ }
134
+ // JSON
135
+ if (dataType === "json") {
136
+ return JSON.stringify({ seed: rowIndex + 1 });
137
+ }
138
+ // Binary
139
+ if (["blob", "tinyblob", "mediumblob", "longblob"].includes(dataType)) {
140
+ return Buffer.from([rowIndex % 256]);
141
+ }
142
+ // Text/string types
143
+ if (["varchar", "char", "text", "tinytext", "mediumtext", "longtext"].includes(dataType)) {
144
+ let v = "";
145
+ if (colNameLower.includes("email"))
146
+ v = `user${rowIndex + 1}@example.com`;
147
+ else if (colNameLower.includes("phone"))
148
+ v = `+1555000${String(rowIndex + 1).padStart(4, "0")}`;
149
+ else if (colNameLower.includes("url"))
150
+ v = `https://example.com/item/${rowIndex + 1}`;
151
+ else if (colNameLower.includes("name"))
152
+ v = `Sample ${col.COLUMN_NAME} ${rowIndex + 1}`;
153
+ else if (colNameLower.includes("title"))
154
+ v = `Title ${rowIndex + 1}`;
155
+ else if (colNameLower.includes("description"))
156
+ v = `Description for row ${rowIndex + 1}`;
157
+ else
158
+ v = `${col.COLUMN_NAME}_${rowIndex + 1}`;
159
+ return this.clampString(v, col.CHARACTER_MAXIMUM_LENGTH);
160
+ }
161
+ // Fallback: string
162
+ const fallback = `${col.COLUMN_NAME}_${rowIndex + 1}`;
163
+ return this.clampString(fallback, col.CHARACTER_MAXIMUM_LENGTH);
164
+ }
165
+ /**
166
+ * Generate SQL INSERT statements (does not execute) for synthetic test data.
167
+ * Attempts to maintain referential integrity by sampling referenced keys when foreign keys exist.
168
+ */
169
+ async generateTestData(params) {
170
+ try {
171
+ const dbValidation = this.validateDatabaseAccess(params?.database);
172
+ if (!dbValidation.valid) {
173
+ return { status: "error", error: dbValidation.error };
174
+ }
175
+ const database = dbValidation.database;
176
+ const { table_name, row_count } = params;
177
+ const batchSize = Math.min(Math.max(params.batch_size ?? 100, 1), 1000);
178
+ const includeNulls = params.include_nulls ?? true;
179
+ if (!this.security.validateIdentifier(table_name).valid) {
180
+ return { status: "error", error: "Invalid table name" };
181
+ }
182
+ if (!Number.isFinite(row_count) || row_count <= 0) {
183
+ return { status: "error", error: "row_count must be a positive number" };
184
+ }
185
+ if (row_count > 5000) {
186
+ return {
187
+ status: "error",
188
+ error: "row_count too large (max 5000) to avoid oversized responses",
189
+ };
190
+ }
191
+ // Read column metadata
192
+ const columns = await this.db.query(`
193
+ SELECT
194
+ COLUMN_NAME,
195
+ DATA_TYPE,
196
+ COLUMN_TYPE,
197
+ IS_NULLABLE,
198
+ COLUMN_DEFAULT,
199
+ EXTRA,
200
+ COLUMN_KEY,
201
+ CHARACTER_MAXIMUM_LENGTH
202
+ FROM INFORMATION_SCHEMA.COLUMNS
203
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
204
+ ORDER BY ORDINAL_POSITION
205
+ `, [database, table_name]);
206
+ if (!columns.length) {
207
+ return {
208
+ status: "error",
209
+ error: `Table '${table_name}' not found or has no columns`,
210
+ };
211
+ }
212
+ // Foreign keys for the table
213
+ const fks = await this.db.query(`
214
+ SELECT
215
+ COLUMN_NAME,
216
+ REFERENCED_TABLE_NAME,
217
+ REFERENCED_COLUMN_NAME
218
+ FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
219
+ WHERE TABLE_SCHEMA = ?
220
+ AND TABLE_NAME = ?
221
+ AND REFERENCED_TABLE_NAME IS NOT NULL
222
+ `, [database, table_name]);
223
+ const fkSamplesByColumn = new Map();
224
+ const warnings = [];
225
+ for (const fk of fks) {
226
+ const refTable = fk.REFERENCED_TABLE_NAME;
227
+ const refCol = fk.REFERENCED_COLUMN_NAME;
228
+ if (!this.security.validateIdentifier(refTable).valid)
229
+ continue;
230
+ if (!this.security.validateIdentifier(refCol).valid)
231
+ continue;
232
+ const escapedRefTable = this.security.escapeIdentifier(refTable);
233
+ const escapedRefCol = this.security.escapeIdentifier(refCol);
234
+ const sampleRows = await this.db.query(`SELECT ${escapedRefCol} as v FROM ${escapedRefTable} WHERE ${escapedRefCol} IS NOT NULL LIMIT 200`);
235
+ const sampleValues = sampleRows.map((r) => r.v).filter((v) => v !== null);
236
+ if (sampleValues.length === 0) {
237
+ warnings.push(`Foreign key '${table_name}.${fk.COLUMN_NAME}' references '${refTable}.${refCol}' but no referenced key samples were found (referenced table may be empty).`);
238
+ }
239
+ fkSamplesByColumn.set(fk.COLUMN_NAME, sampleValues);
240
+ }
241
+ // Determine insertable columns (skip auto-increment and generated columns)
242
+ const insertColumns = columns.filter((c) => {
243
+ const extra = (c.EXTRA || "").toLowerCase();
244
+ if (extra.includes("auto_increment"))
245
+ return false;
246
+ if (extra.includes("generated"))
247
+ return false;
248
+ return true;
249
+ });
250
+ if (!insertColumns.length) {
251
+ return {
252
+ status: "error",
253
+ error: `No insertable columns found for '${table_name}' (all columns are auto-increment/generated?)`,
254
+ };
255
+ }
256
+ const escapedDb = this.security.escapeIdentifier(database);
257
+ const escapedTable = this.security.escapeIdentifier(table_name);
258
+ const escapedCols = insertColumns.map((c) => this.security.escapeIdentifier(c.COLUMN_NAME));
259
+ const previewRows = [];
260
+ const statements = [];
261
+ for (let start = 0; start < row_count; start += batchSize) {
262
+ const end = Math.min(start + batchSize, row_count);
263
+ const valuesSql = [];
264
+ for (let i = start; i < end; i++) {
265
+ const rowObj = {};
266
+ const rowVals = [];
267
+ for (const col of insertColumns) {
268
+ const fkSamples = fkSamplesByColumn.get(col.COLUMN_NAME);
269
+ let v = this.generateValueForColumn(col, i, fkSamples);
270
+ // Null handling
271
+ if (!includeNulls && col.IS_NULLABLE === "YES") {
272
+ // avoid NULLs unless needed
273
+ if (v === null)
274
+ v = this.generateValueForColumn(col, i, undefined);
275
+ }
276
+ if ((v === null || v === undefined) && col.IS_NULLABLE === "NO") {
277
+ if (col.COLUMN_DEFAULT !== null && col.COLUMN_DEFAULT !== undefined) {
278
+ // Let DB default apply by omitting value where possible.
279
+ // We can't omit per-column in multi-row insert safely, so materialize a value.
280
+ v = col.COLUMN_DEFAULT;
281
+ }
282
+ else {
283
+ // Last resort: generate non-null fallback
284
+ v = this.generateValueForColumn({ ...col, IS_NULLABLE: "YES" }, i);
285
+ if (v === null || v === undefined) {
286
+ return {
287
+ status: "error",
288
+ error: `Cannot generate non-null value for NOT NULL column '${col.COLUMN_NAME}'`,
289
+ };
290
+ }
291
+ }
292
+ }
293
+ rowObj[col.COLUMN_NAME] = v;
294
+ rowVals.push(this.escapeValue(v));
295
+ }
296
+ if (previewRows.length < 20)
297
+ previewRows.push(rowObj);
298
+ valuesSql.push(`(${rowVals.join(", ")})`);
299
+ }
300
+ const stmt = `INSERT INTO ${escapedDb}.${escapedTable} (${escapedCols.join(", ")}) VALUES\n${valuesSql.join(",\n")};`;
301
+ statements.push(stmt);
302
+ }
303
+ return {
304
+ status: "success",
305
+ data: {
306
+ database,
307
+ table_name,
308
+ row_count,
309
+ batch_size: batchSize,
310
+ insert_sql: statements.join("\n\n"),
311
+ preview_rows: previewRows,
312
+ warnings,
313
+ notes: [
314
+ "This tool only generates SQL (does not execute).",
315
+ "Foreign key columns will use sampled referenced keys when available.",
316
+ ],
317
+ },
318
+ };
319
+ }
320
+ catch (error) {
321
+ return { status: "error", error: error.message };
322
+ }
323
+ }
324
+ }
325
+ exports.TestDataTools = TestDataTools;
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mysql-mcp",
3
3
  "description": "A Model Context Protocol for MySQL database interaction",
4
- "version": "1.16.4",
4
+ "version": "1.17.0",
5
5
  "tools": [
6
6
  {
7
7
  "name": "list_databases",
@@ -616,6 +616,114 @@
616
616
  "error": { "type": ["string", "null"] }
617
617
  }
618
618
  }
619
+ },
620
+ {
621
+ "name": "generate_test_data",
622
+ "description": "Generates synthetic test data as SQL INSERT statements for a given table (does not execute). Attempts FK-aware value selection for referential integrity.",
623
+ "input_schema": {
624
+ "type": "object",
625
+ "properties": {
626
+ "table_name": { "type": "string" },
627
+ "row_count": { "type": "number" },
628
+ "batch_size": { "type": "number" },
629
+ "include_nulls": { "type": "boolean" },
630
+ "database": { "type": "string" }
631
+ },
632
+ "required": ["table_name", "row_count"]
633
+ },
634
+ "output_schema": {
635
+ "type": "object",
636
+ "properties": {
637
+ "status": { "type": "string" },
638
+ "data": { "type": "object" },
639
+ "error": { "type": ["string", "null"] }
640
+ }
641
+ }
642
+ },
643
+ {
644
+ "name": "analyze_schema_patterns",
645
+ "description": "Analyzes the schema for common patterns and anti-patterns (missing PKs, wide tables, unindexed FKs, EAV-like tables, etc.) and returns recommendations.",
646
+ "input_schema": {
647
+ "type": "object",
648
+ "properties": {
649
+ "scope": { "type": "string", "enum": ["database", "table"] },
650
+ "table_name": { "type": "string" },
651
+ "database": { "type": "string" }
652
+ }
653
+ },
654
+ "output_schema": {
655
+ "type": "object",
656
+ "properties": {
657
+ "status": { "type": "string" },
658
+ "data": { "type": "object" },
659
+ "error": { "type": ["string", "null"] }
660
+ }
661
+ }
662
+ },
663
+ {
664
+ "name": "visualize_query",
665
+ "description": "Creates a visual representation of a read-only SQL query as a Mermaid flowchart, based on EXPLAIN FORMAT=JSON and lightweight SQL parsing.",
666
+ "input_schema": {
667
+ "type": "object",
668
+ "properties": {
669
+ "query": { "type": "string" },
670
+ "include_explain_json": { "type": "boolean" },
671
+ "format": { "type": "string", "enum": ["mermaid", "json", "both"] }
672
+ },
673
+ "required": ["query"]
674
+ },
675
+ "output_schema": {
676
+ "type": "object",
677
+ "properties": {
678
+ "status": { "type": "string" },
679
+ "data": { "type": "object" },
680
+ "error": { "type": ["string", "null"] }
681
+ }
682
+ }
683
+ },
684
+ {
685
+ "name": "predict_query_performance",
686
+ "description": "Predicts how EXPLAIN-estimated scan volume/cost could change under table growth assumptions (heuristic).",
687
+ "input_schema": {
688
+ "type": "object",
689
+ "properties": {
690
+ "query": { "type": "string" },
691
+ "row_growth_multiplier": { "type": "number" },
692
+ "per_table_row_growth": { "type": "object" },
693
+ "include_explain_json": { "type": "boolean" }
694
+ },
695
+ "required": ["query"]
696
+ },
697
+ "output_schema": {
698
+ "type": "object",
699
+ "properties": {
700
+ "status": { "type": "string" },
701
+ "data": { "type": "object" },
702
+ "error": { "type": ["string", "null"] }
703
+ }
704
+ }
705
+ },
706
+ {
707
+ "name": "forecast_database_growth",
708
+ "description": "Forecasts database/table growth based on current INFORMATION_SCHEMA sizes and user-supplied growth rate assumptions.",
709
+ "input_schema": {
710
+ "type": "object",
711
+ "properties": {
712
+ "horizon_days": { "type": "number" },
713
+ "growth_rate_percent_per_day": { "type": "number" },
714
+ "growth_rate_percent_per_month": { "type": "number" },
715
+ "per_table_growth_rate_percent_per_day": { "type": "object" },
716
+ "database": { "type": "string" }
717
+ }
718
+ },
719
+ "output_schema": {
720
+ "type": "object",
721
+ "properties": {
722
+ "status": { "type": "string" },
723
+ "data": { "type": "object" },
724
+ "error": { "type": ["string", "null"] }
725
+ }
726
+ }
619
727
  }
620
728
  ]
621
729
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@berthojoris/mcp-mysql-server",
3
- "version": "1.16.4",
3
+ "version": "1.17.0",
4
4
  "description": "Model Context Protocol server for MySQL database integration with dynamic per-project permissions, backup/restore, data import/export, and data migration capabilities",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",