Skip to content

Commit fc71839

Browse files
Merge pull request #184 from robotdad/file-edit
edit_file tool
2 parents 92fc214 + f7da6f4 commit fc71839

File tree

4 files changed

+176
-3
lines changed

4 files changed

+176
-3
lines changed

package-lock.json

Lines changed: 18 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/filesystem/README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,30 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio
3636
- `path` (string): File location
3737
- `content` (string): File content
3838

39+
- **edit_file**
40+
- Make selective edits using advanced pattern matching and formatting
41+
- Features:
42+
- Line-based and multi-line content matching
43+
- Whitespace normalization with indentation preservation
44+
- Fuzzy matching with confidence scoring
45+
- Multiple simultaneous edits with correct positioning
46+
- Indentation style detection and preservation
47+
- Git-style diff output with context
48+
- Preview changes with dry run mode
49+
- Failed match debugging with confidence scores
50+
- Inputs:
51+
- `path` (string): File to edit
52+
- `edits` (array): List of edit operations
53+
- `oldText` (string): Text to search for (can be substring)
54+
- `newText` (string): Text to replace with
55+
- `dryRun` (boolean): Preview changes without applying (default: false)
56+
- `options` (object): Optional formatting settings
57+
- `preserveIndentation` (boolean): Keep existing indentation (default: true)
58+
- `normalizeWhitespace` (boolean): Normalize spaces while preserving structure (default: true)
59+
- `partialMatch` (boolean): Enable fuzzy matching (default: true)
60+
- Returns detailed diff and match information for dry runs, otherwise applies changes
61+
- Best Practice: Always use dryRun first to preview changes before applying them
62+
3963
- **create_directory**
4064
- Create new directory or ensure it exists
4165
- Input: `path` (string)
@@ -98,4 +122,4 @@ Add this to your `claude_desktop_config.json`:
98122

99123
## License
100124

101-
This MCP server is licensed under the MIT License. This means you are free to use, modify, and distribute the software, subject to the terms and conditions of the MIT License. For more details, please see the LICENSE file in the project repository.
125+
This MCP server is licensed under the MIT License. This means you are free to use, modify, and distribute the software, subject to the terms and conditions of the MIT License. For more details, please see the LICENSE file in the project repository.

src/filesystem/index.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import path from "path";
1212
import os from 'os';
1313
import { z } from "zod";
1414
import { zodToJsonSchema } from "zod-to-json-schema";
15+
import { diffLines, createTwoFilesPatch } from 'diff';
1516

1617
// Command line argument parsing
1718
const args = process.argv.slice(2);
@@ -106,6 +107,17 @@ const WriteFileArgsSchema = z.object({
106107
content: z.string(),
107108
});
108109

110+
const EditOperation = z.object({
111+
oldText: z.string().describe('Text to search for - must match exactly'),
112+
newText: z.string().describe('Text to replace with')
113+
});
114+
115+
const EditFileArgsSchema = z.object({
116+
path: z.string(),
117+
edits: z.array(EditOperation),
118+
dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format')
119+
});
120+
109121
const CreateDirectoryArgsSchema = z.object({
110122
path: z.string(),
111123
});
@@ -202,6 +214,104 @@ async function searchFiles(
202214
return results;
203215
}
204216

