mcp-migration-advisor 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/LICENSE +21 -0
- package/README.md +158 -0
- package/build/analyzers/conflicts.js +167 -0
- package/build/analyzers/data-loss.js +131 -0
- package/build/analyzers/lock-risk.js +174 -0
- package/build/generators/rollback.js +197 -0
- package/build/index.js +285 -0
- package/build/license.js +115 -0
- package/build/parsers/flyway-sql.js +189 -0
- package/build/parsers/liquibase-xml.js +263 -0
- package/build/parsers/liquibase-yaml.js +381 -0
- package/package.json +57 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Liquibase YAML changelog parser.
|
|
3
|
+
*
|
|
4
|
+
* Parses Liquibase YAML changelogs and extracts DDL operations
|
|
5
|
+
* compatible with the same DDLStatement interface used by Flyway and XML.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Parse a Liquibase YAML changelog and extract DDL statements.
|
|
9
|
+
*
|
|
10
|
+
* Supports: createTable, dropTable, addColumn, dropColumn, modifyDataType,
|
|
11
|
+
* addNotNullConstraint, createIndex, dropIndex, addForeignKeyConstraint,
|
|
12
|
+
* dropForeignKeyConstraint, renameTable, renameColumn, sql (raw).
|
|
13
|
+
*/
|
|
14
|
+
export function parseLiquibaseYaml(yaml) {
|
|
15
|
+
const changeSets = extractChangeSets(yaml);
|
|
16
|
+
const allStatements = [];
|
|
17
|
+
for (const cs of changeSets) {
|
|
18
|
+
allStatements.push(...cs.statements);
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
version: changeSets.length > 0 ? changeSets[0].id : null,
|
|
22
|
+
description: `Liquibase changelog (${changeSets.length} changeSets)`,
|
|
23
|
+
filename: "changelog.yaml",
|
|
24
|
+
isRepeatable: false,
|
|
25
|
+
statements: allStatements,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function extractChangeSets(yaml) {
|
|
29
|
+
const results = [];
|
|
30
|
+
// Split into lines for indentation-based parsing
|
|
31
|
+
const lines = yaml.split("\n");
|
|
32
|
+
// Find changeSet blocks by scanning for "- changeSet:" pattern
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < lines.length) {
|
|
35
|
+
const line = lines[i];
|
|
36
|
+
const csMatch = line.match(/^(\s*)-\s*changeSet\s*:/);
|
|
37
|
+
if (!csMatch) {
|
|
38
|
+
i++;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const baseIndent = csMatch[1].length;
|
|
42
|
+
// Collect all lines belonging to this changeSet
|
|
43
|
+
const csLines = [];
|
|
44
|
+
i++;
|
|
45
|
+
while (i < lines.length) {
|
|
46
|
+
const nextLine = lines[i];
|
|
47
|
+
// Empty lines are part of the block
|
|
48
|
+
if (nextLine.trim() === "") {
|
|
49
|
+
csLines.push(nextLine);
|
|
50
|
+
i++;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
// Check if we've exited the changeSet block
|
|
54
|
+
const nextIndent = nextLine.search(/\S/);
|
|
55
|
+
if (nextIndent <= baseIndent && nextLine.trim().startsWith("-"))
|
|
56
|
+
break;
|
|
57
|
+
if (nextIndent <= baseIndent && !nextLine.trim().startsWith("-"))
|
|
58
|
+
break;
|
|
59
|
+
csLines.push(nextLine);
|
|
60
|
+
i++;
|
|
61
|
+
}
|
|
62
|
+
const csBody = csLines.join("\n");
|
|
63
|
+
const id = extractValue(csBody, "id") || "unknown";
|
|
64
|
+
const author = extractValue(csBody, "author") || "unknown";
|
|
65
|
+
const statements = parseChanges(csBody);
|
|
66
|
+
results.push({ id, author, statements });
|
|
67
|
+
}
|
|
68
|
+
return results;
|
|
69
|
+
}
|
|
70
|
+
function extractValue(text, key) {
|
|
71
|
+
// Match key: value or key: "value" (with optional quotes)
|
|
72
|
+
const re = new RegExp(`\\b${key}\\s*:\\s*["']?([^"'\\n]+?)["']?\\s*$`, "m");
|
|
73
|
+
const m = text.match(re);
|
|
74
|
+
return m ? m[1].trim() : null;
|
|
75
|
+
}
|
|
76
|
+
function parseChanges(csBody) {
|
|
77
|
+
const statements = [];
|
|
78
|
+
// Find the "changes:" block
|
|
79
|
+
const changesIdx = csBody.indexOf("changes:");
|
|
80
|
+
if (changesIdx === -1)
|
|
81
|
+
return statements;
|
|
82
|
+
const changesBody = csBody.substring(changesIdx);
|
|
83
|
+
// Parse each change type
|
|
84
|
+
parseCreateTable(changesBody, statements);
|
|
85
|
+
parseDropTable(changesBody, statements);
|
|
86
|
+
parseAddColumn(changesBody, statements);
|
|
87
|
+
parseDropColumn(changesBody, statements);
|
|
88
|
+
parseModifyDataType(changesBody, statements);
|
|
89
|
+
parseAddNotNullConstraint(changesBody, statements);
|
|
90
|
+
parseCreateIndex(changesBody, statements);
|
|
91
|
+
parseDropIndex(changesBody, statements);
|
|
92
|
+
parseAddForeignKeyConstraint(changesBody, statements);
|
|
93
|
+
parseDropForeignKeyConstraint(changesBody, statements);
|
|
94
|
+
parseRenameTable(changesBody, statements);
|
|
95
|
+
parseRenameColumn(changesBody, statements);
|
|
96
|
+
parseRawSql(changesBody, statements);
|
|
97
|
+
return statements;
|
|
98
|
+
}
|
|
99
|
+
function extractChangeBlock(body, changeName) {
|
|
100
|
+
const blocks = [];
|
|
101
|
+
const re = new RegExp(`-\\s*${changeName}\\s*:`, "g");
|
|
102
|
+
let m;
|
|
103
|
+
while ((m = re.exec(body)) !== null) {
|
|
104
|
+
const startIdx = m.index + m[0].length;
|
|
105
|
+
const blockIndent = body.substring(0, m.index).split("\n").pop().search(/\S/);
|
|
106
|
+
const remaining = body.substring(startIdx);
|
|
107
|
+
const lines = remaining.split("\n");
|
|
108
|
+
const blockLines = [];
|
|
109
|
+
for (let i = 0; i < lines.length; i++) {
|
|
110
|
+
const line = lines[i];
|
|
111
|
+
if (line.trim() === "") {
|
|
112
|
+
blockLines.push(line);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const indent = line.search(/\S/);
|
|
116
|
+
// If we hit something at same or lower indent that starts a new change, stop
|
|
117
|
+
if (i > 0 && indent <= blockIndent + 2 && line.trim().startsWith("-"))
|
|
118
|
+
break;
|
|
119
|
+
if (i > 0 && indent <= blockIndent)
|
|
120
|
+
break;
|
|
121
|
+
blockLines.push(line);
|
|
122
|
+
}
|
|
123
|
+
blocks.push(blockLines.join("\n"));
|
|
124
|
+
}
|
|
125
|
+
return blocks;
|
|
126
|
+
}
|
|
127
|
+
function parseCreateTable(body, statements) {
|
|
128
|
+
for (const block of extractChangeBlock(body, "createTable")) {
|
|
129
|
+
const tableName = extractValue(block, "tableName") || "unknown";
|
|
130
|
+
const columns = extractColumnNames(block);
|
|
131
|
+
const details = {};
|
|
132
|
+
if (columns.length > 0)
|
|
133
|
+
details.columns = columns.join(", ");
|
|
134
|
+
statements.push({
|
|
135
|
+
type: "CREATE_TABLE",
|
|
136
|
+
raw: `createTable: ${tableName}`,
|
|
137
|
+
tableName,
|
|
138
|
+
columnName: null,
|
|
139
|
+
details,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function parseDropTable(body, statements) {
|
|
144
|
+
for (const block of extractChangeBlock(body, "dropTable")) {
|
|
145
|
+
const tableName = extractValue(block, "tableName") || "unknown";
|
|
146
|
+
const details = {};
|
|
147
|
+
const cascade = extractValue(block, "cascadeConstraints");
|
|
148
|
+
if (cascade === "true")
|
|
149
|
+
details.cascade = "true";
|
|
150
|
+
statements.push({
|
|
151
|
+
type: "DROP_TABLE",
|
|
152
|
+
raw: `dropTable: ${tableName}`,
|
|
153
|
+
tableName,
|
|
154
|
+
columnName: null,
|
|
155
|
+
details,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function parseAddColumn(body, statements) {
|
|
160
|
+
for (const block of extractChangeBlock(body, "addColumn")) {
|
|
161
|
+
const tableName = extractValue(block, "tableName") || "unknown";
|
|
162
|
+
// Find column entries
|
|
163
|
+
const colNames = extractColumnEntries(block);
|
|
164
|
+
for (const col of colNames) {
|
|
165
|
+
const details = {};
|
|
166
|
+
if (col.type)
|
|
167
|
+
details.type = col.type;
|
|
168
|
+
if (col.nullable === "false")
|
|
169
|
+
details.notNull = "true";
|
|
170
|
+
if (col.defaultValue)
|
|
171
|
+
details.hasDefault = "true";
|
|
172
|
+
statements.push({
|
|
173
|
+
type: "ADD_COLUMN",
|
|
174
|
+
raw: `addColumn: ${tableName}.${col.name}`,
|
|
175
|
+
tableName,
|
|
176
|
+
columnName: col.name,
|
|
177
|
+
details,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function parseDropColumn(body, statements) {
|
|
183
|
+
for (const block of extractChangeBlock(body, "dropColumn")) {
|
|
184
|
+
const tableName = extractValue(block, "tableName") || "unknown";
|
|
185
|
+
const colName = extractValue(block, "columnName") || "unknown";
|
|
186
|
+
statements.push({
|
|
187
|
+
type: "DROP_COLUMN",
|
|
188
|
+
raw: `dropColumn: ${tableName}.${colName}`,
|
|
189
|
+
tableName,
|
|
190
|
+
columnName: colName,
|
|
191
|
+
details: {},
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function parseModifyDataType(body, statements) {
|
|
196
|
+
for (const block of extractChangeBlock(body, "modifyDataType")) {
|
|
197
|
+
const tableName = extractValue(block, "tableName") || "unknown";
|
|
198
|
+
const colName = extractValue(block, "columnName") || "unknown";
|
|
199
|
+
const newType = extractValue(block, "newDataType");
|
|
200
|
+
const details = { typeChange: "true" };
|
|
201
|
+
if (newType)
|
|
202
|
+
details.newType = newType;
|
|
203
|
+
statements.push({
|
|
204
|
+
type: "MODIFY_COLUMN",
|
|
205
|
+
raw: `modifyDataType: ${tableName}.${colName}`,
|
|
206
|
+
tableName,
|
|
207
|
+
columnName: colName,
|
|
208
|
+
details,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function parseAddNotNullConstraint(body, statements) {
|
|
213
|
+
for (const block of extractChangeBlock(body, "addNotNullConstraint")) {
|
|
214
|
+
const tableName = extractValue(block, "tableName") || "unknown";
|
|
215
|
+
const colName = extractValue(block, "columnName") || "unknown";
|
|
216
|
+
const details = { setNotNull: "true" };
|
|
217
|
+
const defaultVal = extractValue(block, "defaultNullValue");
|
|
218
|
+
if (defaultVal)
|
|
219
|
+
details.hasDefault = "true";
|
|
220
|
+
statements.push({
|
|
221
|
+
type: "MODIFY_COLUMN",
|
|
222
|
+
raw: `addNotNullConstraint: ${tableName}.${colName}`,
|
|
223
|
+
tableName,
|
|
224
|
+
columnName: colName,
|
|
225
|
+
details,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function parseCreateIndex(body, statements) {
|
|
230
|
+
for (const block of extractChangeBlock(body, "createIndex")) {
|
|
231
|
+
const tableName = extractValue(block, "tableName") || "unknown";
|
|
232
|
+
const details = {};
|
|
233
|
+
if (extractValue(block, "unique") === "true")
|
|
234
|
+
details.unique = "true";
|
|
235
|
+
statements.push({
|
|
236
|
+
type: "CREATE_INDEX",
|
|
237
|
+
raw: `createIndex: ${tableName}`,
|
|
238
|
+
tableName,
|
|
239
|
+
columnName: null,
|
|
240
|
+
details,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function parseDropIndex(body, statements) {
|
|
245
|
+
for (const block of extractChangeBlock(body, "dropIndex")) {
|
|
246
|
+
const tableName = extractValue(block, "tableName") || null;
|
|
247
|
+
statements.push({
|
|
248
|
+
type: "DROP_INDEX",
|
|
249
|
+
raw: `dropIndex`,
|
|
250
|
+
tableName,
|
|
251
|
+
columnName: null,
|
|
252
|
+
details: {},
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function parseAddForeignKeyConstraint(body, statements) {
|
|
257
|
+
for (const block of extractChangeBlock(body, "addForeignKeyConstraint")) {
|
|
258
|
+
const tableName = extractValue(block, "baseTableName") || "unknown";
|
|
259
|
+
const constraintName = extractValue(block, "constraintName") || "unknown";
|
|
260
|
+
statements.push({
|
|
261
|
+
type: "ADD_CONSTRAINT",
|
|
262
|
+
raw: `addForeignKeyConstraint: ${constraintName}`,
|
|
263
|
+
tableName,
|
|
264
|
+
columnName: null,
|
|
265
|
+
details: { constraintName, constraintType: "FOREIGN_KEY" },
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function parseDropForeignKeyConstraint(body, statements) {
|
|
270
|
+
for (const block of extractChangeBlock(body, "dropForeignKeyConstraint")) {
|
|
271
|
+
const tableName = extractValue(block, "baseTableName") || "unknown";
|
|
272
|
+
const constraintName = extractValue(block, "constraintName") || "unknown";
|
|
273
|
+
statements.push({
|
|
274
|
+
type: "DROP_CONSTRAINT",
|
|
275
|
+
raw: `dropForeignKeyConstraint: ${constraintName}`,
|
|
276
|
+
tableName,
|
|
277
|
+
columnName: null,
|
|
278
|
+
details: { constraintName },
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function parseRenameTable(body, statements) {
|
|
283
|
+
for (const block of extractChangeBlock(body, "renameTable")) {
|
|
284
|
+
const oldName = extractValue(block, "oldTableName");
|
|
285
|
+
const newName = extractValue(block, "newTableName");
|
|
286
|
+
statements.push({
|
|
287
|
+
type: "RENAME",
|
|
288
|
+
raw: `renameTable: ${oldName} -> ${newName}`,
|
|
289
|
+
tableName: oldName || "unknown",
|
|
290
|
+
columnName: null,
|
|
291
|
+
details: { newName: newName || "unknown" },
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function parseRenameColumn(body, statements) {
|
|
296
|
+
for (const block of extractChangeBlock(body, "renameColumn")) {
|
|
297
|
+
const tableName = extractValue(block, "tableName") || "unknown";
|
|
298
|
+
const oldName = extractValue(block, "oldColumnName");
|
|
299
|
+
const newName = extractValue(block, "newColumnName");
|
|
300
|
+
statements.push({
|
|
301
|
+
type: "RENAME",
|
|
302
|
+
raw: `renameColumn: ${tableName}.${oldName} -> ${newName}`,
|
|
303
|
+
tableName,
|
|
304
|
+
columnName: oldName || null,
|
|
305
|
+
details: { newName: newName || "unknown" },
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function parseRawSql(body, statements) {
|
|
310
|
+
for (const block of extractChangeBlock(body, "sql")) {
|
|
311
|
+
// The block content after "- sql:" is the raw SQL
|
|
312
|
+
const sql = block.trim();
|
|
313
|
+
if (sql) {
|
|
314
|
+
statements.push({
|
|
315
|
+
type: "OTHER",
|
|
316
|
+
raw: sql,
|
|
317
|
+
tableName: null,
|
|
318
|
+
columnName: null,
|
|
319
|
+
details: { source: "inline-sql" },
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function extractColumnEntries(block) {
|
|
325
|
+
const columns = [];
|
|
326
|
+
// Find "- column:" blocks
|
|
327
|
+
const colRe = /-\s*column\s*:/g;
|
|
328
|
+
let m;
|
|
329
|
+
const lines = block.split("\n");
|
|
330
|
+
let lineIdx = 0;
|
|
331
|
+
while (lineIdx < lines.length) {
|
|
332
|
+
const line = lines[lineIdx];
|
|
333
|
+
if (!line.match(/-\s*column\s*:/)) {
|
|
334
|
+
lineIdx++;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
// Collect column block
|
|
338
|
+
const colIndent = line.search(/\S/);
|
|
339
|
+
const colLines = [];
|
|
340
|
+
lineIdx++;
|
|
341
|
+
while (lineIdx < lines.length) {
|
|
342
|
+
const next = lines[lineIdx];
|
|
343
|
+
if (next.trim() === "") {
|
|
344
|
+
colLines.push(next);
|
|
345
|
+
lineIdx++;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const nextIndent = next.search(/\S/);
|
|
349
|
+
if (nextIndent <= colIndent)
|
|
350
|
+
break;
|
|
351
|
+
colLines.push(next);
|
|
352
|
+
lineIdx++;
|
|
353
|
+
}
|
|
354
|
+
const colBody = colLines.join("\n");
|
|
355
|
+
const name = extractValue(colBody, "name");
|
|
356
|
+
if (name) {
|
|
357
|
+
const entry = { name };
|
|
358
|
+
const type = extractValue(colBody, "type");
|
|
359
|
+
if (type)
|
|
360
|
+
entry.type = type;
|
|
361
|
+
// Check constraints block for nullable
|
|
362
|
+
if (colBody.includes("nullable:")) {
|
|
363
|
+
const nullable = extractValue(colBody, "nullable");
|
|
364
|
+
if (nullable)
|
|
365
|
+
entry.nullable = nullable;
|
|
366
|
+
}
|
|
367
|
+
// Check for default values
|
|
368
|
+
const defaultValue = extractValue(colBody, "defaultValue") ||
|
|
369
|
+
extractValue(colBody, "defaultValueNumeric") ||
|
|
370
|
+
extractValue(colBody, "defaultValueBoolean");
|
|
371
|
+
if (defaultValue)
|
|
372
|
+
entry.defaultValue = defaultValue;
|
|
373
|
+
columns.push(entry);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return columns;
|
|
377
|
+
}
|
|
378
|
+
function extractColumnNames(block) {
|
|
379
|
+
return extractColumnEntries(block).map((c) => c.name);
|
|
380
|
+
}
|
|
381
|
+
//# sourceMappingURL=liquibase-yaml.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-migration-advisor",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP server for database migration risk analysis — Flyway and Liquibase XML/YAML/SQL support with lock detection and conflict analysis",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mcp-migration-advisor": "build/index.js"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc && chmod 755 build/index.js",
|
|
12
|
+
"test": "vitest run",
|
|
13
|
+
"test:watch": "vitest",
|
|
14
|
+
"prepublishOnly": "npm run build && npm test"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"build/**/*.js",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"model-context-protocol",
|
|
24
|
+
"database",
|
|
25
|
+
"migration",
|
|
26
|
+
"flyway",
|
|
27
|
+
"liquibase",
|
|
28
|
+
"schema",
|
|
29
|
+
"risk-analysis",
|
|
30
|
+
"postgresql",
|
|
31
|
+
"mysql",
|
|
32
|
+
"ai",
|
|
33
|
+
"claude"
|
|
34
|
+
],
|
|
35
|
+
"author": "Dmytro Lisnichenko",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18.0.0"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "https://github.com/Dmitriusan/mcp-migration-advisor"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/Dmitriusan/mcp-migration-advisor#readme",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/Dmitriusan/mcp-migration-advisor/issues"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
50
|
+
"zod": "^3.24.2"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^22.0.0",
|
|
54
|
+
"typescript": "^5.8.2",
|
|
55
|
+
"vitest": "^4.0.18"
|
|
56
|
+
}
|
|
57
|
+
}
|