Skip to content

Commit

Permalink
Merge pull request #184 from robotdad/file-edit
Browse files Browse the repository at this point in the history
edit_file tool
  • Loading branch information
jspahrsummers authored Dec 5, 2024
2 parents 92fc214 + f7da6f4 commit fc71839
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 3 deletions.
19 changes: 18 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 25 additions & 1 deletion src/filesystem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,30 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio
- `path` (string): File location
- `content` (string): File content

- **edit_file**
- Make selective edits using advanced pattern matching and formatting
- Features:
- Line-based and multi-line content matching
- Whitespace normalization with indentation preservation
- Fuzzy matching with confidence scoring
- Multiple simultaneous edits with correct positioning
- Indentation style detection and preservation
- Git-style diff output with context
- Preview changes with dry run mode
- Failed match debugging with confidence scores
- Inputs:
- `path` (string): File to edit
- `edits` (array): List of edit operations
- `oldText` (string): Text to search for (can be substring)
- `newText` (string): Text to replace with
- `dryRun` (boolean): Preview changes without applying (default: false)
- `options` (object): Optional formatting settings
- `preserveIndentation` (boolean): Keep existing indentation (default: true)
- `normalizeWhitespace` (boolean): Normalize spaces while preserving structure (default: true)
- `partialMatch` (boolean): Enable fuzzy matching (default: true)
- Returns detailed diff and match information for dry runs, otherwise applies changes
- Best Practice: Always use dryRun first to preview changes before applying them

- **create_directory**
- Create new directory or ensure it exists
- Input: `path` (string)
Expand Down Expand Up @@ -98,4 +122,4 @@ Add this to your `claude_desktop_config.json`:

## License

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.
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.
130 changes: 130 additions & 0 deletions src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import path from "path";
import os from 'os';
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { diffLines, createTwoFilesPatch } from 'diff';

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

const EditOperation = z.object({
oldText: z.string().describe('Text to search for - must match exactly'),
newText: z.string().describe('Text to replace with')
});

const EditFileArgsSchema = z.object({
path: z.string(),
edits: z.array(EditOperation),
dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format')
});

const CreateDirectoryArgsSchema = z.object({
path: z.string(),
});
Expand Down Expand Up @@ -202,6 +214,104 @@ async function searchFiles(
return results;
}

// file editing and diffing utilities
function normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, '\n');
}

function createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string {
// Ensure consistent line endings for diff
const normalizedOriginal = normalizeLineEndings(originalContent);
const normalizedNew = normalizeLineEndings(newContent);

return createTwoFilesPatch(
filepath,
filepath,
normalizedOriginal,
normalizedNew,
'original',
'modified'
);
}

async function applyFileEdits(
filePath: string,
edits: Array<{oldText: string, newText: string}>,
dryRun = false
): Promise<string> {
// Read file content and normalize line endings
const content = normalizeLineEndings(await fs.readFile(filePath, 'utf-8'));

// Apply edits sequentially
let modifiedContent = content;
for (const edit of edits) {
const normalizedOld = normalizeLineEndings(edit.oldText);
const normalizedNew = normalizeLineEndings(edit.newText);

// If exact match exists, use it
if (modifiedContent.includes(normalizedOld)) {
modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew);
continue;
}

// Otherwise, try line-by-line matching with flexibility for whitespace
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);

// Compare lines with normalized whitespace
const isMatch = oldLines.every((oldLine, j) => {
const contentLine = potentialMatch[j];
return oldLine.trim() === contentLine.trim();
});

if (isMatch) {
// Preserve original indentation of first line
const originalIndent = contentLines[i].match(/^\s*/)?.[0] || '';
const newLines = normalizedNew.split('\n').map((line, j) => {
if (j === 0) return originalIndent + line.trimStart();
// For subsequent lines, try to preserve relative indentation
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}`);
}
}

// Create unified diff
const diff = createUnifiedDiff(content, modifiedContent, filePath);

// Format diff with appropriate number of backticks
let numBackticks = 3;
while (diff.includes('`'.repeat(numBackticks))) {
numBackticks++;
}
const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`;

if (!dryRun) {
await fs.writeFile(filePath, modifiedContent, 'utf-8');
}

return formattedDiff;
}

// Tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
Expand Down Expand Up @@ -233,6 +343,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
"Handles text content with proper encoding. Only works within allowed directories.",
inputSchema: zodToJsonSchema(WriteFileArgsSchema) as ToolInput,
},
{
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.",
inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput,
},
{
name: "create_directory",
description:
Expand Down Expand Up @@ -346,6 +464,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
};
}

case "edit_file": {
const parsed = EditFileArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for edit_file: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun);
return {
content: [{ type: "text", text: result }],
};
}

case "create_directory": {
const parsed = CreateDirectoryArgsSchema.safeParse(args);
if (!parsed.success) {
Expand Down
4 changes: 3 additions & 1 deletion src/filesystem/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
"watch": "tsc --watch"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.0.1",
"@modelcontextprotocol/sdk": "0.5.0",
"diff": "^5.1.0",
"glob": "^10.3.10",
"zod-to-json-schema": "^3.23.5"
},
"devDependencies": {
"@types/diff": "^5.0.9",
"@types/node": "^20.11.0",
"shx": "^0.3.4",
"typescript": "^5.3.3"
Expand Down

0 comments on commit fc71839

Please sign in to comment.