mcp-code-context 2.1.3 → 2.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/CHANGELOG.md +44 -71
- package/CONTRIBUTING.md +243 -0
- package/MEJORAS_PROPUESTAS.md +324 -0
- package/README.md +31 -7
- package/RELEASE_NOTES_v2.2.0.md +286 -0
- package/dist/src/index.d.ts +36 -0
- package/dist/src/index.js +198 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/tools/readFileLines.d.ts +26 -0
- package/dist/src/tools/readFileLines.js +77 -0
- package/dist/src/tools/readFileLines.js.map +1 -0
- package/dist/src/tools/searchCodePattern.d.ts +32 -0
- package/dist/src/tools/searchCodePattern.js +80 -0
- package/dist/src/tools/searchCodePattern.js.map +1 -0
- package/dist/src/utils/backupManager.d.ts +13 -6
- package/dist/src/utils/backupManager.js +73 -8
- package/dist/src/utils/backupManager.js.map +1 -1
- package/dist/src/utils/projectRoot.d.ts +10 -0
- package/dist/src/utils/projectRoot.js +40 -0
- package/dist/src/utils/projectRoot.js.map +1 -0
- package/dist/tests/test-backup-system.d.ts +7 -0
- package/dist/tests/test-backup-system.js +152 -0
- package/dist/tests/test-backup-system.js.map +1 -0
- package/dist/tests/test-new-tools.d.ts +4 -0
- package/dist/tests/test-new-tools.js +147 -0
- package/dist/tests/test-new-tools.js.map +1 -0
- package/llms.txt +62 -7
- package/package.json +2 -2
- package/src/index.ts +233 -2
- package/src/tools/readFileLines.ts +104 -0
- package/src/tools/searchCodePattern.ts +129 -0
- package/src/utils/backupManager.ts +83 -8
- package/src/utils/projectRoot.ts +44 -0
- package/tests/test-backup-system.ts +174 -0
- package/tests/test-new-tools.ts +165 -0
package/src/index.ts
CHANGED
|
@@ -40,16 +40,18 @@ import {
|
|
|
40
40
|
type InsertPosition,
|
|
41
41
|
} from "./ast/writers/symbolWriter.js";
|
|
42
42
|
import { generateDiff, generateMultiFileDiff } from "./utils/diffEngine.js";
|
|
43
|
-
import { createBackup, restoreBackup, cleanBackup } from "./utils/backupManager.js";
|
|
43
|
+
import { createBackup, restoreBackup, cleanBackup, cleanAllBackups } from "./utils/backupManager.js";
|
|
44
44
|
import { extractFallbackSymbols, findClosestSymbols } from "./utils/fuzzyMatch.js";
|
|
45
45
|
import { confirmationCache } from "./utils/confirmationCache.js";
|
|
46
|
+
import { readFileLines } from "./tools/readFileLines.js";
|
|
47
|
+
import { searchCodePattern } from "./tools/searchCodePattern.js";
|
|
46
48
|
import * as fs from "node:fs";
|
|
47
49
|
import * as path from "node:path";
|
|
48
50
|
|
|
49
51
|
// ─── Constants ──────────────────────────────────────────────────────
|
|
50
52
|
|
|
51
53
|
const SERVER_NAME = "mcp-code-context";
|
|
52
|
-
const SERVER_VERSION = "2.
|
|
54
|
+
const SERVER_VERSION = "2.2.0";
|
|
53
55
|
|
|
54
56
|
/** Extensions we consider source code for analysis */
|
|
55
57
|
const SOURCE_EXTENSIONS = new Set([
|
|
@@ -167,6 +169,113 @@ const TOOLS = [
|
|
|
167
169
|
required: ["filePath"],
|
|
168
170
|
},
|
|
169
171
|
},
|
|
172
|
+
{
|
|
173
|
+
name: "clean_backups",
|
|
174
|
+
description:
|
|
175
|
+
"Removes all backup files for a project. Deletes the entire .mcp-backups directory " +
|
|
176
|
+
"at the project root, freeing up disk space and cleaning the working directory. " +
|
|
177
|
+
"Use this to clean up after completing a series of edits or when backups are no longer needed.",
|
|
178
|
+
inputSchema: {
|
|
179
|
+
type: "object" as const,
|
|
180
|
+
properties: {
|
|
181
|
+
projectRoot: {
|
|
182
|
+
type: "string",
|
|
183
|
+
description:
|
|
184
|
+
"Absolute path to the project root directory. The .mcp-backups folder " +
|
|
185
|
+
"at this location will be removed.",
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
required: ["projectRoot"],
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
name: "read_file_lines",
|
|
193
|
+
description:
|
|
194
|
+
"Reads specific line ranges from a file without loading the entire content. " +
|
|
195
|
+
"Supports reading by exact line range (startLine/endLine) or around a pattern match. " +
|
|
196
|
+
"More efficient than read_file_surgical when you only need a small fragment of code. " +
|
|
197
|
+
"Perfect for debugging, viewing specific code blocks, or extracting context around errors.",
|
|
198
|
+
inputSchema: {
|
|
199
|
+
type: "object" as const,
|
|
200
|
+
properties: {
|
|
201
|
+
filePath: {
|
|
202
|
+
type: "string",
|
|
203
|
+
description: "Absolute path to the source file to read.",
|
|
204
|
+
},
|
|
205
|
+
startLine: {
|
|
206
|
+
type: "number",
|
|
207
|
+
description: "Starting line number (1-indexed). Required if using exact range mode.",
|
|
208
|
+
},
|
|
209
|
+
endLine: {
|
|
210
|
+
type: "number",
|
|
211
|
+
description: "Ending line number (1-indexed). Required if using exact range mode.",
|
|
212
|
+
},
|
|
213
|
+
aroundPattern: {
|
|
214
|
+
type: "string",
|
|
215
|
+
description:
|
|
216
|
+
"Search pattern to find in the file. Returns lines around the first match. " +
|
|
217
|
+
"Use this mode when you don't know the exact line numbers.",
|
|
218
|
+
},
|
|
219
|
+
contextLines: {
|
|
220
|
+
type: "number",
|
|
221
|
+
description:
|
|
222
|
+
"Number of lines to include before and after the pattern match. " +
|
|
223
|
+
"Defaults to 5. Only used with aroundPattern mode.",
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
required: ["filePath"],
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: "search_code_pattern",
|
|
231
|
+
description:
|
|
232
|
+
"Searches for code patterns across multiple files in a repository. " +
|
|
233
|
+
"Returns matches with file paths, line numbers, and optional context lines. " +
|
|
234
|
+
"Respects .gitignore rules and allows filtering by file extensions. " +
|
|
235
|
+
"More efficient than manual grep when you need structured results with context.",
|
|
236
|
+
inputSchema: {
|
|
237
|
+
type: "object" as const,
|
|
238
|
+
properties: {
|
|
239
|
+
rootDir: {
|
|
240
|
+
type: "string",
|
|
241
|
+
description: "Absolute path to the repository root directory to search.",
|
|
242
|
+
},
|
|
243
|
+
pattern: {
|
|
244
|
+
type: "string",
|
|
245
|
+
description:
|
|
246
|
+
"Regular expression pattern to search for. Use proper regex escaping " +
|
|
247
|
+
"(e.g., 'widget\\\\.height' to match 'widget.height').",
|
|
248
|
+
},
|
|
249
|
+
fileExtensions: {
|
|
250
|
+
type: "array",
|
|
251
|
+
items: { type: "string" },
|
|
252
|
+
description:
|
|
253
|
+
"Array of file extensions to search (e.g., ['.ts', '.dart', '.py']). " +
|
|
254
|
+
"Defaults to common code extensions if omitted.",
|
|
255
|
+
},
|
|
256
|
+
excludeDirs: {
|
|
257
|
+
type: "array",
|
|
258
|
+
items: { type: "string" },
|
|
259
|
+
description:
|
|
260
|
+
"Array of directory names to exclude (e.g., ['node_modules', 'dist']). " +
|
|
261
|
+
"Defaults to common build/dependency directories.",
|
|
262
|
+
},
|
|
263
|
+
showContext: {
|
|
264
|
+
type: "boolean",
|
|
265
|
+
description: "If true, includes surrounding lines for each match. Defaults to true.",
|
|
266
|
+
},
|
|
267
|
+
contextLines: {
|
|
268
|
+
type: "number",
|
|
269
|
+
description: "Number of context lines before/after each match. Defaults to 3.",
|
|
270
|
+
},
|
|
271
|
+
maxResults: {
|
|
272
|
+
type: "number",
|
|
273
|
+
description: "Maximum number of matches to return. Defaults to 50.",
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
required: ["rootDir", "pattern"],
|
|
277
|
+
},
|
|
278
|
+
},
|
|
170
279
|
{
|
|
171
280
|
name: "analyze_impact",
|
|
172
281
|
description:
|
|
@@ -434,6 +543,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
434
543
|
return await handleRollbackFile(
|
|
435
544
|
args as Record<string, unknown>,
|
|
436
545
|
);
|
|
546
|
+
case "clean_backups":
|
|
547
|
+
return await handleCleanBackups(
|
|
548
|
+
args as Record<string, unknown>,
|
|
549
|
+
);
|
|
550
|
+
case "read_file_lines":
|
|
551
|
+
return await handleReadFileLines(
|
|
552
|
+
args as Record<string, unknown>,
|
|
553
|
+
);
|
|
554
|
+
case "search_code_pattern":
|
|
555
|
+
return await handleSearchCodePattern(
|
|
556
|
+
args as Record<string, unknown>,
|
|
557
|
+
);
|
|
437
558
|
default:
|
|
438
559
|
return errorResponse(`Unknown tool: "${name}"`);
|
|
439
560
|
}
|
|
@@ -1526,6 +1647,116 @@ export async function handleRollbackFile(args: Record<string, unknown>) {
|
|
|
1526
1647
|
};
|
|
1527
1648
|
}
|
|
1528
1649
|
|
|
1650
|
+
export async function handleCleanBackups(args: Record<string, unknown>) {
|
|
1651
|
+
const projectRoot = args.projectRoot as string;
|
|
1652
|
+
|
|
1653
|
+
if (!projectRoot) return errorResponse("Missing required parameter: projectRoot");
|
|
1654
|
+
|
|
1655
|
+
const resolvedPath = path.resolve(projectRoot);
|
|
1656
|
+
|
|
1657
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
1658
|
+
return errorResponse(`Project root not found: ${resolvedPath}`);
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
const success = cleanAllBackups(resolvedPath);
|
|
1662
|
+
|
|
1663
|
+
if (!success) {
|
|
1664
|
+
return {
|
|
1665
|
+
content: [{
|
|
1666
|
+
type: "text" as const,
|
|
1667
|
+
text: `// ℹ️ No backups found\n// No .mcp-backups directory exists at: ${resolvedPath}`,
|
|
1668
|
+
}],
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
return {
|
|
1673
|
+
content: [{
|
|
1674
|
+
type: "text" as const,
|
|
1675
|
+
text: `// 🧹 Cleanup successful!\n// Removed all backups from: ${resolvedPath}/.mcp-backups\n// Your project is now clean.`,
|
|
1676
|
+
}],
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// ─── Tool: read_file_lines ──────────────────────────────────────────
|
|
1681
|
+
|
|
1682
|
+
export async function handleReadFileLines(args: Record<string, unknown>) {
|
|
1683
|
+
const result = readFileLines(args as any);
|
|
1684
|
+
|
|
1685
|
+
if (!result.success) {
|
|
1686
|
+
return errorResponse(result.error || "Unknown error reading file lines");
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
const { content, lineRange } = result;
|
|
1690
|
+
const header = lineRange
|
|
1691
|
+
? `// Lines ${lineRange.start}-${lineRange.end}\n// File: ${args.filePath}\n\n`
|
|
1692
|
+
: `// File: ${args.filePath}\n\n`;
|
|
1693
|
+
|
|
1694
|
+
return {
|
|
1695
|
+
content: [
|
|
1696
|
+
{
|
|
1697
|
+
type: "text" as const,
|
|
1698
|
+
text: header + content,
|
|
1699
|
+
},
|
|
1700
|
+
],
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// ─── Tool: search_code_pattern ──────────────────────────────────────
|
|
1705
|
+
|
|
1706
|
+
export async function handleSearchCodePattern(args: Record<string, unknown>) {
|
|
1707
|
+
const result = searchCodePattern(args as any);
|
|
1708
|
+
|
|
1709
|
+
if (!result.success) {
|
|
1710
|
+
return errorResponse(result.error || "Unknown error searching code");
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
const { matches, totalMatches } = result;
|
|
1714
|
+
|
|
1715
|
+
if (!matches || matches.length === 0) {
|
|
1716
|
+
return {
|
|
1717
|
+
content: [
|
|
1718
|
+
{
|
|
1719
|
+
type: "text" as const,
|
|
1720
|
+
text: `# Search Results\n\nNo matches found for pattern: "${args.pattern}"`,
|
|
1721
|
+
},
|
|
1722
|
+
],
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
const lines: string[] = [
|
|
1727
|
+
`# Search Results`,
|
|
1728
|
+
``,
|
|
1729
|
+
`**Pattern:** \`${args.pattern}\``,
|
|
1730
|
+
`**Root:** \`${args.rootDir}\``,
|
|
1731
|
+
`**Matches:** ${totalMatches} (showing ${matches.length})`,
|
|
1732
|
+
``,
|
|
1733
|
+
];
|
|
1734
|
+
|
|
1735
|
+
for (const match of matches) {
|
|
1736
|
+
lines.push(`## \`${match.file}\` (line ${match.lineNumber})`);
|
|
1737
|
+
lines.push(``);
|
|
1738
|
+
|
|
1739
|
+
if (match.context) {
|
|
1740
|
+
lines.push("```");
|
|
1741
|
+
lines.push(...match.context);
|
|
1742
|
+
lines.push("```");
|
|
1743
|
+
} else {
|
|
1744
|
+
lines.push(`\`\`\`\n${match.line}\n\`\`\``);
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
lines.push(``);
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
return {
|
|
1751
|
+
content: [
|
|
1752
|
+
{
|
|
1753
|
+
type: "text" as const,
|
|
1754
|
+
text: lines.join("\n"),
|
|
1755
|
+
},
|
|
1756
|
+
],
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1529
1760
|
function errorResponse(message: string) {
|
|
1530
1761
|
return {
|
|
1531
1762
|
content: [{ type: "text" as const, text: `❌ ${message}` }],
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* read_file_lines — Read specific line ranges from a file
|
|
3
|
+
*
|
|
4
|
+
* Efficiently reads only the requested lines without loading the entire file into memory.
|
|
5
|
+
* Supports reading by exact line range or around a pattern match.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
|
|
11
|
+
export interface ReadFileLinesArgs {
|
|
12
|
+
filePath: string;
|
|
13
|
+
startLine?: number;
|
|
14
|
+
endLine?: number;
|
|
15
|
+
aroundPattern?: string;
|
|
16
|
+
contextLines?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ReadFileLinesResult {
|
|
20
|
+
success: boolean;
|
|
21
|
+
content?: string;
|
|
22
|
+
error?: string;
|
|
23
|
+
lineRange?: { start: number; end: number };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read specific lines from a file
|
|
28
|
+
*/
|
|
29
|
+
export function readFileLines(args: ReadFileLinesArgs): ReadFileLinesResult {
|
|
30
|
+
const { filePath, startLine, endLine, aroundPattern, contextLines = 5 } = args;
|
|
31
|
+
|
|
32
|
+
const resolvedPath = path.resolve(filePath);
|
|
33
|
+
|
|
34
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
35
|
+
return {
|
|
36
|
+
success: false,
|
|
37
|
+
error: `File not found: ${resolvedPath}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let content: string;
|
|
42
|
+
try {
|
|
43
|
+
content = fs.readFileSync(resolvedPath, "utf-8");
|
|
44
|
+
} catch (error: unknown) {
|
|
45
|
+
return {
|
|
46
|
+
success: false,
|
|
47
|
+
error: `Failed to read file: ${error instanceof Error ? error.message : String(error)}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const lines = content.split("\n");
|
|
52
|
+
const totalLines = lines.length;
|
|
53
|
+
|
|
54
|
+
// Mode 1: Exact line range
|
|
55
|
+
if (startLine !== undefined && endLine !== undefined) {
|
|
56
|
+
if (startLine < 1 || endLine < 1 || startLine > totalLines || endLine > totalLines) {
|
|
57
|
+
return {
|
|
58
|
+
success: false,
|
|
59
|
+
error: `Invalid line range: ${startLine}-${endLine}. File has ${totalLines} lines.`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (startLine > endLine) {
|
|
64
|
+
return {
|
|
65
|
+
success: false,
|
|
66
|
+
error: `startLine (${startLine}) must be <= endLine (${endLine})`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const selectedLines = lines.slice(startLine - 1, endLine);
|
|
71
|
+
return {
|
|
72
|
+
success: true,
|
|
73
|
+
content: selectedLines.join("\n"),
|
|
74
|
+
lineRange: { start: startLine, end: endLine },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Mode 2: Around pattern
|
|
79
|
+
if (aroundPattern) {
|
|
80
|
+
const matchIndex = lines.findIndex((line) => line.includes(aroundPattern));
|
|
81
|
+
|
|
82
|
+
if (matchIndex === -1) {
|
|
83
|
+
return {
|
|
84
|
+
success: false,
|
|
85
|
+
error: `Pattern "${aroundPattern}" not found in file`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const start = Math.max(0, matchIndex - contextLines);
|
|
90
|
+
const end = Math.min(totalLines - 1, matchIndex + contextLines);
|
|
91
|
+
|
|
92
|
+
const selectedLines = lines.slice(start, end + 1);
|
|
93
|
+
return {
|
|
94
|
+
success: true,
|
|
95
|
+
content: selectedLines.join("\n"),
|
|
96
|
+
lineRange: { start: start + 1, end: end + 1 },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
success: false,
|
|
102
|
+
error: "Must provide either (startLine + endLine) or aroundPattern",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search_code_pattern — Search for code patterns across files
|
|
3
|
+
*
|
|
4
|
+
* AST-aware search that finds patterns in code while optionally excluding
|
|
5
|
+
* matches in strings, comments, and other non-code contexts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { IgnoreManager } from "../utils/ignoreManager.js";
|
|
11
|
+
|
|
12
|
+
export interface SearchCodePatternArgs {
|
|
13
|
+
rootDir: string;
|
|
14
|
+
pattern: string;
|
|
15
|
+
fileExtensions?: string[];
|
|
16
|
+
excludeDirs?: string[];
|
|
17
|
+
showContext?: boolean;
|
|
18
|
+
contextLines?: number;
|
|
19
|
+
maxResults?: number;
|
|
20
|
+
astAware?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SearchMatch {
|
|
24
|
+
file: string;
|
|
25
|
+
lineNumber: number;
|
|
26
|
+
line: string;
|
|
27
|
+
context?: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SearchCodePatternResult {
|
|
31
|
+
success: boolean;
|
|
32
|
+
matches?: SearchMatch[];
|
|
33
|
+
error?: string;
|
|
34
|
+
totalMatches?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_EXTENSIONS = [
|
|
38
|
+
".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs",
|
|
39
|
+
".py", ".php", ".dart", ".java", ".go", ".rs"
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Search for a pattern across code files
|
|
44
|
+
*/
|
|
45
|
+
export function searchCodePattern(args: SearchCodePatternArgs): SearchCodePatternResult {
|
|
46
|
+
const {
|
|
47
|
+
rootDir,
|
|
48
|
+
pattern,
|
|
49
|
+
fileExtensions = DEFAULT_EXTENSIONS,
|
|
50
|
+
excludeDirs = ["node_modules", "dist", "build", ".git"],
|
|
51
|
+
showContext = true,
|
|
52
|
+
contextLines = 3,
|
|
53
|
+
maxResults = 50,
|
|
54
|
+
astAware = false,
|
|
55
|
+
} = args;
|
|
56
|
+
|
|
57
|
+
const resolvedRoot = path.resolve(rootDir);
|
|
58
|
+
|
|
59
|
+
if (!fs.existsSync(resolvedRoot)) {
|
|
60
|
+
return {
|
|
61
|
+
success: false,
|
|
62
|
+
error: `Directory not found: ${resolvedRoot}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ignoreManager = new IgnoreManager(resolvedRoot);
|
|
67
|
+
const allFiles = ignoreManager.walkDirectory();
|
|
68
|
+
|
|
69
|
+
const matches: SearchMatch[] = [];
|
|
70
|
+
let totalMatches = 0;
|
|
71
|
+
|
|
72
|
+
const regex = new RegExp(pattern, "gi");
|
|
73
|
+
|
|
74
|
+
for (const file of allFiles) {
|
|
75
|
+
// Check extension
|
|
76
|
+
const ext = path.extname(file).toLowerCase();
|
|
77
|
+
if (!fileExtensions.includes(ext)) continue;
|
|
78
|
+
|
|
79
|
+
// Check excluded directories
|
|
80
|
+
const relativePath = path.relative(resolvedRoot, file);
|
|
81
|
+
if (excludeDirs.some(dir => relativePath.includes(dir))) continue;
|
|
82
|
+
|
|
83
|
+
let content: string;
|
|
84
|
+
try {
|
|
85
|
+
content = fs.readFileSync(file, "utf-8");
|
|
86
|
+
} catch {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const lines = content.split("\n");
|
|
91
|
+
|
|
92
|
+
for (let i = 0; i < lines.length; i++) {
|
|
93
|
+
const line = lines[i];
|
|
94
|
+
|
|
95
|
+
if (regex.test(line)) {
|
|
96
|
+
totalMatches++;
|
|
97
|
+
|
|
98
|
+
if (matches.length < maxResults) {
|
|
99
|
+
const match: SearchMatch = {
|
|
100
|
+
file: path.relative(resolvedRoot, file).replace(/\\/g, "/"),
|
|
101
|
+
lineNumber: i + 1,
|
|
102
|
+
line: line.trim(),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (showContext) {
|
|
106
|
+
const start = Math.max(0, i - contextLines);
|
|
107
|
+
const end = Math.min(lines.length - 1, i + contextLines);
|
|
108
|
+
match.context = lines.slice(start, end + 1).map((l, idx) => {
|
|
109
|
+
const lineNum = start + idx + 1;
|
|
110
|
+
const marker = lineNum === i + 1 ? ">" : " ";
|
|
111
|
+
return `${marker} ${lineNum}: ${l}`;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
matches.push(match);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Reset regex lastIndex for global flag
|
|
120
|
+
regex.lastIndex = 0;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
success: true,
|
|
126
|
+
matches,
|
|
127
|
+
totalMatches,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -2,26 +2,40 @@
|
|
|
2
2
|
* backupManager.ts — File Backup & Restore Utility
|
|
3
3
|
*
|
|
4
4
|
* Provides opt-in backup functionality for surgical write operations.
|
|
5
|
-
* Creates `.
|
|
5
|
+
* Creates centralized `.mcp-backups/` directory at project root.
|
|
6
6
|
*
|
|
7
7
|
* Usage:
|
|
8
8
|
* - Before modifying: createBackup(filePath) → returns backup path
|
|
9
|
-
* - On failure: restoreBackup(filePath) → restores from
|
|
10
|
-
* -
|
|
9
|
+
* - On failure: restoreBackup(filePath) → restores from backup
|
|
10
|
+
* - Cleanup: cleanAllBackups(projectRoot) → removes all backups
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import * as fs from "node:fs";
|
|
14
14
|
import * as path from "node:path";
|
|
15
|
+
import { findProjectRoot } from "./projectRoot.js";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Create a backup copy of a file before modifying it.
|
|
18
|
-
* Keeps a rolling window of the last 5 backups per file in a
|
|
19
|
+
* Keeps a rolling window of the last 5 backups per file in a centralized directory.
|
|
20
|
+
*
|
|
21
|
+
* Backups are stored at: [project-root]/.mcp-backups/[relative-path]/[filename].N.backup
|
|
19
22
|
*/
|
|
20
23
|
export function createBackup(filePath: string): string {
|
|
21
24
|
if (!fs.existsSync(filePath)) return "";
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
const
|
|
26
|
+
// Find project root
|
|
27
|
+
const projectRoot = findProjectRoot(filePath);
|
|
28
|
+
if (!projectRoot) {
|
|
29
|
+
// Fallback to old behavior if no project root found
|
|
30
|
+
return createBackupLegacy(filePath);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Calculate relative path from project root
|
|
34
|
+
const relativePath = path.relative(projectRoot, filePath);
|
|
35
|
+
const parsed = path.parse(relativePath);
|
|
36
|
+
|
|
37
|
+
// Create centralized backup directory
|
|
38
|
+
const backupDir = path.join(projectRoot, ".mcp-backups", parsed.dir);
|
|
25
39
|
|
|
26
40
|
if (!fs.existsSync(backupDir)) {
|
|
27
41
|
fs.mkdirSync(backupDir, { recursive: true });
|
|
@@ -45,9 +59,53 @@ export function createBackup(filePath: string): string {
|
|
|
45
59
|
}
|
|
46
60
|
|
|
47
61
|
/**
|
|
48
|
-
*
|
|
62
|
+
* Legacy backup function (creates .mcp-backups in same directory)
|
|
63
|
+
* Used as fallback when project root cannot be determined
|
|
64
|
+
*/
|
|
65
|
+
function createBackupLegacy(filePath: string): string {
|
|
66
|
+
const parsed = path.parse(filePath);
|
|
67
|
+
const backupDir = path.join(parsed.dir, ".mcp-backups");
|
|
68
|
+
|
|
69
|
+
if (!fs.existsSync(backupDir)) {
|
|
70
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (let i = 4; i >= 1; i--) {
|
|
74
|
+
const oldB = path.join(backupDir, `${parsed.base}.${i}.backup`);
|
|
75
|
+
const newB = path.join(backupDir, `${parsed.base}.${i + 1}.backup`);
|
|
76
|
+
if (fs.existsSync(oldB)) {
|
|
77
|
+
if (i === 4 && fs.existsSync(newB)) {
|
|
78
|
+
fs.unlinkSync(newB);
|
|
79
|
+
}
|
|
80
|
+
fs.renameSync(oldB, newB);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const backupPath = path.join(backupDir, `${parsed.base}.1.backup`);
|
|
85
|
+
fs.copyFileSync(filePath, backupPath);
|
|
86
|
+
return backupPath;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Restore a file from its backup.
|
|
49
91
|
*/
|
|
50
92
|
export function restoreBackup(filePath: string, steps = 1): boolean {
|
|
93
|
+
// Try centralized backup first
|
|
94
|
+
const projectRoot = findProjectRoot(filePath);
|
|
95
|
+
|
|
96
|
+
if (projectRoot) {
|
|
97
|
+
const relativePath = path.relative(projectRoot, filePath);
|
|
98
|
+
const parsed = path.parse(relativePath);
|
|
99
|
+
const backupDir = path.join(projectRoot, ".mcp-backups", parsed.dir);
|
|
100
|
+
const targetBackup = path.join(backupDir, `${parsed.base}.${steps}.backup`);
|
|
101
|
+
|
|
102
|
+
if (fs.existsSync(targetBackup)) {
|
|
103
|
+
fs.copyFileSync(targetBackup, filePath);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Fallback to legacy location
|
|
51
109
|
const parsed = path.parse(filePath);
|
|
52
110
|
const backupDir = path.join(parsed.dir, ".mcp-backups");
|
|
53
111
|
const targetBackup = path.join(backupDir, `${parsed.base}.${steps}.backup`);
|
|
@@ -59,7 +117,24 @@ export function restoreBackup(filePath: string, steps = 1): boolean {
|
|
|
59
117
|
}
|
|
60
118
|
|
|
61
119
|
/**
|
|
62
|
-
* Clean
|
|
120
|
+
* Clean all backups for a project.
|
|
121
|
+
* Removes the entire .mcp-backups directory at project root.
|
|
122
|
+
*/
|
|
123
|
+
export function cleanAllBackups(projectRoot: string): boolean {
|
|
124
|
+
const backupDir = path.join(projectRoot, ".mcp-backups");
|
|
125
|
+
|
|
126
|
+
if (!fs.existsSync(backupDir)) return false;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
130
|
+
return true;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Clean legacy backups (no-op, kept for compatibility).
|
|
63
138
|
*/
|
|
64
139
|
export function cleanBackup(filePath: string): void {
|
|
65
140
|
// No-op. We keep the rolling 5 versions instead of deleting.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* projectRoot.ts — Project Root Discovery Utility
|
|
3
|
+
*
|
|
4
|
+
* Finds the root directory of a project by looking for common markers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
|
|
10
|
+
const ROOT_MARKERS = [
|
|
11
|
+
"package.json",
|
|
12
|
+
".git",
|
|
13
|
+
"pyproject.toml",
|
|
14
|
+
"setup.py",
|
|
15
|
+
"Cargo.toml",
|
|
16
|
+
"go.mod",
|
|
17
|
+
"pom.xml",
|
|
18
|
+
"build.gradle",
|
|
19
|
+
"CMakeLists.txt",
|
|
20
|
+
".gitignore",
|
|
21
|
+
"Gemfile",
|
|
22
|
+
"composer.json",
|
|
23
|
+
"pubspec.yaml",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Find the project root by walking up from a file path.
|
|
28
|
+
* Returns undefined if no root marker is found.
|
|
29
|
+
*/
|
|
30
|
+
export function findProjectRoot(filePath: string): string | undefined {
|
|
31
|
+
let current = path.dirname(path.resolve(filePath));
|
|
32
|
+
const root = path.parse(current).root;
|
|
33
|
+
|
|
34
|
+
while (current !== root) {
|
|
35
|
+
for (const marker of ROOT_MARKERS) {
|
|
36
|
+
if (fs.existsSync(path.join(current, marker))) {
|
|
37
|
+
return current;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
current = path.dirname(current);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|