@imenam/refactoring-mcp 2.0.0 → 3.1.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/README.md CHANGED
@@ -25,6 +25,21 @@ Returns the list of top-level symbols in a file (functions, classes, interfaces,
25
25
  |---|---|---|---|
26
26
  | `filePath` | `string` | ✅ | Absolute path to the file to analyse |
27
27
 
28
+ ### `compare_code_sections`
29
+
30
+ Compares two code sections from two files (delimited by line numbers) and returns a unified diff in git format. Useful for verifying that a block of code was correctly copied from one file to another. Leading and trailing blank lines in each section are ignored to avoid false positives from positioning differences.
31
+
32
+ | Parameter | Type | Required | Description |
33
+ |---|---|---|---|
34
+ | `fileA` | `string` | ✅ | Absolute path to the first file |
35
+ | `startLineA` | `number` | ✅ | Start line of the section in file A (1-indexed) |
36
+ | `endLineA` | `number` | ✅ | End line of the section in file A, inclusive (1-indexed) |
37
+ | `fileB` | `string` | ✅ | Absolute path to the second file |
38
+ | `startLineB` | `number` | ✅ | Start line of the section in file B (1-indexed) |
39
+ | `endLineB` | `number` | ✅ | End line of the section in file B, inclusive (1-indexed) |
40
+
41
+ Returns `"Les sections sont identiques."` when no differences are found, or a unified diff with real file line numbers otherwise.
42
+
28
43
  ## Configuration
29
44
 
30
45
  All file paths must be **absolute**. Relative paths are rejected.
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
4
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
@@ -5,6 +6,7 @@ import { z } from "zod";
5
6
  import { setupLogging } from "./logger.js";
6
7
  import { executeRefactor } from "./utils/files.js";
7
8
  import { getFileOutline } from "./utils/outline.js";
9
+ import { compareCodeSections } from "./utils/compare.js";
8
10
  setupLogging("MASTER");
9
11
  const server = new Server({ name: "refactoring-mcp", version: "1.0.0" }, { capabilities: { tools: {} } });
10
12
  const RefactorSchema = z.object({
@@ -36,6 +38,48 @@ const OutlineSchema = z.object({
36
38
  filePath: z
37
39
  .string()
38
40
  .describe("Chemin absolu complet du fichier à analyser"),
41
+ depth: z
42
+ .number()
43
+ .int()
44
+ .min(0)
45
+ .optional()
46
+ .describe("Optionnel : profondeur maximale des symboles à retourner. 0 = premier niveau uniquement (classes, fonctions top-level), 1 = +1 niveau imbriqué (méthodes d'une classe), etc. Par défaut : tous les niveaux."),
47
+ startLine: z
48
+ .number()
49
+ .int()
50
+ .positive()
51
+ .optional()
52
+ .describe("Optionnel : première ligne à analyser (1-indexé). Utile pour cibler une section du fichier. Par défaut : début du fichier."),
53
+ endLine: z
54
+ .number()
55
+ .int()
56
+ .positive()
57
+ .optional()
58
+ .describe("Optionnel : dernière ligne à analyser (1-indexé). Utile pour cibler une section du fichier. Par défaut : fin du fichier."),
59
+ });
60
+ const CompareSchema = z.object({
61
+ fileA: z.string().describe("Chemin absolu complet du premier fichier"),
62
+ startLineA: z
63
+ .number()
64
+ .int()
65
+ .positive()
66
+ .describe("Ligne de début de la section dans le fichier A (1-indexé)"),
67
+ endLineA: z
68
+ .number()
69
+ .int()
70
+ .positive()
71
+ .describe("Ligne de fin de la section dans le fichier A (1-indexé, incluse)"),
72
+ fileB: z.string().describe("Chemin absolu complet du second fichier"),
73
+ startLineB: z
74
+ .number()
75
+ .int()
76
+ .positive()
77
+ .describe("Ligne de début de la section dans le fichier B (1-indexé)"),
78
+ endLineB: z
79
+ .number()
80
+ .int()
81
+ .positive()
82
+ .describe("Ligne de fin de la section dans le fichier B (1-indexé, incluse)"),
39
83
  });
40
84
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
41
85
  tools: [
@@ -84,9 +128,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
84
128
  },
85
129
  {
86
130
  name: "get_file_outline",
87
- description: "Retourne la liste des symboles de premier niveau d'un fichier " +
88
- "(fonctions, classes, interfaces, types, constantes, méthodes) avec leurs numéros de lignes de début et de fin. " +
89
- "Utile pour identifier les coordonnées précises d'un bloc de code avant de le déplacer. " +
131
+ description: "Retourne la liste des symboles d'un fichier (fonctions, classes, interfaces, types, constantes, méthodes) " +
132
+ "avec leurs numéros de lignes de début et de fin, et leur profondeur d'imbrication (depth). " +
133
+ "Supporte tous les langages à accolades (TypeScript, JavaScript, PHP, Java, C#…). " +
134
+ "Paramètres optionnels : depth pour filtrer par niveau d'imbrication, startLine/endLine pour cibler une section. " +
90
135
  "Le chemin doit être absolu.",
91
136
  inputSchema: {
92
137
  type: "object",
@@ -95,18 +140,68 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
95
140
  type: "string",
96
141
  description: "Chemin absolu complet du fichier à analyser",
97
142
  },
143
+ depth: {
144
+ type: "number",
145
+ description: "Optionnel : profondeur maximale à retourner. 0 = premier niveau uniquement (classes, fonctions top-level), 1 = +1 niveau (méthodes d'une classe), etc. Par défaut : tous les niveaux.",
146
+ },
147
+ startLine: {
148
+ type: "number",
149
+ description: "Optionnel : première ligne à analyser (1-indexé). Par défaut : début du fichier.",
150
+ },
151
+ endLine: {
152
+ type: "number",
153
+ description: "Optionnel : dernière ligne à analyser (1-indexé). Par défaut : fin du fichier.",
154
+ },
98
155
  },
99
156
  required: ["filePath"],
100
157
  },
101
158
  },
159
+ {
160
+ name: "compare_code_sections",
161
+ description: "Compare deux sections de code (délimitées par des numéros de lignes) issues de deux fichiers. " +
162
+ "Retourne un diff au format unifié (style git) avec les numéros de lignes réels dans les fichiers. " +
163
+ "Utile pour vérifier qu'un bloc de code a été correctement recopié d'un fichier à l'autre. " +
164
+ "Les lignes vides en début et en fin de chaque section sont ignorées pour éviter les faux positifs. " +
165
+ "Tous les chemins doivent être absolus.",
166
+ inputSchema: {
167
+ type: "object",
168
+ properties: {
169
+ fileA: {
170
+ type: "string",
171
+ description: "Chemin absolu complet du premier fichier",
172
+ },
173
+ startLineA: {
174
+ type: "number",
175
+ description: "Ligne de début de la section dans le fichier A (1-indexé)",
176
+ },
177
+ endLineA: {
178
+ type: "number",
179
+ description: "Ligne de fin de la section dans le fichier A (1-indexé, incluse)",
180
+ },
181
+ fileB: {
182
+ type: "string",
183
+ description: "Chemin absolu complet du second fichier",
184
+ },
185
+ startLineB: {
186
+ type: "number",
187
+ description: "Ligne de début de la section dans le fichier B (1-indexé)",
188
+ },
189
+ endLineB: {
190
+ type: "number",
191
+ description: "Ligne de fin de la section dans le fichier B (1-indexé, incluse)",
192
+ },
193
+ },
194
+ required: ["fileA", "startLineA", "endLineA", "fileB", "startLineB", "endLineB"],
195
+ },
196
+ },
102
197
  ],
