@imenam/refactoring-mcp 2.0.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 ADDED
@@ -0,0 +1,72 @@
1
+ # @imenam/refactoring-mcp
2
+
3
+ An MCP (Model Context Protocol) server that lets AI agents copy or move blocks of code between files, with optional directory confinement for safety.
4
+
5
+ ## Tools
6
+
7
+ ### `move_or_copy_code`
8
+
9
+ Copies or moves a range of lines from a source file to a destination file.
10
+
11
+ | Parameter | Type | Required | Description |
12
+ |---|---|---|---|
13
+ | `sourceFile` | `string` | ✅ | Absolute path to the source file |
14
+ | `startLine` | `number` | ✅ | Start line of the block (1-indexed) |
15
+ | `endLine` | `number` | ✅ | End line of the block, inclusive (1-indexed) |
16
+ | `operationType` | `"copy" \| "cut"` | ✅ | `copy` keeps the source intact, `cut` removes the lines |
17
+ | `destinationFile` | `string` | ✅ | Absolute path to the destination file (created automatically if it doesn't exist) |
18
+ | `destinationLine` | `number` | ❌ | Line at which to insert in the destination (appended at end by default) |
19
+
20
+ ### `get_file_outline`
21
+
22
+ Returns the list of top-level symbols in a file (functions, classes, interfaces, types, constants, methods) with their start and end line numbers. Useful for identifying the exact coordinates of a code block before moving it.
23
+
24
+ | Parameter | Type | Required | Description |
25
+ |---|---|---|---|
26
+ | `filePath` | `string` | ✅ | Absolute path to the file to analyse |
27
+
28
+ ## Configuration
29
+
30
+ All file paths must be **absolute**. Relative paths are rejected.
31
+
32
+ To restrict operations to a specific directory, set the `REFACTORING_ALLOWED_DIR` environment variable. Any attempt to access a file outside that directory will be blocked.
33
+
34
+ ```env
35
+ # .env
36
+ REFACTORING_ALLOWED_DIR=C:/Users/you/my-project
37
+ ```
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ npm install -g @imenam/refactoring-mcp
43
+ ```
44
+
45
+ Or use it directly with `npx`:
46
+
47
+ ```json
48
+ {
49
+ "refactoring-mcp": {
50
+ "command": "npx",
51
+ "args": ["-y", "@imenam/refactoring-mcp"]
52
+ }
53
+ }
54
+ ```
55
+
56
+ Or point to a local build:
57
+
58
+ ```json
59
+ {
60
+ "refactoring-mcp": {
61
+ "command": "node",
62
+ "args": ["/absolute/path/to/refactoring-mcp/dist/index.js"],
63
+ "env": {
64
+ "REFACTORING_ALLOWED_DIR": "/absolute/path/to/your/project"
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
70
+ ## License
71
+
72
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,167 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
+ import { z } from "zod";
5
+ import { setupLogging } from "./logger.js";
6
+ import { executeRefactor } from "./utils/files.js";
7
+ import { getFileOutline } from "./utils/outline.js";
8
+ setupLogging("MASTER");
9
+ const server = new Server({ name: "refactoring-mcp", version: "1.0.0" }, { capabilities: { tools: {} } });
10
+ const RefactorSchema = z.object({
11
+ sourceFile: z.string().describe("Chemin absolu complet du fichier d'origine"),
12
+ startLine: z
13
+ .number()
14
+ .int()
15
+ .positive()
16
+ .describe("Ligne de début du bloc de code (1-indexé)"),
17
+ endLine: z
18
+ .number()
19
+ .int()
20
+ .positive()
21
+ .describe("Ligne de fin du bloc de code incluse (1-indexé)"),
22
+ operationType: z
23
+ .enum(["copy", "cut"])
24
+ .describe("Type d'opération : copy (copie) ou cut (coupure/déplacement)"),
25
+ destinationFile: z
26
+ .string()
27
+ .describe("Chemin absolu complet du fichier de destination (créé automatiquement si inexistant)"),
28
+ destinationLine: z
29
+ .number()
30
+ .int()
31
+ .positive()
32
+ .optional()
33
+ .describe("Optionnel : ligne d'insertion dans le fichier de destination (1-indexé). Ajout en fin de fichier par défaut."),
34
+ });
35
+ const OutlineSchema = z.object({
36
+ filePath: z
37
+ .string()
38
+ .describe("Chemin absolu complet du fichier à analyser"),
39
+ });
40
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
41
+ tools: [
42
+ {
43
+ name: "move_or_copy_code",
44
+ description: "Copie ou déplace un bloc de lignes de code d'un fichier source vers un fichier de destination. " +
45
+ "Le fichier de destination (ainsi que ses dossiers parents) est créé automatiquement s'il n'existe pas. " +
46
+ "Tous les chemins doivent être absolus.",
47
+ inputSchema: {
48
+ type: "object",
49
+ properties: {
50
+ sourceFile: {
51
+ type: "string",
52
+ description: "Chemin absolu complet du fichier d'origine",
53
+ },
54
+ startLine: {
55
+ type: "number",
56
+ description: "Ligne de début du bloc de code (1-indexé)",
57
+ },
58
+ endLine: {
59
+ type: "number",
60
+ description: "Ligne de fin du bloc de code incluse (1-indexé)",
61
+ },
62
+ operationType: {
63
+ type: "string",
64
+ enum: ["copy", "cut"],
65
+ description: "Type d'opération : copy ou cut",
66
+ },
67
+ destinationFile: {
68
+ type: "string",
69
+ description: "Chemin absolu complet du fichier de destination",
70
+ },
71
+ destinationLine: {
72
+ type: "number",
73
+ description: "Optionnel : ligne d'insertion dans la destination (ajout en fin de fichier par défaut)",
74
+ },
75
+ },
76
+ required: [
77
+ "sourceFile",
78
+ "startLine",
79
+ "endLine",
80
+ "operationType",
81
+ "destinationFile",
82
+ ],
83
+ },
84
+ },
85
+ {
86
+ 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. " +
90
+ "Le chemin doit être absolu.",
91
+ inputSchema: {
92
+ type: "object",
93
+ properties: {
94
+ filePath: {
95
+ type: "string",
96
+ description: "Chemin absolu complet du fichier à analyser",
97
+ },
98
+ },
99
+ required: ["filePath"],
100
+ },
101
+ },
102
+ ],
103
+ }));
104
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
105
+ const { name, arguments: args } = request.params;
106
+ if (name === "get_file_outline") {
107
+ try {
108
+ const { filePath } = OutlineSchema.parse(args);
109
+ const symbols = await getFileOutline(filePath);
110
+ return {
111
+ content: [{ type: "text", text: JSON.stringify(symbols, null, 2) }],
112
+ };
113
+ }
114
+ catch (error) {
115
+ return {
116
+ content: [
117
+ {
118
+ type: "text",
119
+ text: `Erreur : ${error.message}`,
120
+ },
121
+ ],
122
+ isError: true,
123
+ };
124
+ }
125
+ }
126
+ if (name === "move_or_copy_code") {
127
+ try {
128
+ const validatedArgs = RefactorSchema.parse(args);
129
+ const result = await executeRefactor(validatedArgs);
130
+ return {
131
+ content: [
132
+ {
133
+ type: "text",
134
+ text: `Succès : opération "${result.operation}" effectuée.\n` +
135
+ `- Lignes traitées : ${result.linesMoved}\n` +
136
+ `- Fichier source : ${result.sourceLength} lignes après opération\n` +
137
+ `- Fichier destination : ${result.destLength} lignes après opération`,
138
+ },
139
+ ],
140
+ };
141
+ }
142
+ catch (error) {
143
+ return {
144
+ content: [
145
+ {
146
+ type: "text",
147
+ text: `Erreur : ${error.message}`,
148
+ },
149
+ ],
150
+ isError: true,
151
+ };
152
+ }
153
+ }
154
+ return {
155
+ content: [{ type: "text", text: `Outil inconnu : ${name}` }],
156
+ isError: true,
157
+ };
158
+ });
159
+ async function main() {
160
+ const transport = new StdioServerTransport();
161
+ await server.connect(transport);
162
+ console.error("Serveur MCP Refactoring Helper démarré en stdio.");
163
+ }
164
+ main().catch((error) => {
165
+ console.error("Erreur critique au démarrage :", error);
166
+ process.exit(1);
167
+ });
package/dist/logger.js ADDED
@@ -0,0 +1,37 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ const logDir = process.platform === "win32"
4
+ ? "C:\\var\\log\\refactoring-mcp"
5
+ : "/var/log/refactoring-mcp";
6
+ const logFile = path.join(logDir, "server.log");
7
+ export function setupLogging(prefix) {
8
+ if (!fs.existsSync(logDir)) {
9
+ fs.mkdirSync(logDir, { recursive: true });
10
+ }
11
+ const logToFile = (msg) => {
12
+ const timestamp = new Date().toISOString();
13
+ try {
14
+ fs.appendFileSync(logFile, `[${timestamp}] [PID ${process.pid}] [${prefix}] ${msg}\n`, "utf-8");
15
+ }
16
+ catch {
17
+ // Silently ignore log write failures to avoid crashing the server
18
+ }
19
+ };
20
+ // Rediriger console.log vers stderr pour protéger le flux JSON-RPC MCP
21
+ console.log = (...args) => {
22
+ const msg = args
23
+ .map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg)))
24
+ .join(" ");
25
+ logToFile(`[INFO] ${msg}`);
26
+ process.stderr.write(msg + "\n");
27
+ };
28
+ // Surcharger console.error pour journalisation automatique
29
+ const originalError = console.error.bind(console);
30
+ console.error = (...args) => {
31
+ const msg = args
32
+ .map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg)))
33
+ .join(" ");
34
+ logToFile(`[ERROR] ${msg}`);
35
+ originalError(msg);
36
+ };
37
+ }
@@ -0,0 +1,11 @@
1
+ import path from "path";
2
+ import { fileURLToPath } from "url";
3
+ import dotenv from "dotenv";
4
+ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
5
+ const envPath = path.join(rootDir, ".env");
6
+ export function getConfig() {
7
+ dotenv.config({ path: envPath, override: true });
8
+ const rawDir = process.env.REFACTORING_ALLOWED_DIR;
9
+ const allowedDir = rawDir ? path.resolve(rawDir) : null;
10
+ return { allowedDir };
11
+ }
@@ -0,0 +1,96 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import { getConfig } from "./config.js";
4
+ const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
5
+ export async function validateAndResolvePath(filePath) {
6
+ if (!path.isAbsolute(filePath)) {
7
+ throw new Error(`Le chemin doit être absolu : "${filePath}"`);
8
+ }
9
+ const resolved = path.resolve(filePath);
10
+ // Résoudre les symlinks pour éviter les contournements de confinement
11
+ // Si le fichier n'existe pas encore (destination), on résout le dossier parent
12
+ let realResolved;
13
+ try {
14
+ realResolved = await fs.realpath(resolved);
15
+ }
16
+ catch {
17
+ const parent = path.dirname(resolved);
18
+ try {
19
+ const realParent = await fs.realpath(parent);
20
+ realResolved = path.join(realParent, path.basename(resolved));
21
+ }
22
+ catch {
23
+ // Le dossier parent n'existe pas non plus — on conserve le chemin résolu
24
+ realResolved = resolved;
25
+ }
26
+ }
27
+ const { allowedDir } = getConfig();
28
+ if (allowedDir) {
29
+ // Normaliser avec séparateur pour éviter les faux positifs (ex: /foo vs /foobar)
30
+ const normalizedResolved = realResolved.endsWith(path.sep)
31
+ ? realResolved
32
+ : realResolved + path.sep;
33
+ const normalizedAllowed = allowedDir.endsWith(path.sep)
34
+ ? allowedDir
35
+ : allowedDir + path.sep;
36
+ if (!normalizedResolved.startsWith(normalizedAllowed) &&
37
+ realResolved !== allowedDir) {
38
+ throw new Error(`Accès interdit : le chemin "${realResolved}" n'est pas confiné dans le dossier autorisé "${allowedDir}"`);
39
+ }
40
+ }
41
+ return realResolved;
42
+ }
43
+ export async function checkFileSize(filePath) {
44
+ const stats = await fs.stat(filePath);
45
+ if (stats.size > MAX_FILE_SIZE_BYTES) {
46
+ throw new Error(`Fichier trop volumineux : ${Math.round(stats.size / 1024 / 1024)} MB (maximum ${MAX_FILE_SIZE_BYTES / 1024 / 1024} MB)`);
47
+ }
48
+ }
49
+ export async function executeRefactor(params) {
50
+ const sourcePath = await validateAndResolvePath(params.sourceFile);
51
+ const destPath = await validateAndResolvePath(params.destinationFile);
52
+ // Vérifier la taille du fichier source avant lecture
53
+ await checkFileSize(sourcePath);
54
+ // Lire le fichier source
55
+ const sourceContent = await fs.readFile(sourcePath, "utf-8");
56
+ const sourceLines = sourceContent.split(/\r?\n/);
57
+ const { startLine, endLine } = params;
58
+ if (startLine < 1 || endLine > sourceLines.length || startLine > endLine) {
59
+ throw new Error(`Lignes source invalides (demandées: ${startLine}-${endLine}, fichier source de ${sourceLines.length} lignes)`);
60
+ }
61
+ // Extraire le segment
62
+ const segment = sourceLines.slice(startLine - 1, endLine);
63
+ // Si coupure, supprimer les lignes du fichier source
64
+ if (params.operationType === "cut") {
65
+ sourceLines.splice(startLine - 1, endLine - startLine + 1);
66
+ await fs.writeFile(sourcePath, sourceLines.join("\n"), "utf-8");
67
+ }
68
+ // Préparer le fichier de destination (création automatique si inexistant)
69
+ const destDir = path.dirname(destPath);
70
+ await fs.mkdir(destDir, { recursive: true });
71
+ let destLines = [];
72
+ try {
73
+ const destContent = await fs.readFile(destPath, "utf-8");
74
+ destLines = destContent.split(/\r?\n/);
75
+ }
76
+ catch (error) {
77
+ if (error.code !== "ENOENT")
78
+ throw error;
79
+ // Fichier inexistant : on part d'un tableau vide
80
+ }
81
+ // Insérer le segment dans la destination
82
+ if (params.destinationLine !== undefined && params.destinationLine > 0) {
83
+ const insertIndex = Math.max(0, Math.min(destLines.length, params.destinationLine - 1));
84
+ destLines.splice(insertIndex, 0, ...segment);
85
+ }
86
+ else {
87
+ destLines.push(...segment);
88
+ }
89
+ await fs.writeFile(destPath, destLines.join("\n"), "utf-8");
90
+ return {
91
+ linesMoved: segment.length,
92
+ operation: params.operationType,
93
+ sourceLength: sourceLines.length,
94
+ destLength: destLines.length,
95
+ };
96
+ }
@@ -0,0 +1,55 @@
1
+ import fs from "fs/promises";
2
+ import { validateAndResolvePath, checkFileSize } from "./files.js";
3
+ const SYMBOL_PATTERNS = [
4
+ {
5
+ regex: /^(?:export\s+(?:default\s+)?)?(?:async\s+)?function\s+(\w+)/,
6
+ type: "function",
7
+ },
8
+ {
9
+ regex: /^(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/,
10
+ type: "class",
11
+ },
12
+ {
13
+ regex: /^(?:export\s+)?interface\s+(\w+)/,
14
+ type: "interface",
15
+ },
16
+ {
17
+ regex: /^(?:export\s+)?type\s+(\w+)\s*[=<]/,
18
+ type: "type",
19
+ },
20
+ {
21
+ regex: /^(?:export\s+)?(?:const|let|var)\s+(\w+)\s*[=:]/,
22
+ type: "const",
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...)
26
+ {
27
+ 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
+ type: "method",
29
+ },
30
+ ];
31
+ export async function getFileOutline(filePath) {
32
+ const resolved = await validateAndResolvePath(filePath);
33
+ await checkFileSize(resolved);
34
+ const content = await fs.readFile(resolved, "utf-8");
35
+ const lines = content.split(/\r?\n/);
36
+ const symbols = [];
37
+ for (let i = 0; i < lines.length; i++) {
38
+ 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;
46
+ }
47
+ }
48
+ }
49
+ // Calculer les lignes de fin : fin du symbole = début du suivant - 1 (ou fin du fichier)
50
+ for (let i = 0; i < symbols.length; i++) {
51
+ symbols[i].endLine =
52
+ i + 1 < symbols.length ? symbols[i + 1].startLine - 1 : lines.length;
53
+ }
54
+ return symbols;
55
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@imenam/refactoring-mcp",
3
+ "version": "2.0.0",
4
+ "description": "MCP server to copy or move code blocks between files, with optional directory confinement.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "refactoring-mcp": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "start": "node dist/index.js",
16
+ "dev": "tsx src/index.ts",
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"
21
+ },
22
+ "keywords": [
23
+ "mcp",
24
+ "refactoring",
25
+ "code",
26
+ "move",
27
+ "copy",
28
+ "model-context-protocol"
29
+ ],
30
+ "author": "imenam",
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/Matthieu-Pesnot-Pin/refactoring-mcp.git"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "^1.29.0",
41
+ "dotenv": "^17.4.2",
42
+ "zod": "^4.4.3"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^25.9.1",
46
+ "tsx": "^4.22.3",
47
+ "typescript": "^6.0.3"
48
+ }
49
+ }