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,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration rollback generator.
|
|
3
|
+
*
|
|
4
|
+
* Given a forward migration, generates the reverse DDL to undo it.
|
|
5
|
+
* Handles:
|
|
6
|
+
* - CREATE TABLE → DROP TABLE
|
|
7
|
+
* - ADD COLUMN → DROP COLUMN (ALTER TABLE ... DROP COLUMN)
|
|
8
|
+
* - CREATE INDEX → DROP INDEX
|
|
9
|
+
* - DROP TABLE → warns (cannot reverse without backup)
|
|
10
|
+
* - DROP COLUMN → warns (data is lost)
|
|
11
|
+
* - ADD CONSTRAINT → DROP CONSTRAINT
|
|
12
|
+
* - MODIFY COLUMN → warns (original type unknown without schema)
|
|
13
|
+
* - RENAME → reverse RENAME
|
|
14
|
+
*/
|
|
15
|
+
export function generateRollback(migration) {
|
|
16
|
+
const rollbackStatements = [];
|
|
17
|
+
const warnings = [];
|
|
18
|
+
// Process in reverse order (last statement rolled back first)
|
|
19
|
+
for (let i = migration.statements.length - 1; i >= 0; i--) {
|
|
20
|
+
const stmt = migration.statements[i];
|
|
21
|
+
const result = generateRollbackStatement(stmt);
|
|
22
|
+
rollbackStatements.push(result);
|
|
23
|
+
if (result.warning) {
|
|
24
|
+
warnings.push(result.warning);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const fullyReversible = rollbackStatements.every(s => s.isReversible);
|
|
28
|
+
// Build the rollback SQL
|
|
29
|
+
const rollbackLines = [];
|
|
30
|
+
rollbackLines.push("-- Rollback migration: " + migration.filename);
|
|
31
|
+
if (migration.version) {
|
|
32
|
+
rollbackLines.push("-- Reverses version: " + migration.version);
|
|
33
|
+
}
|
|
34
|
+
rollbackLines.push("-- Generated by MCP Migration Advisor");
|
|
35
|
+
rollbackLines.push("");
|
|
36
|
+
if (!fullyReversible) {
|
|
37
|
+
rollbackLines.push("-- WARNING: This migration is NOT fully reversible.");
|
|
38
|
+
rollbackLines.push("-- Some operations require manual intervention (see warnings below).");
|
|
39
|
+
rollbackLines.push("");
|
|
40
|
+
}
|
|
41
|
+
for (const stmt of rollbackStatements) {
|
|
42
|
+
if (stmt.warning) {
|
|
43
|
+
rollbackLines.push(`-- WARNING: ${stmt.warning}`);
|
|
44
|
+
}
|
|
45
|
+
rollbackLines.push(stmt.rollback + ";");
|
|
46
|
+
rollbackLines.push("");
|
|
47
|
+
}
|
|
48
|
+
// Add Flyway schema_version cleanup
|
|
49
|
+
if (migration.version) {
|
|
50
|
+
rollbackLines.push("-- Remove migration record from Flyway history:");
|
|
51
|
+
rollbackLines.push(`DELETE FROM flyway_schema_history WHERE version = '${migration.version}';`);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
statements: rollbackStatements,
|
|
55
|
+
fullyReversible,
|
|
56
|
+
warnings,
|
|
57
|
+
rollbackSql: rollbackLines.join("\n"),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function generateRollbackStatement(stmt) {
|
|
61
|
+
switch (stmt.type) {
|
|
62
|
+
case "CREATE_TABLE":
|
|
63
|
+
return {
|
|
64
|
+
forward: stmt.raw,
|
|
65
|
+
rollback: `DROP TABLE IF EXISTS ${stmt.tableName}`,
|
|
66
|
+
isReversible: true,
|
|
67
|
+
warning: null,
|
|
68
|
+
};
|
|
69
|
+
case "DROP_TABLE":
|
|
70
|
+
return {
|
|
71
|
+
forward: stmt.raw,
|
|
72
|
+
rollback: `-- Cannot reverse DROP TABLE ${stmt.tableName} without schema backup`,
|
|
73
|
+
isReversible: false,
|
|
74
|
+
warning: `DROP TABLE ${stmt.tableName} is irreversible — table structure and data are lost. Restore from backup.`,
|
|
75
|
+
};
|
|
76
|
+
case "CREATE_INDEX": {
|
|
77
|
+
// Extract index name from the raw SQL
|
|
78
|
+
const indexMatch = stmt.raw.match(/INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(?:`|"|)?(\w+)/i);
|
|
79
|
+
const indexName = indexMatch ? indexMatch[1] : "unknown_index";
|
|
80
|
+
const concurrently = stmt.details.concurrently ? "CONCURRENTLY " : "";
|
|
81
|
+
return {
|
|
82
|
+
forward: stmt.raw,
|
|
83
|
+
rollback: `DROP INDEX ${concurrently}IF EXISTS ${indexName}`,
|
|
84
|
+
isReversible: true,
|
|
85
|
+
warning: null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
case "DROP_INDEX": {
|
|
89
|
+
return {
|
|
90
|
+
forward: stmt.raw,
|
|
91
|
+
rollback: `-- Cannot reverse DROP INDEX without original index definition`,
|
|
92
|
+
isReversible: false,
|
|
93
|
+
warning: "DROP INDEX is irreversible without the original CREATE INDEX statement.",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
case "ADD_COLUMN":
|
|
97
|
+
return {
|
|
98
|
+
forward: stmt.raw,
|
|
99
|
+
rollback: `ALTER TABLE ${stmt.tableName} DROP COLUMN IF EXISTS ${stmt.columnName}`,
|
|
100
|
+
isReversible: true,
|
|
101
|
+
warning: null,
|
|
102
|
+
};
|
|
103
|
+
case "DROP_COLUMN":
|
|
104
|
+
return {
|
|
105
|
+
forward: stmt.raw,
|
|
106
|
+
rollback: `-- Cannot reverse DROP COLUMN ${stmt.columnName} on ${stmt.tableName} — data is lost`,
|
|
107
|
+
isReversible: false,
|
|
108
|
+
warning: `DROP COLUMN ${stmt.tableName}.${stmt.columnName} is irreversible — column data is lost. Restore from backup.`,
|
|
109
|
+
};
|
|
110
|
+
case "MODIFY_COLUMN": {
|
|
111
|
+
if (stmt.details.setNotNull === "true") {
|
|
112
|
+
return {
|
|
113
|
+
forward: stmt.raw,
|
|
114
|
+
rollback: `ALTER TABLE ${stmt.tableName} ALTER COLUMN ${stmt.columnName} DROP NOT NULL`,
|
|
115
|
+
isReversible: true,
|
|
116
|
+
warning: null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (stmt.details.dropNotNull === "true") {
|
|
120
|
+
return {
|
|
121
|
+
forward: stmt.raw,
|
|
122
|
+
rollback: `ALTER TABLE ${stmt.tableName} ALTER COLUMN ${stmt.columnName} SET NOT NULL`,
|
|
123
|
+
isReversible: true,
|
|
124
|
+
warning: "Reversing DROP NOT NULL requires all existing values to be non-null.",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (stmt.details.typeChange === "true") {
|
|
128
|
+
return {
|
|
129
|
+
forward: stmt.raw,
|
|
130
|
+
rollback: `-- Cannot reverse type change on ${stmt.tableName}.${stmt.columnName} — original type unknown`,
|
|
131
|
+
isReversible: false,
|
|
132
|
+
warning: `Type change on ${stmt.tableName}.${stmt.columnName} requires knowing the original type. Check schema before migration.`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
forward: stmt.raw,
|
|
137
|
+
rollback: `-- Cannot reverse: ${stmt.raw.slice(0, 80)}`,
|
|
138
|
+
isReversible: false,
|
|
139
|
+
warning: `Column modification on ${stmt.tableName}.${stmt.columnName} may not be reversible.`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
case "ADD_CONSTRAINT":
|
|
143
|
+
return {
|
|
144
|
+
forward: stmt.raw,
|
|
145
|
+
rollback: `ALTER TABLE ${stmt.tableName} DROP CONSTRAINT IF EXISTS ${stmt.details.constraintName || "unknown_constraint"}`,
|
|
146
|
+
isReversible: true,
|
|
147
|
+
warning: null,
|
|
148
|
+
};
|
|
149
|
+
case "DROP_CONSTRAINT":
|
|
150
|
+
return {
|
|
151
|
+
forward: stmt.raw,
|
|
152
|
+
rollback: `-- Cannot reverse DROP CONSTRAINT ${stmt.details.constraintName} — original definition unknown`,
|
|
153
|
+
isReversible: false,
|
|
154
|
+
warning: `DROP CONSTRAINT ${stmt.details.constraintName} is irreversible without the original constraint definition.`,
|
|
155
|
+
};
|
|
156
|
+
case "RENAME": {
|
|
157
|
+
// Try to extract old and new names from the raw SQL
|
|
158
|
+
const renameMatch = stmt.raw.match(/RENAME\s+(?:TO|COLUMN\s+\w+\s+TO)\s+(?:`|"|)?(\w+)/i);
|
|
159
|
+
if (renameMatch && stmt.tableName) {
|
|
160
|
+
const newName = renameMatch[1];
|
|
161
|
+
if (stmt.raw.toUpperCase().includes("RENAME COLUMN")) {
|
|
162
|
+
const colMatch = stmt.raw.match(/RENAME\s+COLUMN\s+(?:`|"|)?(\w+)(?:`|")?\s+TO\s+(?:`|"|)?(\w+)/i);
|
|
163
|
+
if (colMatch) {
|
|
164
|
+
return {
|
|
165
|
+
forward: stmt.raw,
|
|
166
|
+
rollback: `ALTER TABLE ${stmt.tableName} RENAME COLUMN ${colMatch[2]} TO ${colMatch[1]}`,
|
|
167
|
+
isReversible: true,
|
|
168
|
+
warning: null,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
forward: stmt.raw,
|
|
174
|
+
rollback: `ALTER TABLE ${newName} RENAME TO ${stmt.tableName}`,
|
|
175
|
+
isReversible: true,
|
|
176
|
+
warning: null,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
forward: stmt.raw,
|
|
181
|
+
rollback: `-- Cannot parse RENAME for rollback: ${stmt.raw.slice(0, 80)}`,
|
|
182
|
+
isReversible: false,
|
|
183
|
+
warning: "RENAME could not be parsed for automatic rollback.",
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
case "ALTER_TABLE":
|
|
187
|
+
case "OTHER":
|
|
188
|
+
default:
|
|
189
|
+
return {
|
|
190
|
+
forward: stmt.raw,
|
|
191
|
+
rollback: `-- Manual rollback needed: ${stmt.raw.slice(0, 80)}`,
|
|
192
|
+
isReversible: false,
|
|
193
|
+
warning: `Statement requires manual rollback: ${stmt.raw.slice(0, 80)}`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=rollback.js.map
|
package/build/index.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* MCP Migration Advisor — MCP server for database migration risk analysis.
|
|
4
|
+
*
|
|
5
|
+
* Tools:
|
|
6
|
+
* analyze_migration — Parse and analyze a SQL migration for risks
|
|
7
|
+
* score_risk — Calculate risk score for a migration
|
|
8
|
+
*/
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { parseMigration } from "./parsers/flyway-sql.js";
|
|
13
|
+
import { parseLiquibaseXml } from "./parsers/liquibase-xml.js";
|
|
14
|
+
import { parseLiquibaseYaml } from "./parsers/liquibase-yaml.js";
|
|
15
|
+
import { analyzeLockRisks, calculateRiskScore } from "./analyzers/lock-risk.js";
|
|
16
|
+
import { analyzeDataLoss } from "./analyzers/data-loss.js";
|
|
17
|
+
import { generateRollback } from "./generators/rollback.js";
|
|
18
|
+
import { detectConflicts, formatConflictReport } from "./analyzers/conflicts.js";
|
|
19
|
+
import { validateLicense, formatUpgradePrompt } from "./license.js";
|
|
20
|
+
// License check (reads MCP_LICENSE_KEY env var once at startup)
|
|
21
|
+
const license = validateLicense(process.env.MCP_LICENSE_KEY, "migration-advisor");
|
|
22
|
+
// Handle --help
|
|
23
|
+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
24
|
+
console.log(`mcp-migration-advisor v0.1.0 — MCP server for database migration risk analysis
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
mcp-migration-advisor [options]
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
--help, -h Show this help message
|
|
31
|
+
|
|
32
|
+
Tools provided:
|
|
33
|
+
analyze_migration Parse a SQL migration file and detect risks
|
|
34
|
+
analyze_liquibase Parse a Liquibase XML changelog and detect risks
|
|
35
|
+
analyze_liquibase_yaml Parse a Liquibase YAML changelog and detect risks
|
|
36
|
+
score_risk Calculate overall risk score (0-100)
|
|
37
|
+
generate_rollback Generate reverse DDL to undo a migration
|
|
38
|
+
detect_conflicts Detect conflicts between two migration files`);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
const server = new McpServer({
|
|
42
|
+
name: "mcp-migration-advisor",
|
|
43
|
+
version: "0.1.0",
|
|
44
|
+
});
|
|
45
|
+
// Tool 1: analyze_migration
|
|
46
|
+
server.tool("analyze_migration", "Analyze a SQL migration file for lock risks, data loss potential, and unsafe patterns. Supports Flyway (V__*.sql) and plain SQL.", {
|
|
47
|
+
filename: z.string().describe("Migration filename (e.g., V2__add_user_email.sql)"),
|
|
48
|
+
sql: z.string().describe("The SQL content of the migration file"),
|
|
49
|
+
}, async ({ filename, sql }) => {
|
|
50
|
+
const migration = parseMigration(filename, sql);
|
|
51
|
+
const lockRisks = analyzeLockRisks(migration);
|
|
52
|
+
const dataLossIssues = analyzeDataLoss(migration);
|
|
53
|
+
const riskScore = calculateRiskScore(lockRisks);
|
|
54
|
+
let output = `## Migration Analysis: ${filename}\n\n`;
|
|
55
|
+
// Migration info
|
|
56
|
+
if (migration.version) {
|
|
57
|
+
output += `**Version**: ${migration.version}\n`;
|
|
58
|
+
}
|
|
59
|
+
output += `**Description**: ${migration.description}\n`;
|
|
60
|
+
output += `**Statements**: ${migration.statements.length}\n`;
|
|
61
|
+
output += `**Risk Score**: ${riskScore}/100${riskScore >= 60 ? " ⚠️ HIGH RISK" : riskScore >= 30 ? " ⚡ MODERATE RISK" : " ✅ LOW RISK"}\n\n`;
|
|
62
|
+
// Statement summary
|
|
63
|
+
output += "### Operations\n\n";
|
|
64
|
+
const typeCounts = {};
|
|
65
|
+
for (const stmt of migration.statements) {
|
|
66
|
+
typeCounts[stmt.type] = (typeCounts[stmt.type] || 0) + 1;
|
|
67
|
+
}
|
|
68
|
+
output += "| Operation | Count |\n|-----------|-------|\n";
|
|
69
|
+
for (const [type, count] of Object.entries(typeCounts)) {
|
|
70
|
+
output += `| ${type} | ${count} |\n`;
|
|
71
|
+
}
|
|
72
|
+
output += "\n";
|
|
73
|
+
// Lock risks
|
|
74
|
+
if (lockRisks.length > 0) {
|
|
75
|
+
output += "### Lock Risks\n\n";
|
|
76
|
+
for (const risk of lockRisks) {
|
|
77
|
+
output += `**${risk.severity}**: ${risk.risk}\n`;
|
|
78
|
+
output += `> \`${risk.statement}\`\n`;
|
|
79
|
+
output += `> **Recommendation**: ${risk.recommendation}\n\n`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
output += "### Lock Risks\n\nNo lock risks detected.\n\n";
|
|
84
|
+
}
|
|
85
|
+
// Data loss issues
|
|
86
|
+
if (dataLossIssues.length > 0) {
|
|
87
|
+
output += "### Data Loss Analysis\n\n";
|
|
88
|
+
for (const issue of dataLossIssues) {
|
|
89
|
+
output += `**${issue.risk}**: ${issue.description}\n`;
|
|
90
|
+
output += `> \`${issue.statement}\`\n`;
|
|
91
|
+
output += `> **Mitigation**: ${issue.mitigation}\n\n`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
output += "### Data Loss Analysis\n\nNo data loss risks detected.\n\n";
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
content: [{ type: "text", text: output }],
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
// Tool 2: analyze_liquibase
|
|
102
|
+
server.tool("analyze_liquibase", "Analyze a Liquibase XML changelog for lock risks, data loss potential, and unsafe patterns.", {
|
|
103
|
+
xml: z.string().describe("The Liquibase XML changelog content"),
|
|
104
|
+
}, async ({ xml }) => {
|
|
105
|
+
const migration = parseLiquibaseXml(xml);
|
|
106
|
+
const lockRisks = analyzeLockRisks(migration);
|
|
107
|
+
const dataLossIssues = analyzeDataLoss(migration);
|
|
108
|
+
const riskScore = calculateRiskScore(lockRisks);
|
|
109
|
+
let output = `## Liquibase Changelog Analysis\n\n`;
|
|
110
|
+
output += `**ChangeSets**: ${migration.description}\n`;
|
|
111
|
+
output += `**Statements**: ${migration.statements.length}\n`;
|
|
112
|
+
output += `**Risk Score**: ${riskScore}/100${riskScore >= 60 ? " HIGH RISK" : riskScore >= 30 ? " MODERATE RISK" : " LOW RISK"}\n\n`;
|
|
113
|
+
const typeCounts = {};
|
|
114
|
+
for (const stmt of migration.statements) {
|
|
115
|
+
typeCounts[stmt.type] = (typeCounts[stmt.type] || 0) + 1;
|
|
116
|
+
}
|
|
117
|
+
output += "### Operations\n\n| Operation | Count |\n|-----------|-------|\n";
|
|
118
|
+
for (const [type, count] of Object.entries(typeCounts)) {
|
|
119
|
+
output += `| ${type} | ${count} |\n`;
|
|
120
|
+
}
|
|
121
|
+
output += "\n";
|
|
122
|
+
if (lockRisks.length > 0) {
|
|
123
|
+
output += "### Lock Risks\n\n";
|
|
124
|
+
for (const risk of lockRisks) {
|
|
125
|
+
output += `**${risk.severity}**: ${risk.risk}\n> **Recommendation**: ${risk.recommendation}\n\n`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (dataLossIssues.length > 0) {
|
|
129
|
+
output += "### Data Loss Analysis\n\n";
|
|
130
|
+
for (const issue of dataLossIssues) {
|
|
131
|
+
output += `**${issue.risk}**: ${issue.description}\n> **Mitigation**: ${issue.mitigation}\n\n`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (lockRisks.length === 0 && dataLossIssues.length === 0) {
|
|
135
|
+
output += "### No risks detected.\n";
|
|
136
|
+
}
|
|
137
|
+
return { content: [{ type: "text", text: output }] };
|
|
138
|
+
});
|
|
139
|
+
// Tool 3: analyze_liquibase_yaml
|
|
140
|
+
server.tool("analyze_liquibase_yaml", "Analyze a Liquibase YAML changelog for lock risks, data loss potential, and unsafe patterns. Supports createTable, dropTable, addColumn, dropColumn, modifyDataType, createIndex, renameTable, renameColumn, and more.", {
|
|
141
|
+
yaml: z.string().describe("The Liquibase YAML changelog content"),
|
|
142
|
+
}, async ({ yaml }) => {
|
|
143
|
+
const migration = parseLiquibaseYaml(yaml);
|
|
144
|
+
const lockRisks = analyzeLockRisks(migration);
|
|
145
|
+
const dataLossIssues = analyzeDataLoss(migration);
|
|
146
|
+
const riskScore = calculateRiskScore(lockRisks);
|
|
147
|
+
let output = `## Liquibase YAML Changelog Analysis\n\n`;
|
|
148
|
+
output += `**ChangeSets**: ${migration.description}\n`;
|
|
149
|
+
output += `**Statements**: ${migration.statements.length}\n`;
|
|
150
|
+
output += `**Risk Score**: ${riskScore}/100${riskScore >= 60 ? " HIGH RISK" : riskScore >= 30 ? " MODERATE RISK" : " LOW RISK"}\n\n`;
|
|
151
|
+
const typeCounts = {};
|
|
152
|
+
for (const stmt of migration.statements) {
|
|
153
|
+
typeCounts[stmt.type] = (typeCounts[stmt.type] || 0) + 1;
|
|
154
|
+
}
|
|
155
|
+
output += "### Operations\n\n| Operation | Count |\n|-----------|-------|\n";
|
|
156
|
+
for (const [type, count] of Object.entries(typeCounts)) {
|
|
157
|
+
output += `| ${type} | ${count} |\n`;
|
|
158
|
+
}
|
|
159
|
+
output += "\n";
|
|
160
|
+
if (lockRisks.length > 0) {
|
|
161
|
+
output += "### Lock Risks\n\n";
|
|
162
|
+
for (const risk of lockRisks) {
|
|
163
|
+
output += `**${risk.severity}**: ${risk.risk}\n> **Recommendation**: ${risk.recommendation}\n\n`;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (dataLossIssues.length > 0) {
|
|
167
|
+
output += "### Data Loss Analysis\n\n";
|
|
168
|
+
for (const issue of dataLossIssues) {
|
|
169
|
+
output += `**${issue.risk}**: ${issue.description}\n> **Mitigation**: ${issue.mitigation}\n\n`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (lockRisks.length === 0 && dataLossIssues.length === 0) {
|
|
173
|
+
output += "### No risks detected.\n";
|
|
174
|
+
}
|
|
175
|
+
return { content: [{ type: "text", text: output }] };
|
|
176
|
+
});
|
|
177
|
+
// Tool 4: score_risk
|
|
178
|
+
server.tool("score_risk", "Calculate the overall risk score (0-100) for a SQL migration. Higher scores indicate more dangerous migrations.", {
|
|
179
|
+
filename: z.string().describe("Migration filename"),
|
|
180
|
+
sql: z.string().describe("The SQL content of the migration file"),
|
|
181
|
+
}, async ({ filename, sql }) => {
|
|
182
|
+
const migration = parseMigration(filename, sql);
|
|
183
|
+
const lockRisks = analyzeLockRisks(migration);
|
|
184
|
+
const dataLossIssues = analyzeDataLoss(migration);
|
|
185
|
+
const riskScore = calculateRiskScore(lockRisks);
|
|
186
|
+
const criticalCount = lockRisks.filter(r => r.severity === "CRITICAL").length;
|
|
187
|
+
const highCount = lockRisks.filter(r => r.severity === "HIGH").length;
|
|
188
|
+
const dataLossCertain = dataLossIssues.filter(i => i.risk === "CERTAIN").length;
|
|
189
|
+
const dataLossLikely = dataLossIssues.filter(i => i.risk === "LIKELY").length;
|
|
190
|
+
let verdict;
|
|
191
|
+
if (riskScore >= 60 || dataLossCertain > 0) {
|
|
192
|
+
verdict = "HIGH RISK — requires careful review and testing before deployment";
|
|
193
|
+
}
|
|
194
|
+
else if (riskScore >= 30 || dataLossLikely > 0) {
|
|
195
|
+
verdict = "MODERATE RISK — review lock duration and test on staging";
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
verdict = "LOW RISK — standard migration, proceed with normal deployment";
|
|
199
|
+
}
|
|
200
|
+
const output = `## Risk Score: ${riskScore}/100
|
|
201
|
+
|
|
202
|
+
**Verdict**: ${verdict}
|
|
203
|
+
|
|
204
|
+
### Breakdown
|
|
205
|
+
|
|
206
|
+
| Category | Count |
|
|
207
|
+
|----------|-------|
|
|
208
|
+
| CRITICAL lock risks | ${criticalCount} |
|
|
209
|
+
| HIGH lock risks | ${highCount} |
|
|
210
|
+
| Certain data loss | ${dataLossCertain} |
|
|
211
|
+
| Likely data loss | ${dataLossLikely} |
|
|
212
|
+
| Total statements | ${migration.statements.length} |
|
|
213
|
+
`;
|
|
214
|
+
return {
|
|
215
|
+
content: [{ type: "text", text: output }],
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
// Tool 4: generate_rollback
|
|
219
|
+
server.tool("generate_rollback", "Generate reverse DDL to undo a SQL migration. Produces rollback SQL with warnings for irreversible operations (DROP TABLE, DROP COLUMN, type changes). Includes Flyway schema_history cleanup.", {
|
|
220
|
+
filename: z.string().describe("Migration filename (e.g., V2__add_user_email.sql)"),
|
|
221
|
+
sql: z.string().describe("The SQL content of the migration file"),
|
|
222
|
+
}, async ({ filename, sql }) => {
|
|
223
|
+
// Pro feature gate — free users get a preview, Pro users get full output
|
|
224
|
+
if (!license.isPro) {
|
|
225
|
+
const migration = parseMigration(filename, sql);
|
|
226
|
+
const report = generateRollback(migration);
|
|
227
|
+
// Show a preview: statement count and reversibility, but not the actual SQL
|
|
228
|
+
let preview = `## Rollback Preview: ${filename}\n\n`;
|
|
229
|
+
preview += `**Reversible**: ${report.fullyReversible ? "Yes" : "Partially"}\n`;
|
|
230
|
+
preview += `**Statements**: ${report.statements.length}\n`;
|
|
231
|
+
preview += `**Warnings**: ${report.warnings.length}\n\n`;
|
|
232
|
+
preview += formatUpgradePrompt("generate_rollback", "Full rollback SQL generation with:\n" +
|
|
233
|
+
"- Complete reverse DDL for all migration operations\n" +
|
|
234
|
+
"- Flyway schema_history cleanup statements\n" +
|
|
235
|
+
"- Irreversibility warnings with manual intervention guidance");
|
|
236
|
+
return { content: [{ type: "text", text: preview }] };
|
|
237
|
+
}
|
|
238
|
+
const migration = parseMigration(filename, sql);
|
|
239
|
+
const report = generateRollback(migration);
|
|
240
|
+
let output = `## Rollback Script: ${filename}\n\n`;
|
|
241
|
+
output += `**Reversible**: ${report.fullyReversible ? "Yes — all operations can be automatically reversed" : "Partially — some operations require manual intervention"}\n`;
|
|
242
|
+
output += `**Statements**: ${report.statements.length}\n\n`;
|
|
243
|
+
if (report.warnings.length > 0) {
|
|
244
|
+
output += "### Warnings\n\n";
|
|
245
|
+
for (const w of report.warnings) {
|
|
246
|
+
output += `- ${w}\n`;
|
|
247
|
+
}
|
|
248
|
+
output += "\n";
|
|
249
|
+
}
|
|
250
|
+
output += "### Rollback SQL\n\n```sql\n";
|
|
251
|
+
output += report.rollbackSql;
|
|
252
|
+
output += "\n```\n";
|
|
253
|
+
return { content: [{ type: "text", text: output }] };
|
|
254
|
+
});
|
|
255
|
+
// Tool 5: detect_conflicts
|
|
256
|
+
server.tool("detect_conflicts", "Detect conflicts between two SQL migration files. Identifies same-table modifications, same-column changes, lock contention risks, and drop dependencies that could cause failures if applied concurrently or in the wrong order.", {
|
|
257
|
+
filename_a: z.string().describe("First migration filename (e.g., V3__add_email.sql)"),
|
|
258
|
+
sql_a: z.string().describe("SQL content of the first migration"),
|
|
259
|
+
filename_b: z.string().describe("Second migration filename (e.g., V4__modify_users.sql)"),
|
|
260
|
+
sql_b: z.string().describe("SQL content of the second migration"),
|
|
261
|
+
}, async ({ filename_a, sql_a, filename_b, sql_b }) => {
|
|
262
|
+
try {
|
|
263
|
+
const migA = parseMigration(filename_a, sql_a);
|
|
264
|
+
const migB = parseMigration(filename_b, sql_b);
|
|
265
|
+
const report = detectConflicts(migA, migB);
|
|
266
|
+
return { content: [{ type: "text", text: formatConflictReport(report) }] };
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
return {
|
|
270
|
+
content: [{
|
|
271
|
+
type: "text",
|
|
272
|
+
text: `Error detecting conflicts: ${err instanceof Error ? err.message : String(err)}`,
|
|
273
|
+
}],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
async function main() {
|
|
278
|
+
const transport = new StdioServerTransport();
|
|
279
|
+
await server.connect(transport);
|
|
280
|
+
}
|
|
281
|
+
main().catch((err) => {
|
|
282
|
+
console.error("Fatal error:", err);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
});
|
|
285
|
+
//# sourceMappingURL=index.js.map
|
package/build/license.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
}
|
|
115
|
+
//# sourceMappingURL=license.js.map
|