217+
// file editing and diffing utilities
218+
function normalizeLineEndings(text: string): string {
219+
return text.replace(/\r\n/g, '\n');
220+
}
221+
222+
function createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string {
223+
// Ensure consistent line endings for diff
224+
const normalizedOriginal = normalizeLineEndings(originalContent);
225+
const normalizedNew = normalizeLineEndings(newContent);
226+
227+
return createTwoFilesPatch(
228+
filepath,
229+
filepath,
230+
normalizedOriginal,
231+
normalizedNew,
232+
'original',
233+
'modified'
234+
);
235+
}
236+
237+
async function applyFileEdits(
238+
filePath: string,
239+
edits: Array<{oldText: string, newText: string}>,
240+
dryRun = false
241+
): Promise<string> {
242+
// Read file content and normalize line endings
243+
const content = normalizeLineEndings(await fs.readFile(filePath, 'utf-8'));
244+
245+
// Apply edits sequentially
246+
let modifiedContent = content;
247+
for (const edit of edits) {
248+
const normalizedOld = normalizeLineEndings(edit.oldText);
249+
const normalizedNew = normalizeLineEndings(edit.newText);
250+
251+
// If exact match exists, use it
252+
if (modifiedContent.includes(normalizedOld)) {
253+
modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew);
254+
continue;
255+
}
256+
257+
// Otherwise, try line-by-line matching with flexibility for whitespace
258+
const oldLines = normalizedOld.split('\n');
259+
const contentLines = modifiedContent.split('\n');
260+
let matchFound = false;
261+
262+
for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
263+
const potentialMatch = contentLines.slice(i, i + oldLines.length);
264+
265+
// Compare lines with normalized whitespace
266+
const isMatch = oldLines.every((oldLine, j) => {
267+
const contentLine = potentialMatch[j];
268+
return oldLine.trim() === contentLine.trim();
269+
});
270+
271+
if (isMatch) {
272+
// Preserve original indentation of first line
273+
const originalIndent = contentLines[i].match(/^\s*/)?.[0] || '';
274+
const newLines = normalizedNew.split('\n').map((line, j) => {
275+
if (j === 0) return originalIndent + line.trimStart();
276+
// For subsequent lines, try to preserve relative indentation
277+
const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || '';
278+
const newIndent = line.match(/^\s*/)?.[0] || '';
279+
if (oldIndent && newIndent) {
280+
const relativeIndent = newIndent.length - oldIndent.length;
281+
return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart();
282+
}
283+
return line;
284+
});
285+
286+
contentLines.splice(i, oldLines.length, ...newLines);
287+
modifiedContent = contentLines.join('\n');
288+
matchFound = true;
289+
break;
290+
}
291+
}
292+
293+
if (!matchFound) {
294+
throw new Error(`Could not find exact match for edit:\n${edit.oldText}`);
295+
}
296+
}
297+
298+
// Create unified diff
299+
const diff = createUnifiedDiff(content, modifiedContent, filePath);
300+
301+
// Format diff with appropriate number of backticks
302+
let numBackticks = 3;
303+
while (diff.includes('`'.repeat(numBackticks))) {
304+
numBackticks++;
305+
}
306+
const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`;
307+
308+
if (!dryRun) {
309+
await fs.writeFile(filePath, modifiedContent, 'utf-8');
310+
}
311+
312+
return formattedDiff;
313+
}
314+
205315
// Tool handlers
206316
server.setRequestHandler(ListToolsRequestSchema, async () => {
207317
return {
@@ -233,6 +343,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
233343
"Handles text content with proper encoding. Only works within allowed directories.",
234344
inputSchema: zodToJsonSchema(WriteFileArgsSchema) as ToolInput,
235345
},
346+
{
347+
name: "edit_file",
348+
description:
349+
"Make line-based edits to a text file. Each edit replaces exact line sequences " +
350+
"with new content. Returns a git-style diff showing the changes made. " +
351+
"Only works within allowed directories.",
352+
inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput,
353+
},
236354
{
237355
name: "create_directory",
238356
description:
@@ -346,6 +464,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
346464
};
347465
}
348466

467+
case "edit_file": {
468+
const parsed = EditFileArgsSchema.safeParse(args);
469+
if (!parsed.success) {
470+
throw new Error(`Invalid arguments for edit_file: ${parsed.error}`);
471+
}
472+
const validPath = await validatePath(parsed.data.path);
473+
const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun);
474+
return {
475+
content: [{ type: "text", text: result }],
476+
};
477+
}
478+
349479
case "create_directory": {
350480
const parsed = CreateDirectoryArgsSchema.safeParse(args);
351481
if (!parsed.success) {

src/filesystem/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@
1919
"watch": "tsc --watch"
2020
},
2121
"dependencies": {
22-
"@modelcontextprotocol/sdk": "1.0.1",
22+
"@modelcontextprotocol/sdk": "0.5.0",
23+
"diff": "^5.1.0",
2324
"glob": "^10.3.10",
2425
"zod-to-json-schema": "^3.23.5"
2526
},
2627
"devDependencies": {
28+
"@types/diff": "^5.0.9",
2729
"@types/node": "^20.11.0",
2830
"shx": "^0.3.4",
2931
"typescript": "^5.3.3"

0 commit comments

Comments
 (0)