Merge pull request #184 from robotdad/file-edit

edit_file tool
This commit is contained in:
Justin Spahr-Summers 2024-12-05 23:16:53 +00:00 • committed by GitHub
commit fc7183983c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 176 additions and 3 deletions

19
package-lock.json generated
View file

@ -1523,6 +1523,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/diff": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/diff/-/diff-5.2.3.tgz",
"integrity": "sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==",
"dev": true
},
"node_modules/@types/express": { "node_modules/@types/express": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz",
@ -2258,6 +2264,14 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz",
"integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==" "integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg=="
}, },
"node_modules/diff": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
"integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.4.6", "version": "16.4.6",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.6.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.6.tgz",
@ -4983,6 +4997,7 @@
} }
}, },
"src/aws-kb-retrieval-server": { "src/aws-kb-retrieval-server": {
"name": "@modelcontextprotocol/server-aws-kb-retrieval",
"version": "0.6.2", "version": "0.6.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -5167,7 +5182,8 @@
"version": "0.6.2", "version": "0.6.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "1.0.1", "@modelcontextprotocol/sdk": "0.5.0",
"diff": "^5.1.0",
"glob": "^10.3.10", "glob": "^10.3.10",
"zod-to-json-schema": "^3.23.5" "zod-to-json-schema": "^3.23.5"
}, },
@ -5175,6 +5191,7 @@
"mcp-server-filesystem": "dist/index.js" "mcp-server-filesystem": "dist/index.js"
}, },
"devDependencies": { "devDependencies": {
"@types/diff": "^5.0.9",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"shx": "^0.3.4", "shx": "^0.3.4",
"typescript": "^5.3.3" "typescript": "^5.3.3"

View file

@ -36,6 +36,30 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio
- `path` (string): File location - `path` (string): File location
- `content` (string): File content - `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_directory**
- Create new directory or ensure it exists - Create new directory or ensure it exists
- Input: `path` (string) - Input: `path` (string)

View file

@ -12,6 +12,7 @@ import path from "path";
import os from 'os'; import os from 'os';
import { z } from "zod"; import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema"; import { zodToJsonSchema } from "zod-to-json-schema";
import { diffLines, createTwoFilesPatch } from 'diff';
// Command line argument parsing // Command line argument parsing
const args = process.argv.slice(2); const args = process.argv.slice(2);
@ -106,6 +107,17 @@ const WriteFileArgsSchema = z.object({
content: z.string(), 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({ const CreateDirectoryArgsSchema = z.object({
path: z.string(), path: z.string(),
}); });
@ -202,6 +214,104 @@ async function searchFiles(
return results; 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 // Tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => { server.setRequestHandler(ListToolsRequestSchema, async () => {
return { return {
@ -233,6 +343,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
"Handles text content with proper encoding. Only works within allowed directories.", "Handles text content with proper encoding. Only works within allowed directories.",
inputSchema: zodToJsonSchema(WriteFileArgsSchema) as ToolInput, 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", name: "create_directory",
description: description:
@ -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": { case "create_directory": {
const parsed = CreateDirectoryArgsSchema.safeParse(args); const parsed = CreateDirectoryArgsSchema.safeParse(args);
if (!parsed.success) { if (!parsed.success) {

View file

@ -19,11 +19,13 @@
"watch": "tsc --watch" "watch": "tsc --watch"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "1.0.1", "@modelcontextprotocol/sdk": "0.5.0",
"diff": "^5.1.0",
"glob": "^10.3.10", "glob": "^10.3.10",
"zod-to-json-schema": "^3.23.5" "zod-to-json-schema": "^3.23.5"
}, },
"devDependencies": { "devDependencies": {
"@types/diff": "^5.0.9",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"shx": "^0.3.4", "shx": "^0.3.4",
"typescript": "^5.3.3" "typescript": "^5.3.3"