@berthojoris/mcp-mysql-server 1.6.3 → 1.8.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/DOCUMENTATIONS.md +551 -28
- package/README.md +36 -117
- package/dist/config/featureConfig.js +30 -0
- package/dist/index.d.ts +185 -0
- package/dist/index.js +113 -0
- package/dist/mcp-server.js +551 -0
- package/dist/tools/backupRestoreTools.d.ts +91 -0
- package/dist/tools/backupRestoreTools.js +584 -0
- package/dist/tools/dataExportTools.d.ts +91 -2
- package/dist/tools/dataExportTools.js +726 -66
- package/dist/tools/migrationTools.d.ts +96 -0
- package/dist/tools/migrationTools.js +546 -0
- package/package.json +2 -2
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import SecurityLayer from "../security/securityLayer";
|
|
2
|
+
/**
|
|
3
|
+
* Data Migration Tools for MySQL MCP Server
|
|
4
|
+
* Provides utilities for copying, moving, and transforming data between tables
|
|
5
|
+
*/
|
|
6
|
+
export declare class MigrationTools {
|
|
7
|
+
private db;
|
|
8
|
+
private security;
|
|
9
|
+
constructor(security: SecurityLayer);
|
|
10
|
+
/**
|
|
11
|
+
* Validate database access
|
|
12
|
+
*/
|
|
13
|
+
private validateDatabaseAccess;
|
|
14
|
+
/**
|
|
15
|
+
* Escape string value for SQL
|
|
16
|
+
*/
|
|
17
|
+
private escapeValue;
|
|
18
|
+
/**
|
|
19
|
+
* Copy data from one table to another within the same database
|
|
20
|
+
*/
|
|
21
|
+
copyTableData(params: {
|
|
22
|
+
source_table: string;
|
|
23
|
+
target_table: string;
|
|
24
|
+
column_mapping?: Record<string, string>;
|
|
25
|
+
where_clause?: string;
|
|
26
|
+
truncate_target?: boolean;
|
|
27
|
+
batch_size?: number;
|
|
28
|
+
database?: string;
|
|
29
|
+
}): Promise<{
|
|
30
|
+
status: string;
|
|
31
|
+
data?: any;
|
|
32
|
+
error?: string;
|
|
33
|
+
queryLog?: string;
|
|
34
|
+
}>;
|
|
35
|
+
/**
|
|
36
|
+
* Move data from one table to another (copy + delete from source)
|
|
37
|
+
*/
|
|
38
|
+
moveTableData(params: {
|
|
39
|
+
source_table: string;
|
|
40
|
+
target_table: string;
|
|
41
|
+
column_mapping?: Record<string, string>;
|
|
42
|
+
where_clause?: string;
|
|
43
|
+
batch_size?: number;
|
|
44
|
+
database?: string;
|
|
45
|
+
}): Promise<{
|
|
46
|
+
status: string;
|
|
47
|
+
data?: any;
|
|
48
|
+
error?: string;
|
|
49
|
+
queryLog?: string;
|
|
50
|
+
}>;
|
|
51
|
+
/**
|
|
52
|
+
* Clone a table structure (with or without data)
|
|
53
|
+
*/
|
|
54
|
+
cloneTable(params: {
|
|
55
|
+
source_table: string;
|
|
56
|
+
new_table_name: string;
|
|
57
|
+
include_data?: boolean;
|
|
58
|
+
include_indexes?: boolean;
|
|
59
|
+
database?: string;
|
|
60
|
+
}): Promise<{
|
|
61
|
+
status: string;
|
|
62
|
+
data?: any;
|
|
63
|
+
error?: string;
|
|
64
|
+
queryLog?: string;
|
|
65
|
+
}>;
|
|
66
|
+
/**
|
|
67
|
+
* Compare structure of two tables
|
|
68
|
+
*/
|
|
69
|
+
compareTableStructure(params: {
|
|
70
|
+
table1: string;
|
|
71
|
+
table2: string;
|
|
72
|
+
database?: string;
|
|
73
|
+
}): Promise<{
|
|
74
|
+
status: string;
|
|
75
|
+
data?: any;
|
|
76
|
+
error?: string;
|
|
77
|
+
queryLog?: string;
|
|
78
|
+
}>;
|
|
79
|
+
/**
|
|
80
|
+
* Sync data between two tables based on a key column
|
|
81
|
+
*/
|
|
82
|
+
syncTableData(params: {
|
|
83
|
+
source_table: string;
|
|
84
|
+
target_table: string;
|
|
85
|
+
key_column: string;
|
|
86
|
+
columns_to_sync?: string[];
|
|
87
|
+
sync_mode?: "insert_only" | "update_only" | "upsert";
|
|
88
|
+
batch_size?: number;
|
|
89
|
+
database?: string;
|
|
90
|
+
}): Promise<{
|
|
91
|
+
status: string;
|
|
92
|
+
data?: any;
|
|
93
|
+
error?: string;
|
|
94
|
+
queryLog?: string;
|
|
95
|
+
}>;
|
|
96
|
+
}
|
|
@@ -0,0 +1,546 @@
|
|
|
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.MigrationTools = void 0;
|
|
7
|
+
const connection_1 = __importDefault(require("../db/connection"));
|
|
8
|
+
const config_1 = require("../config/config");
|
|
9
|
+
/**
|
|
10
|
+
* Data Migration Tools for MySQL MCP Server
|
|
11
|
+
* Provides utilities for copying, moving, and transforming data between tables
|
|
12
|
+
*/
|
|
13
|
+
class MigrationTools {
|
|
14
|
+
constructor(security) {
|
|
15
|
+
this.db = connection_1.default.getInstance();
|
|
16
|
+
this.security = security;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Validate database access
|
|
20
|
+
*/
|
|
21
|
+
validateDatabaseAccess(requestedDatabase) {
|
|
22
|
+
const connectedDatabase = config_1.dbConfig.database;
|
|
23
|
+
if (!connectedDatabase) {
|
|
24
|
+
return {
|
|
25
|
+
valid: false,
|
|
26
|
+
database: "",
|
|
27
|
+
error: "No database configured. Please specify a database in your connection settings.",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (requestedDatabase && requestedDatabase !== connectedDatabase) {
|
|
31
|
+
return {
|
|
32
|
+
valid: false,
|
|
33
|
+
database: "",
|
|
34
|
+
error: `Access denied: You are connected to '${connectedDatabase}' but requested '${requestedDatabase}'. Cross-database access is not permitted.`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
valid: true,
|
|
39
|
+
database: connectedDatabase,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Escape string value for SQL
|
|
44
|
+
*/
|
|
45
|
+
escapeValue(value) {
|
|
46
|
+
if (value === null)
|
|
47
|
+
return "NULL";
|
|
48
|
+
if (typeof value === "number")
|
|
49
|
+
return String(value);
|
|
50
|
+
if (typeof value === "boolean")
|
|
51
|
+
return value ? "1" : "0";
|
|
52
|
+
if (value instanceof Date) {
|
|
53
|
+
return `'${value.toISOString().slice(0, 19).replace("T", " ")}'`;
|
|
54
|
+
}
|
|
55
|
+
if (Buffer.isBuffer(value)) {
|
|
56
|
+
return `X'${value.toString("hex")}'`;
|
|
57
|
+
}
|
|
58
|
+
// Escape string
|
|
59
|
+
const escaped = String(value)
|
|
60
|
+
.replace(/\\/g, "\\\\")
|
|
61
|
+
.replace(/'/g, "\\'")
|
|
62
|
+
.replace(/"/g, '\\"')
|
|
63
|
+
.replace(/\n/g, "\\n")
|
|
64
|
+
.replace(/\r/g, "\\r")
|
|
65
|
+
.replace(/\t/g, "\\t")
|
|
66
|
+
.replace(/\0/g, "\\0");
|
|
67
|
+
return `'${escaped}'`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Copy data from one table to another within the same database
|
|
71
|
+
*/
|
|
72
|
+
async copyTableData(params) {
|
|
73
|
+
try {
|
|
74
|
+
const { source_table, target_table, column_mapping, where_clause, truncate_target = false, batch_size = 1000, database, } = params;
|
|
75
|
+
// Validate database access
|
|
76
|
+
const dbValidation = this.validateDatabaseAccess(database);
|
|
77
|
+
if (!dbValidation.valid) {
|
|
78
|
+
return { status: "error", error: dbValidation.error };
|
|
79
|
+
}
|
|
80
|
+
// Validate table names
|
|
81
|
+
const sourceValidation = this.security.validateIdentifier(source_table);
|
|
82
|
+
if (!sourceValidation.valid) {
|
|
83
|
+
return { status: "error", error: `Invalid source table name: ${sourceValidation.error}` };
|
|
84
|
+
}
|
|
85
|
+
const targetValidation = this.security.validateIdentifier(target_table);
|
|
86
|
+
if (!targetValidation.valid) {
|
|
87
|
+
return { status: "error", error: `Invalid target table name: ${targetValidation.error}` };
|
|
88
|
+
}
|
|
89
|
+
const escapedSource = this.security.escapeIdentifier(source_table);
|
|
90
|
+
const escapedTarget = this.security.escapeIdentifier(target_table);
|
|
91
|
+
let queryCount = 0;
|
|
92
|
+
// Truncate target if requested
|
|
93
|
+
if (truncate_target) {
|
|
94
|
+
await this.db.query(`TRUNCATE TABLE ${escapedTarget}`);
|
|
95
|
+
queryCount++;
|
|
96
|
+
}
|
|
97
|
+
// Get source columns
|
|
98
|
+
const sourceColumnsQuery = `SHOW COLUMNS FROM ${escapedSource}`;
|
|
99
|
+
const sourceColumns = await this.db.query(sourceColumnsQuery);
|
|
100
|
+
queryCount++;
|
|
101
|
+
const sourceColumnNames = sourceColumns.map((col) => col.Field);
|
|
102
|
+
// Build column lists
|
|
103
|
+
let selectColumns;
|
|
104
|
+
let insertColumns;
|
|
105
|
+
if (column_mapping && Object.keys(column_mapping).length > 0) {
|
|
106
|
+
// Validate all column names in mapping
|
|
107
|
+
for (const [src, tgt] of Object.entries(column_mapping)) {
|
|
108
|
+
const srcValidation = this.security.validateIdentifier(src);
|
|
109
|
+
if (!srcValidation.valid) {
|
|
110
|
+
return { status: "error", error: `Invalid source column: ${src}` };
|
|
111
|
+
}
|
|
112
|
+
const tgtValidation = this.security.validateIdentifier(tgt);
|
|
113
|
+
if (!tgtValidation.valid) {
|
|
114
|
+
return { status: "error", error: `Invalid target column: ${tgt}` };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
selectColumns = Object.keys(column_mapping).map((c) => this.security.escapeIdentifier(c));
|
|
118
|
+
insertColumns = Object.values(column_mapping).map((c) => this.security.escapeIdentifier(c));
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
// Use all source columns
|
|
122
|
+
selectColumns = sourceColumnNames.map((c) => this.security.escapeIdentifier(c));
|
|
123
|
+
insertColumns = selectColumns;
|
|
124
|
+
}
|
|
125
|
+
// Count source rows
|
|
126
|
+
let countQuery = `SELECT COUNT(*) as cnt FROM ${escapedSource}`;
|
|
127
|
+
if (where_clause) {
|
|
128
|
+
countQuery += ` WHERE ${where_clause}`;
|
|
129
|
+
}
|
|
130
|
+
const countResult = await this.db.query(countQuery);
|
|
131
|
+
queryCount++;
|
|
132
|
+
const totalRows = countResult[0].cnt;
|
|
133
|
+
if (totalRows === 0) {
|
|
134
|
+
return {
|
|
135
|
+
status: "success",
|
|
136
|
+
data: {
|
|
137
|
+
message: "No rows to copy",
|
|
138
|
+
rows_copied: 0,
|
|
139
|
+
source_table,
|
|
140
|
+
target_table,
|
|
141
|
+
},
|
|
142
|
+
queryLog: this.db.getFormattedQueryLogs(queryCount),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// Copy data in batches
|
|
146
|
+
let rowsCopied = 0;
|
|
147
|
+
let offset = 0;
|
|
148
|
+
while (offset < totalRows) {
|
|
149
|
+
let selectQuery = `SELECT ${selectColumns.join(", ")} FROM ${escapedSource}`;
|
|
150
|
+
if (where_clause) {
|
|
151
|
+
selectQuery += ` WHERE ${where_clause}`;
|
|
152
|
+
}
|
|
153
|
+
selectQuery += ` LIMIT ${batch_size} OFFSET ${offset}`;
|
|
154
|
+
const rows = await this.db.query(selectQuery);
|
|
155
|
+
queryCount++;
|
|
156
|
+
if (rows.length === 0)
|
|
157
|
+
break;
|
|
158
|
+
// Build INSERT statement
|
|
159
|
+
const values = rows
|
|
160
|
+
.map((row) => {
|
|
161
|
+
const rowValues = Object.values(row).map((val) => this.escapeValue(val));
|
|
162
|
+
return `(${rowValues.join(", ")})`;
|
|
163
|
+
})
|
|
164
|
+
.join(", ");
|
|
165
|
+
const insertQuery = `INSERT INTO ${escapedTarget} (${insertColumns.join(", ")}) VALUES ${values}`;
|
|
166
|
+
await this.db.query(insertQuery);
|
|
167
|
+
queryCount++;
|
|
168
|
+
rowsCopied += rows.length;
|
|
169
|
+
offset += batch_size;
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
status: "success",
|
|
173
|
+
data: {
|
|
174
|
+
message: `Successfully copied ${rowsCopied} rows`,
|
|
175
|
+
rows_copied: rowsCopied,
|
|
176
|
+
source_table,
|
|
177
|
+
target_table,
|
|
178
|
+
truncated_target: truncate_target,
|
|
179
|
+
},
|
|
180
|
+
queryLog: this.db.getFormattedQueryLogs(queryCount),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
return {
|
|
185
|
+
status: "error",
|
|
186
|
+
error: error.message,
|
|
187
|
+
queryLog: this.db.getFormattedQueryLogs(1),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Move data from one table to another (copy + delete from source)
|
|
193
|
+
*/
|
|
194
|
+
async moveTableData(params) {
|
|
195
|
+
try {
|
|
196
|
+
const { source_table, target_table, column_mapping, where_clause, batch_size = 1000, database, } = params;
|
|
197
|
+
// Validate database access
|
|
198
|
+
const dbValidation = this.validateDatabaseAccess(database);
|
|
199
|
+
if (!dbValidation.valid) {
|
|
200
|
+
return { status: "error", error: dbValidation.error };
|
|
201
|
+
}
|
|
202
|
+
// Validate table names
|
|
203
|
+
const sourceValidation = this.security.validateIdentifier(source_table);
|
|
204
|
+
if (!sourceValidation.valid) {
|
|
205
|
+
return { status: "error", error: `Invalid source table name: ${sourceValidation.error}` };
|
|
206
|
+
}
|
|
207
|
+
const targetValidation = this.security.validateIdentifier(target_table);
|
|
208
|
+
if (!targetValidation.valid) {
|
|
209
|
+
return { status: "error", error: `Invalid target table name: ${targetValidation.error}` };
|
|
210
|
+
}
|
|
211
|
+
// First, copy the data
|
|
212
|
+
const copyResult = await this.copyTableData({
|
|
213
|
+
source_table,
|
|
214
|
+
target_table,
|
|
215
|
+
column_mapping,
|
|
216
|
+
where_clause,
|
|
217
|
+
truncate_target: false,
|
|
218
|
+
batch_size,
|
|
219
|
+
database,
|
|
220
|
+
});
|
|
221
|
+
if (copyResult.status === "error") {
|
|
222
|
+
return copyResult;
|
|
223
|
+
}
|
|
224
|
+
const rowsCopied = copyResult.data?.rows_copied || 0;
|
|
225
|
+
// Then delete from source
|
|
226
|
+
const escapedSource = this.security.escapeIdentifier(source_table);
|
|
227
|
+
let deleteQuery = `DELETE FROM ${escapedSource}`;
|
|
228
|
+
if (where_clause) {
|
|
229
|
+
deleteQuery += ` WHERE ${where_clause}`;
|
|
230
|
+
}
|
|
231
|
+
await this.db.query(deleteQuery);
|
|
232
|
+
return {
|
|
233
|
+
status: "success",
|
|
234
|
+
data: {
|
|
235
|
+
message: `Successfully moved ${rowsCopied} rows`,
|
|
236
|
+
rows_moved: rowsCopied,
|
|
237
|
+
source_table,
|
|
238
|
+
target_table,
|
|
239
|
+
rows_deleted_from_source: rowsCopied,
|
|
240
|
+
},
|
|
241
|
+
queryLog: this.db.getFormattedQueryLogs(1),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
return {
|
|
246
|
+
status: "error",
|
|
247
|
+
error: error.message,
|
|
248
|
+
queryLog: this.db.getFormattedQueryLogs(1),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Clone a table structure (with or without data)
|
|
254
|
+
*/
|
|
255
|
+
async cloneTable(params) {
|
|
256
|
+
try {
|
|
257
|
+
const { source_table, new_table_name, include_data = false, include_indexes = true, database, } = params;
|
|
258
|
+
// Validate database access
|
|
259
|
+
const dbValidation = this.validateDatabaseAccess(database);
|
|
260
|
+
if (!dbValidation.valid) {
|
|
261
|
+
return { status: "error", error: dbValidation.error };
|
|
262
|
+
}
|
|
263
|
+
// Validate table names
|
|
264
|
+
const sourceValidation = this.security.validateIdentifier(source_table);
|
|
265
|
+
if (!sourceValidation.valid) {
|
|
266
|
+
return { status: "error", error: `Invalid source table name: ${sourceValidation.error}` };
|
|
267
|
+
}
|
|
268
|
+
const newValidation = this.security.validateIdentifier(new_table_name);
|
|
269
|
+
if (!newValidation.valid) {
|
|
270
|
+
return { status: "error", error: `Invalid new table name: ${newValidation.error}` };
|
|
271
|
+
}
|
|
272
|
+
const escapedSource = this.security.escapeIdentifier(source_table);
|
|
273
|
+
const escapedNew = this.security.escapeIdentifier(new_table_name);
|
|
274
|
+
let queryCount = 0;
|
|
275
|
+
if (include_indexes) {
|
|
276
|
+
// Use CREATE TABLE ... LIKE to preserve indexes
|
|
277
|
+
const createQuery = `CREATE TABLE ${escapedNew} LIKE ${escapedSource}`;
|
|
278
|
+
await this.db.query(createQuery);
|
|
279
|
+
queryCount++;
|
|
280
|
+
if (include_data) {
|
|
281
|
+
const insertQuery = `INSERT INTO ${escapedNew} SELECT * FROM ${escapedSource}`;
|
|
282
|
+
await this.db.query(insertQuery);
|
|
283
|
+
queryCount++;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
// Create table without indexes using CREATE TABLE ... AS SELECT
|
|
288
|
+
if (include_data) {
|
|
289
|
+
const createQuery = `CREATE TABLE ${escapedNew} AS SELECT * FROM ${escapedSource}`;
|
|
290
|
+
await this.db.query(createQuery);
|
|
291
|
+
queryCount++;
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
const createQuery = `CREATE TABLE ${escapedNew} AS SELECT * FROM ${escapedSource} WHERE 1=0`;
|
|
295
|
+
await this.db.query(createQuery);
|
|
296
|
+
queryCount++;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Get row count if data was included
|
|
300
|
+
let rowCount = 0;
|
|
301
|
+
if (include_data) {
|
|
302
|
+
const countResult = await this.db.query(`SELECT COUNT(*) as cnt FROM ${escapedNew}`);
|
|
303
|
+
queryCount++;
|
|
304
|
+
rowCount = countResult[0].cnt;
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
status: "success",
|
|
308
|
+
data: {
|
|
309
|
+
message: `Successfully cloned table '${source_table}' to '${new_table_name}'`,
|
|
310
|
+
source_table,
|
|
311
|
+
new_table_name,
|
|
312
|
+
include_data,
|
|
313
|
+
include_indexes,
|
|
314
|
+
rows_copied: rowCount,
|
|
315
|
+
},
|
|
316
|
+
queryLog: this.db.getFormattedQueryLogs(queryCount),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
return {
|
|
321
|
+
status: "error",
|
|
322
|
+
error: error.message,
|
|
323
|
+
queryLog: this.db.getFormattedQueryLogs(1),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Compare structure of two tables
|
|
329
|
+
*/
|
|
330
|
+
async compareTableStructure(params) {
|
|
331
|
+
try {
|
|
332
|
+
const { table1, table2, database } = params;
|
|
333
|
+
// Validate database access
|
|
334
|
+
const dbValidation = this.validateDatabaseAccess(database);
|
|
335
|
+
if (!dbValidation.valid) {
|
|
336
|
+
return { status: "error", error: dbValidation.error };
|
|
337
|
+
}
|
|
338
|
+
// Validate table names
|
|
339
|
+
const table1Validation = this.security.validateIdentifier(table1);
|
|
340
|
+
if (!table1Validation.valid) {
|
|
341
|
+
return { status: "error", error: `Invalid table1 name: ${table1Validation.error}` };
|
|
342
|
+
}
|
|
343
|
+
const table2Validation = this.security.validateIdentifier(table2);
|
|
344
|
+
if (!table2Validation.valid) {
|
|
345
|
+
return { status: "error", error: `Invalid table2 name: ${table2Validation.error}` };
|
|
346
|
+
}
|
|
347
|
+
const escapedTable1 = this.security.escapeIdentifier(table1);
|
|
348
|
+
const escapedTable2 = this.security.escapeIdentifier(table2);
|
|
349
|
+
let queryCount = 0;
|
|
350
|
+
// Get columns for both tables
|
|
351
|
+
const cols1 = await this.db.query(`SHOW COLUMNS FROM ${escapedTable1}`);
|
|
352
|
+
queryCount++;
|
|
353
|
+
const cols2 = await this.db.query(`SHOW COLUMNS FROM ${escapedTable2}`);
|
|
354
|
+
queryCount++;
|
|
355
|
+
const columns1 = new Map(cols1.map((c) => [c.Field, c]));
|
|
356
|
+
const columns2 = new Map(cols2.map((c) => [c.Field, c]));
|
|
357
|
+
const onlyInTable1 = [];
|
|
358
|
+
const onlyInTable2 = [];
|
|
359
|
+
const different = [];
|
|
360
|
+
const identical = [];
|
|
361
|
+
// Check columns in table1
|
|
362
|
+
for (const [name, col1] of columns1) {
|
|
363
|
+
const col2 = columns2.get(name);
|
|
364
|
+
if (!col2) {
|
|
365
|
+
onlyInTable1.push(name);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
// Compare column properties
|
|
369
|
+
if (col1.Type !== col2.Type ||
|
|
370
|
+
col1.Null !== col2.Null ||
|
|
371
|
+
col1.Key !== col2.Key ||
|
|
372
|
+
col1.Default !== col2.Default) {
|
|
373
|
+
different.push({
|
|
374
|
+
column: name,
|
|
375
|
+
table1: { type: col1.Type, nullable: col1.Null, key: col1.Key, default: col1.Default },
|
|
376
|
+
table2: { type: col2.Type, nullable: col2.Null, key: col2.Key, default: col2.Default },
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
identical.push(name);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Check columns only in table2
|
|
385
|
+
for (const name of columns2.keys()) {
|
|
386
|
+
if (!columns1.has(name)) {
|
|
387
|
+
onlyInTable2.push(name);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const isIdentical = onlyInTable1.length === 0 &&
|
|
391
|
+
onlyInTable2.length === 0 &&
|
|
392
|
+
different.length === 0;
|
|
393
|
+
return {
|
|
394
|
+
status: "success",
|
|
395
|
+
data: {
|
|
396
|
+
table1,
|
|
397
|
+
table2,
|
|
398
|
+
is_identical: isIdentical,
|
|
399
|
+
columns_only_in_table1: onlyInTable1,
|
|
400
|
+
columns_only_in_table2: onlyInTable2,
|
|
401
|
+
columns_with_differences: different,
|
|
402
|
+
identical_columns: identical,
|
|
403
|
+
summary: {
|
|
404
|
+
table1_column_count: columns1.size,
|
|
405
|
+
table2_column_count: columns2.size,
|
|
406
|
+
identical_count: identical.length,
|
|
407
|
+
different_count: different.length,
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
queryLog: this.db.getFormattedQueryLogs(queryCount),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
return {
|
|
415
|
+
status: "error",
|
|
416
|
+
error: error.message,
|
|
417
|
+
queryLog: this.db.getFormattedQueryLogs(1),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Sync data between two tables based on a key column
|
|
423
|
+
*/
|
|
424
|
+
async syncTableData(params) {
|
|
425
|
+
try {
|
|
426
|
+
const { source_table, target_table, key_column, columns_to_sync, sync_mode = "upsert", batch_size = 1000, database, } = params;
|
|
427
|
+
// Validate database access
|
|
428
|
+
const dbValidation = this.validateDatabaseAccess(database);
|
|
429
|
+
if (!dbValidation.valid) {
|
|
430
|
+
return { status: "error", error: dbValidation.error };
|
|
431
|
+
}
|
|
432
|
+
// Validate identifiers
|
|
433
|
+
const sourceValidation = this.security.validateIdentifier(source_table);
|
|
434
|
+
if (!sourceValidation.valid) {
|
|
435
|
+
return { status: "error", error: `Invalid source table: ${sourceValidation.error}` };
|
|
436
|
+
}
|
|
437
|
+
const targetValidation = this.security.validateIdentifier(target_table);
|
|
438
|
+
if (!targetValidation.valid) {
|
|
439
|
+
return { status: "error", error: `Invalid target table: ${targetValidation.error}` };
|
|
440
|
+
}
|
|
441
|
+
const keyValidation = this.security.validateIdentifier(key_column);
|
|
442
|
+
if (!keyValidation.valid) {
|
|
443
|
+
return { status: "error", error: `Invalid key column: ${keyValidation.error}` };
|
|
444
|
+
}
|
|
445
|
+
const escapedSource = this.security.escapeIdentifier(source_table);
|
|
446
|
+
const escapedTarget = this.security.escapeIdentifier(target_table);
|
|
447
|
+
const escapedKey = this.security.escapeIdentifier(key_column);
|
|
448
|
+
let queryCount = 0;
|
|
449
|
+
// Get columns to sync
|
|
450
|
+
let columnsToUse;
|
|
451
|
+
if (columns_to_sync && columns_to_sync.length > 0) {
|
|
452
|
+
for (const col of columns_to_sync) {
|
|
453
|
+
const colValidation = this.security.validateIdentifier(col);
|
|
454
|
+
if (!colValidation.valid) {
|
|
455
|
+
return { status: "error", error: `Invalid column: ${col}` };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
columnsToUse = columns_to_sync;
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
// Get all columns from source
|
|
462
|
+
const cols = await this.db.query(`SHOW COLUMNS FROM ${escapedSource}`);
|
|
463
|
+
queryCount++;
|
|
464
|
+
columnsToUse = cols.map((c) => c.Field);
|
|
465
|
+
}
|
|
466
|
+
const escapedColumns = columnsToUse.map((c) => this.security.escapeIdentifier(c));
|
|
467
|
+
let insertedCount = 0;
|
|
468
|
+
let updatedCount = 0;
|
|
469
|
+
// Get source data
|
|
470
|
+
const sourceData = await this.db.query(`SELECT ${escapedColumns.join(", ")} FROM ${escapedSource}`);
|
|
471
|
+
queryCount++;
|
|
472
|
+
// Get existing keys in target
|
|
473
|
+
const targetKeys = await this.db.query(`SELECT ${escapedKey} FROM ${escapedTarget}`);
|
|
474
|
+
queryCount++;
|
|
475
|
+
const existingKeys = new Set(targetKeys.map((r) => r[key_column]));
|
|
476
|
+
// Process in batches
|
|
477
|
+
const rowsToInsert = [];
|
|
478
|
+
const rowsToUpdate = [];
|
|
479
|
+
for (const row of sourceData) {
|
|
480
|
+
const keyValue = row[key_column];
|
|
481
|
+
if (existingKeys.has(keyValue)) {
|
|
482
|
+
if (sync_mode === "update_only" || sync_mode === "upsert") {
|
|
483
|
+
rowsToUpdate.push(row);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
if (sync_mode === "insert_only" || sync_mode === "upsert") {
|
|
488
|
+
rowsToInsert.push(row);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// Insert new rows
|
|
493
|
+
if (rowsToInsert.length > 0) {
|
|
494
|
+
for (let i = 0; i < rowsToInsert.length; i += batch_size) {
|
|
495
|
+
const batch = rowsToInsert.slice(i, i + batch_size);
|
|
496
|
+
const values = batch
|
|
497
|
+
.map((row) => {
|
|
498
|
+
const rowValues = columnsToUse.map((col) => this.escapeValue(row[col]));
|
|
499
|
+
return `(${rowValues.join(", ")})`;
|
|
500
|
+
})
|
|
501
|
+
.join(", ");
|
|
502
|
+
const insertQuery = `INSERT INTO ${escapedTarget} (${escapedColumns.join(", ")}) VALUES ${values}`;
|
|
503
|
+
await this.db.query(insertQuery);
|
|
504
|
+
queryCount++;
|
|
505
|
+
insertedCount += batch.length;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// Update existing rows
|
|
509
|
+
if (rowsToUpdate.length > 0) {
|
|
510
|
+
for (const row of rowsToUpdate) {
|
|
511
|
+
const setClause = columnsToUse
|
|
512
|
+
.filter((col) => col !== key_column)
|
|
513
|
+
.map((col) => `${this.security.escapeIdentifier(col)} = ${this.escapeValue(row[col])}`)
|
|
514
|
+
.join(", ");
|
|
515
|
+
if (setClause) {
|
|
516
|
+
const updateQuery = `UPDATE ${escapedTarget} SET ${setClause} WHERE ${escapedKey} = ${this.escapeValue(row[key_column])}`;
|
|
517
|
+
await this.db.query(updateQuery);
|
|
518
|
+
queryCount++;
|
|
519
|
+
updatedCount++;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return {
|
|
524
|
+
status: "success",
|
|
525
|
+
data: {
|
|
526
|
+
message: `Sync completed: ${insertedCount} inserted, ${updatedCount} updated`,
|
|
527
|
+
source_table,
|
|
528
|
+
target_table,
|
|
529
|
+
sync_mode,
|
|
530
|
+
rows_inserted: insertedCount,
|
|
531
|
+
rows_updated: updatedCount,
|
|
532
|
+
total_source_rows: sourceData.length,
|
|
533
|
+
},
|
|
534
|
+
queryLog: this.db.getFormattedQueryLogs(queryCount),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
return {
|
|
539
|
+
status: "error",
|
|
540
|
+
error: error.message,
|
|
541
|
+
queryLog: this.db.getFormattedQueryLogs(1),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
exports.MigrationTools = MigrationTools;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@berthojoris/mcp-mysql-server",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Model Context Protocol server for MySQL database integration with dynamic per-project permissions
|
|
3
|
+
"version": "1.8.0",
|
|
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",
|
|
7
7
|
"bin": {
|