EditFileTool.ts•4.24 kB
import { MCPTool } from "mcp-framework";
import { z } from "zod";
import fs from "fs/promises";
import { createTwoFilesPatch } from 'diff';
interface EditOperation {
oldText: string;
newText: string;
}
interface EditFileInput {
path: string;
edits: EditOperation[];
dryRun: boolean;
}
class EditFileTool extends MCPTool<EditFileInput> {
name = "edit_file";
description = "Make line-based edits to a text file. Each edit replaces exact line sequences " +
"with new content. Returns a git-style diff showing the changes made. " +
"Only works within allowed directories.";
schema = {
path: {
type: z.string(),
description: "Path to the file to edit",
},
edits: {
type: z.array(z.object({
oldText: z.string().describe('Text to search for - must match exactly'),
newText: z.string().describe('Text to replace with')
})),
description: "Array of edit operations to perform",
},
dryRun: {
type: z.boolean(),
description: "Preview changes using git-style diff format",
default: false,
},
};
private normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, '\n');
}
private createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string {
const normalizedOriginal = this.normalizeLineEndings(originalContent);
const normalizedNew = this.normalizeLineEndings(newContent);
return createTwoFilesPatch(
filepath,
filepath,
normalizedOriginal,
normalizedNew,
'original',
'modified'
);
}
async execute(input: EditFileInput) {
try {
const content = this.normalizeLineEndings(await fs.readFile(input.path, 'utf-8'));
let modifiedContent = content;
for (const edit of input.edits) {
const normalizedOld = this.normalizeLineEndings(edit.oldText);
const normalizedNew = this.normalizeLineEndings(edit.newText);
if (modifiedContent.includes(normalizedOld)) {
modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew);
continue;
}
const oldLines = normalizedOld.split('\n');
const contentLines = modifiedContent.split('\n');
let matchFound = false;
for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
const potentialMatch = contentLines.slice(i, i + oldLines.length);
const isMatch = oldLines.every((oldLine, j) => {
const contentLine = potentialMatch[j];
return oldLine.trim() === contentLine.trim();
});
if (isMatch) {
const originalIndent = contentLines[i].match(/^\s*/)?.[0] || '';
const newLines = normalizedNew.split('\n').map((line, j) => {
if (j === 0) return originalIndent + line.trimStart();
const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || '';
const newIndent = line.match(/^\s*/)?.[0] || '';
if (oldIndent && newIndent) {
const relativeIndent = newIndent.length - oldIndent.length;
return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart();
}
return line;
});
contentLines.splice(i, oldLines.length, ...newLines);
modifiedContent = contentLines.join('\n');
matchFound = true;
break;
}
}
if (!matchFound) {
throw new Error(`Could not find exact match for edit:\n${edit.oldText}`);
}
}
const diff = this.createUnifiedDiff(content, modifiedContent, input.path);
let numBackticks = 3;
while (diff.includes('`'.repeat(numBackticks))) {
numBackticks++;
}
const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`;
if (!input.dryRun) {
await fs.writeFile(input.path, modifiedContent, 'utf-8');
}
return formattedDiff;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to edit file: ${errorMessage}`);
}
}
}
export default EditFileTool;