103
198
  }));
104
199
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
105
200
  const { name, arguments: args } = request.params;
106
201
  if (name === "get_file_outline") {
107
202
  try {
108
- const { filePath } = OutlineSchema.parse(args);
109
- const symbols = await getFileOutline(filePath);
203
+ const { filePath, depth, startLine, endLine } = OutlineSchema.parse(args);
204
+ const symbols = await getFileOutline(filePath, { depth, startLine, endLine });
110
205
  return {
111
206
  content: [{ type: "text", text: JSON.stringify(symbols, null, 2) }],
112
207
  };
@@ -151,6 +246,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
151
246
  };
152
247
  }
153
248
  }
249
+ if (name === "compare_code_sections") {
250
+ try {
251
+ const validatedArgs = CompareSchema.parse(args);
252
+ const result = await compareCodeSections(validatedArgs);
253
+ return {
254
+ content: [{ type: "text", text: result }],
255
+ };
256
+ }
257
+ catch (error) {
258
+ return {
259
+ content: [
260
+ {
261
+ type: "text",
262
+ text: `Erreur : ${error.message}`,
263
+ },
264
+ ],
265
+ isError: true,
266
+ };
267
+ }
268
+ }
154
269
  return {
155
270
  content: [{ type: "text", text: `Outil inconnu : ${name}` }],
156
271
  isError: true,
@@ -0,0 +1,48 @@
1
+ import fs from "fs/promises";
2
+ import { createTwoFilesPatch } from "diff";
3
+ import { validateAndResolvePath, checkFileSize } from "./files.js";
4
+ function trimBlankLines(lines) {
5
+ let start = 0;
6
+ while (start < lines.length && lines[start].trim() === "")
7
+ start++;
8
+ let end = lines.length - 1;
9
+ while (end > start && lines[end].trim() === "")
10
+ end--;
11
+ return lines.slice(start, end + 1);
12
+ }
13
+ function adjustDiffLineNumbers(patch, offsetA, offsetB) {
14
+ return patch.replace(/@@ -(\d+),(\d+) \+(\d+),(\d+) @@/g, (_, a1, a2, b1, b2) => `@@ -${parseInt(a1) + offsetA},${a2} +${parseInt(b1) + offsetB},${b2} @@`);
15
+ }
16
+ export async function compareCodeSections(params) {
17
+ const pathA = await validateAndResolvePath(params.fileA);
18
+ const pathB = await validateAndResolvePath(params.fileB);
19
+ await checkFileSize(pathA);
20
+ await checkFileSize(pathB);
21
+ const contentA = await fs.readFile(pathA, "utf-8");
22
+ const contentB = await fs.readFile(pathB, "utf-8");
23
+ const linesA = contentA.split(/\r?\n/);
24
+ const linesB = contentB.split(/\r?\n/);
25
+ const { startLineA, endLineA, startLineB, endLineB } = params;
26
+ if (startLineA < 1 || endLineA > linesA.length || startLineA > endLineA) {
27
+ throw new Error(`Lignes invalides pour le fichier A (demandées: ${startLineA}-${endLineA}, fichier de ${linesA.length} lignes)`);
28
+ }
29
+ if (startLineB < 1 || endLineB > linesB.length || startLineB > endLineB) {
30
+ throw new Error(`Lignes invalides pour le fichier B (demandées: ${startLineB}-${endLineB}, fichier de ${linesB.length} lignes)`);
31
+ }
32
+ const rawSectionA = linesA.slice(startLineA - 1, endLineA);
33
+ const rawSectionB = linesB.slice(startLineB - 1, endLineB);
34
+ const sectionA = trimBlankLines(rawSectionA);
35
+ const sectionB = trimBlankLines(rawSectionB);
36
+ // Offset = number of blank lines trimmed from the start of each section
37
+ const trimOffsetA = rawSectionA.indexOf(sectionA[0] ?? "");
38
+ const trimOffsetB = rawSectionB.indexOf(sectionB[0] ?? "");
39
+ const textA = sectionA.join("\n") + "\n";
40
+ const textB = sectionB.join("\n") + "\n";
41
+ if (textA === textB) {
42
+ return "Les sections sont identiques.";
43
+ }
44
+ const rawPatch = createTwoFilesPatch(`${params.fileA} (lignes ${startLineA}-${endLineA})`, `${params.fileB} (lignes ${startLineB}-${endLineB})`, textA, textB);
45
+ const absoluteOffsetA = startLineA - 1 + trimOffsetA;
46
+ const absoluteOffsetB = startLineB - 1 + trimOffsetB;
47
+ return adjustDiffLineNumbers(rawPatch, absoluteOffsetA, absoluteOffsetB);
48
+ }
@@ -21,35 +21,81 @@ const SYMBOL_PATTERNS = [
21
21
  regex: /^(?:export\s+)?(?:const|let|var)\s+(\w+)\s*[=:]/,
22
22
  type: "const",
23
23
  },
24
- // Méthodes dans une classe (indentées d'au moins 2 espaces/tab)
25
- // On exclut les mots-clés JS/TS pour éviter les faux positifs (if, for, while, switch, catch, return...)
24
+ // Méthodes PHP et autres langages : mot-clé `function` indenté
25
+ {
26
+ regex: /^[ \t]+(?:(?:public|private|protected|static|abstract|final|async|override)\s+)*function\s+(\w+)/,
27
+ type: "method",
28
+ },
29
+ // Méthodes TypeScript/JS indentées sans mot-clé `function`
30
+ // On exclut les mots-clés pour éviter les faux positifs
26
31
  {
27
32
  regex: /^[ \t]{2,}(?:(?:public|private|protected|static|async|override)\s+)*(?!(if|for|while|switch|catch|return|throw|new|typeof|instanceof|await|yield|delete|void|case|else|do)\b)(\w+)\s*[(<]/,
28
33
  type: "method",
29
34
  },
30
35
  ];
31
- export async function getFileOutline(filePath) {
36
+ function countBracesInLine(line) {
37
+ let count = 0;
38
+ let inSingleQuote = false;
39
+ let inDoubleQuote = false;
40
+ for (let i = 0; i < line.length; i++) {
41
+ const ch = line[i];
42
+ if (ch === "'" && !inDoubleQuote)
43
+ inSingleQuote = !inSingleQuote;
44
+ else if (ch === '"' && !inSingleQuote)
45
+ inDoubleQuote = !inDoubleQuote;
46
+ else if (!inSingleQuote && !inDoubleQuote) {
47
+ if (ch === "{")
48
+ count++;
49
+ else if (ch === "}")
50
+ count--;
51
+ }
52
+ }
53
+ return count;
54
+ }
55
+ export async function getFileOutline(filePath, options = {}) {
32
56
  const resolved = await validateAndResolvePath(filePath);
33
57
  await checkFileSize(resolved);
34
58
  const content = await fs.readFile(resolved, "utf-8");
35
59
  const lines = content.split(/\r?\n/);
60
+ const rangeStart = options.startLine ?? 1;
61
+ const rangeEnd = options.endLine ?? lines.length;
36
62
  const symbols = [];
63
+ let braceDepth = 0;
37
64
  for (let i = 0; i < lines.length; i++) {
65
+ const lineNum = i + 1;
38
66
  const line = lines[i];
39
- for (const { regex, type } of SYMBOL_PATTERNS) {
40
- const match = line.match(regex);
41
- if (match && match[1]) {
42
- // Pour le pattern "method", le groupe capturant est match[2] car match[1] est le lookahead
43
- const name = match[2] ?? match[1];
44
- symbols.push({ name, type, startLine: i + 1, endLine: 0 });
45
- break;
67
+ if (lineNum >= rangeStart && lineNum <= rangeEnd) {
68
+ for (const { regex, type } of SYMBOL_PATTERNS) {
69
+ const match = line.match(regex);
70
+ if (match) {
71
+ // Pour le pattern TS method, le groupe capturant est match[2] (match[1] = lookahead interne)
72
+ const name = match[2] ?? match[1];
73
+ if (name) {
74
+ symbols.push({ name, type, startLine: lineNum, endLine: 0, depth: braceDepth });
75
+ break;
76
+ }
77
+ }
46
78
  }
47
79
  }
80
+ braceDepth += countBracesInLine(line);
81
+ if (braceDepth < 0)
82
+ braceDepth = 0;
48
83
  }
49
- // Calculer les lignes de fin : fin du symbole = début du suivant - 1 (ou fin du fichier)
84
+ // Calculer endLine en tenant compte de la profondeur :
85
+ // un symbole se termine juste avant le prochain symbole de même niveau ou supérieur
50
86
  for (let i = 0; i < symbols.length; i++) {
51
- symbols[i].endLine =
52
- i + 1 < symbols.length ? symbols[i + 1].startLine - 1 : lines.length;
87
+ const currentDepth = symbols[i].depth;
88
+ let nextBoundaryLine = rangeEnd + 1;
89
+ for (let j = i + 1; j < symbols.length; j++) {
90
+ if (symbols[j].depth <= currentDepth) {
91
+ nextBoundaryLine = symbols[j].startLine;
92
+ break;
93
+ }
94
+ }
95
+ symbols[i].endLine = Math.min(nextBoundaryLine - 1, rangeEnd);
96
+ }
97
+ if (options.depth !== undefined) {
98
+ return symbols.filter((s) => s.depth <= options.depth);
53
99
  }
54
100
  return symbols;
55
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/refactoring-mcp",
3
- "version": "2.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "MCP server to copy or move code blocks between files, with optional directory confinement.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,9 +15,9 @@
15
15
  "start": "node dist/index.js",
16
16
  "dev": "tsx src/index.ts",
17
17
  "prepublishOnly": "npm run build",
18
- "release:patch": "npm version patch && npm publish --access public && git push origin main --follow-tags",
19
- "release:minor": "npm version minor && npm publish --access public && git push origin main --follow-tags",
20
- "release:major": "npm version major && npm publish --access public && git push origin main --follow-tags"
18
+ "release:patch": "npm version patch && npm publish --access public && git push origin master --follow-tags",
19
+ "release:minor": "npm version minor && npm publish --access public && git push origin master --follow-tags",
20
+ "release:major": "npm version major && npm publish --access public && git push origin master --follow-tags"
21
21
  },
22
22
  "keywords": [
23
23
  "mcp",
@@ -38,10 +38,12 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@modelcontextprotocol/sdk": "^1.29.0",
41
+ "diff": "^9.0.0",
41
42
  "dotenv": "^17.4.2",
42
43
  "zod": "^4.4.3"
43
44
  },
44
45
  "devDependencies": {
46
+ "@types/diff": "^7.0.2",
45
47
  "@types/node": "^25.9.1",
46
48
  "tsx": "^4.22.3",
47
49
  "typescript": "^6.0.3"