From 1096d5cd7520c031abd545d860518706a02dfd45 Mon Sep 17 00:00:00 2001 From: Marc Goodner Date: Mon, 2 Dec 2024 15:13:05 -0800 Subject: [PATCH 01/18] edit_file tool --- src/filesystem/index.ts | 234 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index b4c4e92..ebe84a0 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -106,6 +106,27 @@ const WriteFileArgsSchema = z.object({ content: z.string(), }); +const EditOperation = z.object({ + startLine: z.number().int().min(1).optional(), + contextLines: z.number().int().min(0).default(3), + oldText: z.string(), + newText: z.string(), + verifyState: z.boolean().default(true), + readBeforeEdit: z.boolean().default(false), + findAnchor: z.string().optional(), + anchorOffset: z.number().int().default(0), + beforeContext: z.string().optional(), + afterContext: z.string().optional(), + contextRadius: z.number().int().min(0).default(3), + insertMode: z.enum(['replace', 'before', 'after']).default('replace'), + dryRun: z.boolean().default(false), +}); + +const EditFileArgsSchema = z.object({ + path: z.string(), + edits: z.array(EditOperation), +}); + const CreateDirectoryArgsSchema = z.object({ path: z.string(), }); @@ -202,6 +223,166 @@ async function searchFiles( return results; } +// Line ending detection and normalization utilities +function detectLineEnding(content: string): string { + // Check if the content contains CRLF + if (content.includes('\r\n')) { + return '\r\n'; + } + // Default to LF + return '\n'; +} + +function normalizeLineEndings(content: string): string { + // Convert all line endings to LF for internal processing + return content.replace(/\r\n/g, '\n'); +} + +function preserveLineEndings(newContent: string, originalLineEnding: string): string { + // Ensure all line endings match the original file + if (originalLineEnding === '\r\n') { + return newContent.replace(/\n/g, '\r\n'); + } + return newContent; +} + +// Edit preview type +interface EditPreview { + originalContent: string; + newContent: string; + lineNumber: number; + matchedAnchor?: string; + contextVerified: boolean; +} + +// File editing utilities +async function applyFileEdits(filePath: string, edits: z.infer[]): Promise { + // Read the file and detect its line endings + let currentContent = await fs.readFile(filePath, 'utf-8'); + const originalLineEnding = detectLineEnding(currentContent); + + // Normalize content for processing + currentContent = normalizeLineEndings(currentContent); + const previews: EditPreview[] = []; + let lines = currentContent.split('\n'); + + // Sort edits by line number in descending order + const sortedEdits = [...edits].sort((a, b) => { + if (a.startLine && b.startLine) { + return b.startLine - a.startLine; + } + return 0; + }); + + for (const edit of sortedEdits) { + // Normalize the edit text for comparison + const normalizedOldText = normalizeLineEndings(edit.oldText); + const normalizedNewText = normalizeLineEndings(edit.newText); + + let startIdx = edit.startLine ? edit.startLine - 1 : -1; + + if (edit.findAnchor) { + // Normalize anchor text and search in normalized content + const normalizedAnchor = normalizeLineEndings(edit.findAnchor); + const content = lines.join('\n'); + const anchorIdx = content.indexOf(normalizedAnchor); + if (anchorIdx === -1) { + throw new Error(`Anchor text not found: ${edit.findAnchor}`); + } + const beforeAnchor = content.substring(0, anchorIdx); + const anchorLine = beforeAnchor.split('\n').length - 1; + startIdx = anchorLine + (edit.anchorOffset || 0); + } + + if (startIdx === -1) { + throw new Error('No valid edit position found - need either startLine or findAnchor'); + } + + // Context verification with normalized line endings + let contextVerified = true; + if (edit.beforeContext || edit.afterContext) { + const radius = edit.contextRadius || 3; + const beforeText = normalizeLineEndings(lines.slice(Math.max(0, startIdx - radius), startIdx).join('\n')); + const afterText = normalizeLineEndings(lines.slice(startIdx + 1, startIdx + radius + 1).join('\n')); + + if (edit.beforeContext && !beforeText.includes(normalizeLineEndings(edit.beforeContext))) { + contextVerified = false; + } + if (edit.afterContext && !afterText.includes(normalizeLineEndings(edit.afterContext))) { + contextVerified = false; + } + + if (!contextVerified && edit.verifyState) { + throw new Error( + `Context verification failed at line ${startIdx + 1}.\n` + + `Expected before context: ${edit.beforeContext}\n` + + `Expected after context: ${edit.afterContext}\n` + + `Found before context: ${beforeText}\n` + + `Found after context: ${afterText}` + ); + } + } + + const oldLines = normalizedOldText.split('\n'); + const newLines = normalizedNewText.split('\n'); + + // Content verification with normalized line endings + if (edit.verifyState) { + const existingContent = normalizeLineEndings(lines.slice(startIdx, startIdx + oldLines.length).join('\n')); + if (existingContent !== normalizedOldText) { + throw new Error( + `Edit validation failed: Content mismatch at line ${startIdx + 1}.\n` + + `Expected:\n${edit.oldText}\n` + + `Found:\n${lines.slice(startIdx, startIdx + oldLines.length).join('\n')}` + ); + } + } + + if (edit.dryRun) { + previews.push({ + originalContent: preserveLineEndings(lines.slice(startIdx, startIdx + oldLines.length).join('\n'), originalLineEnding), + newContent: preserveLineEndings(edit.newText, originalLineEnding), + lineNumber: startIdx + 1, + matchedAnchor: edit.findAnchor, + contextVerified + }); + continue; + } + + // Apply the edit based on insertMode + switch (edit.insertMode) { + case 'before': + lines.splice(startIdx, 0, ...newLines); + break; + case 'after': + lines.splice(startIdx + oldLines.length, 0, ...newLines); + break; + default: // 'replace' + lines.splice(startIdx, oldLines.length, ...newLines); + } + + let updatedContent = lines.join('\n'); + + // Preserve original line endings when writing + updatedContent = preserveLineEndings(updatedContent, originalLineEnding); + + // Re-read file if requested + if (edit.readBeforeEdit) { + await fs.writeFile(filePath, updatedContent, 'utf-8'); + currentContent = await fs.readFile(filePath, 'utf-8'); + currentContent = normalizeLineEndings(currentContent); + lines = currentContent.split('\n'); + } + } + + if (edits.some(e => e.dryRun)) { + return previews; + } + + // Preserve original line endings in final content + return preserveLineEndings(lines.join('\n'), originalLineEnding); +} + // Tool handlers server.setRequestHandler(ListToolsRequestSchema, async () => { return { @@ -233,6 +414,29 @@ 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 selective edits to a text file with advanced pattern matching and validation. " + + "Supports multiple edit modes:\n" + + "1. Line-based: Use startLine to specify exact positions\n" + + "2. Pattern-based: Use findAnchor to locate edit points by matching text\n" + + "3. Context-aware: Verify surrounding text with beforeContext/afterContext\n\n" + + "Features:\n" + + "- Dry run mode for previewing changes (dryRun: true)\n" + + "- Multiple insertion modes: 'replace', 'before', 'after'\n" + + "- Anchor-based positioning with offset support\n" + + "- Automatic state refresh between edits (readBeforeEdit)\n" + + "- Context verification to ensure edit safety\n\n" + + "Recommended workflow:\n" + + "1. Use dryRun to preview changes\n" + + "2. Use findAnchor for resilient positioning\n" + + "3. Enable readBeforeEdit for multi-step changes\n" + + "4. Verify context when position is critical\n\n" + + "This is safer than complete file overwrites as it verifies existing content " + + "and supports granular changes. Only works within allowed directories.", + inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput, + }, { name: "create_directory", description: @@ -346,6 +550,36 @@ 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); + + // If it's a dry run, format the previews + if (Array.isArray(result)) { + const previewText = result.map(preview => + `Line ${preview.lineNumber}:\n` + + `${preview.matchedAnchor ? `Matched anchor: ${preview.matchedAnchor}\n` : ''}` + + `Context verified: ${preview.contextVerified}\n` + + `Original:\n${preview.originalContent}\n` + + `New:\n${preview.newContent}\n` + ).join('\n---\n'); + + return { + content: [{ type: "text", text: `Edit preview:\n${previewText}` }], + }; + } + + // Otherwise write the changes + await fs.writeFile(validPath, result, "utf-8"); + return { + content: [{ type: "text", text: `Successfully applied edits to ${parsed.data.path}` }], + }; + } + case "create_directory": { const parsed = CreateDirectoryArgsSchema.safeParse(args); if (!parsed.success) { From 9f2a77e044c59b48e3d6b89147c34499c4513795 Mon Sep 17 00:00:00 2001 From: Marc Goodner Date: Mon, 2 Dec 2024 17:43:17 -0800 Subject: [PATCH 02/18] updated readme --- src/filesystem/README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/filesystem/README.md b/src/filesystem/README.md index c2950cd..99337d3 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -36,6 +36,35 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio - `path` (string): File location - `content` (string): File content +- **edit_file** + - Make selective edits to files with advanced pattern matching + - Features: + - Multiple positioning modes: + - Line-based: Specify exact line numbers + - Pattern-based: Find positions using anchor text + - Context-aware: Verify surrounding content + - Insert modes: 'replace', 'before', or 'after' content + - Dry run preview of changes + - Cross-platform line ending support (CRLF/LF) + - Git-friendly content verification + - Inputs: + - `path` (string): File to edit + - `edits` (array): List of edit operations + - `startLine?` (number): Line number for edit (optional) + - `findAnchor?` (string): Text to locate edit position (optional) + - `anchorOffset` (number): Lines to offset from anchor (default: 0) + - `oldText` (string): Content to replace/verify + - `newText` (string): New content to insert + - `insertMode` (string): 'replace', 'before', or 'after' (default: 'replace') + - `beforeContext?` (string): Expected content before edit point (optional) + - `afterContext?` (string): Expected content after edit point (optional) + - `contextRadius` (number): Lines to check for context (default: 3) + - `verifyState` (boolean): Verify content matches before editing (default: true) + - `readBeforeEdit` (boolean): Refresh file state between edits (default: false) + - `dryRun` (boolean): Preview changes without applying them (default: false) + - Returns preview information for dry runs, otherwise applies changes + - Preserves original line endings and handles Git auto CRLF/LF + - **create_directory** - Create new directory or ensure it exists - Input: `path` (string) From a1bc14d38dc6b224b6074045c4794c353b21b19a Mon Sep 17 00:00:00 2001 From: Marc Goodner Date: Mon, 2 Dec 2024 17:48:19 -0800 Subject: [PATCH 03/18] improve line ending docs --- src/filesystem/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index ebe84a0..8ebae2f 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -224,6 +224,12 @@ async function searchFiles( } // Line ending detection and normalization utilities +// These functions ensure consistent behavior across different platforms and Git configurations. +// They handle the following scenarios: +// - Windows CRLF (\r\n) vs Unix LF (\n) line endings +// - Git's core.autocrlf setting converting line endings +// - Mixed line endings within the same file +// This makes the edit functionality reliable regardless of the development environment. function detectLineEnding(content: string): string { // Check if the content contains CRLF if (content.includes('\r\n')) { From 431d90971722824e7c2966a4399b235f09cf3c52 Mon Sep 17 00:00:00 2001 From: Marc Goodner Date: Mon, 2 Dec 2024 18:03:36 -0800 Subject: [PATCH 04/18] indentation support --- src/filesystem/index.ts | 126 +++++++++++++++++++++++----------------- 1 file changed, 73 insertions(+), 53 deletions(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 8ebae2f..44aa948 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -223,13 +223,34 @@ async function searchFiles( return results; } -// Line ending detection and normalization utilities -// These functions ensure consistent behavior across different platforms and Git configurations. -// They handle the following scenarios: -// - Windows CRLF (\r\n) vs Unix LF (\n) line endings -// - Git's core.autocrlf setting converting line endings -// - Mixed line endings within the same file -// This makes the edit functionality reliable regardless of the development environment. +// Content normalization utilities +// These functions handle: +// - Line ending normalization (CRLF vs LF) +// - Indentation preservation and normalization +// - Git's core.autocrlf setting +// - Mixed line endings +// This makes the edit functionality reliable across different environments and formatting styles + +function normalizeForComparison(content: string): string { + // First normalize line endings + let normalized = content.replace(/\r\n/g, '\n'); + // Remove leading/trailing whitespace from each line while preserving empty lines + normalized = normalized.split('\n') + .map(line => line.trim()) + .join('\n'); + return normalized; +} + +function preserveIndentation(newContent: string, originalContent: string): string { + const originalLines = originalContent.split(/\r?\n/); + const indentMatch = originalLines.find(line => line.trim())?.match(/^\s*/); + const baseIndent = indentMatch ? indentMatch[0] : ''; + + return newContent.split(/\r?\n/) + .map(line => line.trim() ? baseIndent + line : line) + .join(originalContent.includes('\r\n') ? '\r\n' : '\n'); +} + function detectLineEnding(content: string): string { // Check if the content contains CRLF if (content.includes('\r\n')) { @@ -263,14 +284,10 @@ interface EditPreview { // File editing utilities async function applyFileEdits(filePath: string, edits: z.infer[]): Promise { - // Read the file and detect its line endings + // Read the file content let currentContent = await fs.readFile(filePath, 'utf-8'); - const originalLineEnding = detectLineEnding(currentContent); - - // Normalize content for processing - currentContent = normalizeLineEndings(currentContent); const previews: EditPreview[] = []; - let lines = currentContent.split('\n'); + let lines = currentContent.split(/\r?\n/); // Sort edits by line number in descending order const sortedEdits = [...edits].sort((a, b) => { @@ -281,73 +298,76 @@ async function applyFileEdits(filePath: string, edits: z.infer Date: Mon, 2 Dec 2024 19:03:48 -0800 Subject: [PATCH 05/18] line numbering improvements --- src/filesystem/index.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 44aa948..ed6f627 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -301,20 +301,21 @@ async function applyFileEdits(filePath: string, edits: z.infer Date: Mon, 2 Dec 2024 19:21:05 -0800 Subject: [PATCH 06/18] simplify text replacement approach for better reliability --- src/filesystem/index.ts | 203 +++++++++++++++------------------------- 1 file changed, 77 insertions(+), 126 deletions(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index ed6f627..f88036d 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -107,19 +107,30 @@ const WriteFileArgsSchema = z.object({ }); const EditOperation = z.object({ - startLine: z.number().int().min(1).optional(), - contextLines: z.number().int().min(0).default(3), - oldText: z.string(), - newText: z.string(), - verifyState: z.boolean().default(true), - readBeforeEdit: z.boolean().default(false), - findAnchor: z.string().optional(), - anchorOffset: z.number().int().default(0), - beforeContext: z.string().optional(), - afterContext: z.string().optional(), - contextRadius: z.number().int().min(0).default(3), - insertMode: z.enum(['replace', 'before', 'after']).default('replace'), - dryRun: z.boolean().default(false), + // Primary edit specification + oldText: z.string().describe('Exact text to match, including whitespace/formatting'), + newText: z.string().describe('Replacement text with desired formatting'), + + // Location finding (one of these should be provided) + startLine: z.number().int().min(1).optional().describe('Exact line number to start edit'), + findAnchor: z.string().optional().describe('Text to search for to locate edit position'), + + // Edit behavior + insertMode: z.enum(['replace', 'before', 'after']).default('replace') + .describe('Whether to replace matched text or insert before/after it'), + verifyState: z.boolean().default(true) + .describe('Whether to verify exact text matches before editing'), + readBeforeEdit: z.boolean().default(false) + .describe('Whether to re-read file between multiple edits'), + + // Optional context verification + beforeContext: z.string().optional().describe('Text that should appear before edit point'), + afterContext: z.string().optional().describe('Text that should appear after edit point'), + contextRadius: z.number().int().min(0).default(3) + .describe('Number of lines to check for context matches'), + + // Preview mode + dryRun: z.boolean().default(false).describe('Preview changes without applying them'), }); const EditFileArgsSchema = z.object({ @@ -224,53 +235,14 @@ async function searchFiles( } // Content normalization utilities -// These functions handle: -// - Line ending normalization (CRLF vs LF) -// - Indentation preservation and normalization -// - Git's core.autocrlf setting -// - Mixed line endings -// This makes the edit functionality reliable across different environments and formatting styles - +// Used only for fuzzy matching of anchor text and context verification +// Does not affect the actual content replacement function normalizeForComparison(content: string): string { - // First normalize line endings - let normalized = content.replace(/\r\n/g, '\n'); - // Remove leading/trailing whitespace from each line while preserving empty lines - normalized = normalized.split('\n') + // Normalize line endings and whitespace for comparison only + return content.replace(/\r\n/g, '\n') + .split('\n') .map(line => line.trim()) .join('\n'); - return normalized; -} - -function preserveIndentation(newContent: string, originalContent: string): string { - const originalLines = originalContent.split(/\r?\n/); - const indentMatch = originalLines.find(line => line.trim())?.match(/^\s*/); - const baseIndent = indentMatch ? indentMatch[0] : ''; - - return newContent.split(/\r?\n/) - .map(line => line.trim() ? baseIndent + line : line) - .join(originalContent.includes('\r\n') ? '\r\n' : '\n'); -} - -function detectLineEnding(content: string): string { - // Check if the content contains CRLF - if (content.includes('\r\n')) { - return '\r\n'; - } - // Default to LF - return '\n'; -} - -function normalizeLineEndings(content: string): string { - // Convert all line endings to LF for internal processing - return content.replace(/\r\n/g, '\n'); -} - -function preserveLineEndings(newContent: string, originalLineEnding: string): string { - // Ensure all line endings match the original file - if (originalLineEnding === '\r\n') { - return newContent.replace(/\n/g, '\r\n'); - } - return newContent; } // Edit preview type @@ -287,48 +259,37 @@ async function applyFileEdits(filePath: string, edits: z.infer { - if (a.startLine && b.startLine) { - return b.startLine - a.startLine; - } - return 0; - }); - - for (const edit of sortedEdits) { - let startIdx = edit.startLine ? edit.startLine - 1 : -1; + for (const edit of edits) { + let editContent = currentContent; + let editPosition = -1; + // Find the edit position using anchor if provided if (edit.findAnchor) { - // Use line-by-line comparison for accurate anchor matching - let foundLine = -1; + const normalizedContent = normalizeForComparison(currentContent); const normalizedAnchor = normalizeForComparison(edit.findAnchor); + const anchorPos = normalizedContent.indexOf(normalizedAnchor); - for (let i = 0; i < lines.length; i++) { - const normalizedLine = normalizeForComparison(lines[i]); - if (normalizedLine.includes(normalizedAnchor)) { - foundLine = i; - break; - } - } - if (foundLine === -1) { + if (anchorPos === -1) { throw new Error(`Edit failed - anchor text not found: ${edit.findAnchor} in ${filePath}`); } - startIdx = foundLine + (edit.anchorOffset || 0); - } - - if (startIdx === -1) { + + // Map normalized position back to original content + editPosition = currentContent.slice(0, anchorPos).split('\n').length - 1; + } else if (edit.startLine) { + editPosition = edit.startLine - 1; + } else { throw new Error(`Edit failed - no valid position found in ${filePath}. Operation requires either startLine or findAnchor`); } - - // Context verification with normalized comparison - let contextVerified = true; + + // Verify context if provided if (edit.beforeContext || edit.afterContext) { + const lines = currentContent.split('\n'); const radius = edit.contextRadius || 3; - const beforeText = lines.slice(Math.max(0, startIdx - radius), startIdx).join('\n'); - const afterText = lines.slice(startIdx + 1, startIdx + radius + 1).join('\n'); + const beforeText = lines.slice(Math.max(0, editPosition - radius), editPosition).join('\n'); + const afterText = lines.slice(editPosition + 1, editPosition + radius + 1).join('\n'); + let contextVerified = true; if (edit.beforeContext && !normalizeForComparison(beforeText).includes(normalizeForComparison(edit.beforeContext))) { contextVerified = false; } @@ -338,67 +299,58 @@ async function applyFileEdits(filePath: string, edits: z.infer Date: Mon, 2 Dec 2024 19:38:33 -0800 Subject: [PATCH 07/18] simplify edit_file to use git-style diffs and substring matching --- src/filesystem/index.ts | 187 ++++++++-------------------------------- 1 file changed, 38 insertions(+), 149 deletions(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index f88036d..ff113af 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -107,30 +107,12 @@ const WriteFileArgsSchema = z.object({ }); const EditOperation = z.object({ - // Primary edit specification - oldText: z.string().describe('Exact text to match, including whitespace/formatting'), - newText: z.string().describe('Replacement text with desired formatting'), - - // Location finding (one of these should be provided) - startLine: z.number().int().min(1).optional().describe('Exact line number to start edit'), - findAnchor: z.string().optional().describe('Text to search for to locate edit position'), - - // Edit behavior - insertMode: z.enum(['replace', 'before', 'after']).default('replace') - .describe('Whether to replace matched text or insert before/after it'), - verifyState: z.boolean().default(true) - .describe('Whether to verify exact text matches before editing'), - readBeforeEdit: z.boolean().default(false) - .describe('Whether to re-read file between multiple edits'), - - // Optional context verification - beforeContext: z.string().optional().describe('Text that should appear before edit point'), - afterContext: z.string().optional().describe('Text that should appear after edit point'), - contextRadius: z.number().int().min(0).default(3) - .describe('Number of lines to check for context matches'), - - // Preview mode - dryRun: z.boolean().default(false).describe('Preview changes without applying them'), + // The text to search for + oldText: z.string().describe('Text to search for - can be a substring of the target'), + // The new text to replace with + newText: z.string().describe('Text to replace the found text with'), + // Optional: preview changes without applying them + dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format') }); const EditFileArgsSchema = z.object({ @@ -234,131 +216,59 @@ async function searchFiles( return results; } -// Content normalization utilities -// Used only for fuzzy matching of anchor text and context verification -// Does not affect the actual content replacement -function normalizeForComparison(content: string): string { - // Normalize line endings and whitespace for comparison only - return content.replace(/\r\n/g, '\n') - .split('\n') - .map(line => line.trim()) - .join('\n'); -} - // Edit preview type interface EditPreview { - originalContent: string; - newContent: string; + original: string; + modified: string; lineNumber: number; - matchedAnchor?: string; - contextVerified: boolean; + preview: string; // Git-style diff format } // File editing utilities async function applyFileEdits(filePath: string, edits: z.infer[]): Promise { - // Read the file content - let currentContent = await fs.readFile(filePath, 'utf-8'); + let content = await fs.readFile(filePath, 'utf-8'); const previews: EditPreview[] = []; for (const edit of edits) { - let editContent = currentContent; - let editPosition = -1; - - // Find the edit position using anchor if provided - if (edit.findAnchor) { - const normalizedContent = normalizeForComparison(currentContent); - const normalizedAnchor = normalizeForComparison(edit.findAnchor); - const anchorPos = normalizedContent.indexOf(normalizedAnchor); - - if (anchorPos === -1) { - throw new Error(`Edit failed - anchor text not found: ${edit.findAnchor} in ${filePath}`); - } - - // Map normalized position back to original content - editPosition = currentContent.slice(0, anchorPos).split('\n').length - 1; - } else if (edit.startLine) { - editPosition = edit.startLine - 1; - } else { - throw new Error(`Edit failed - no valid position found in ${filePath}. Operation requires either startLine or findAnchor`); - } - - // Verify context if provided - if (edit.beforeContext || edit.afterContext) { - const lines = currentContent.split('\n'); - const radius = edit.contextRadius || 3; - const beforeText = lines.slice(Math.max(0, editPosition - radius), editPosition).join('\n'); - const afterText = lines.slice(editPosition + 1, editPosition + radius + 1).join('\n'); - - let contextVerified = true; - if (edit.beforeContext && !normalizeForComparison(beforeText).includes(normalizeForComparison(edit.beforeContext))) { - contextVerified = false; - } - if (edit.afterContext && !normalizeForComparison(afterText).includes(normalizeForComparison(edit.afterContext))) { - contextVerified = false; - } - - if (!contextVerified && edit.verifyState) { - throw new Error( - `Edit failed - context verification failed in ${filePath} at line ${editPosition + 1}\n` + - `Expected before context: ${edit.beforeContext}\n` + - `Expected after context: ${edit.afterContext}\n` + - `Found before context: ${beforeText}\n` + - `Found after context: ${afterText}\n` - ); - } - } - - // Look for exact match of oldText - const searchStr = edit.oldText; - const searchPos = currentContent.indexOf(searchStr); - - if (searchPos === -1 && edit.verifyState) { + const pos = content.indexOf(edit.oldText); + if (pos === -1) { throw new Error( - `Edit failed - content not found in ${filePath}\n` + - `Expected to find:\n${searchStr}\n` + `Search text not found in ${filePath}:\n${edit.oldText}` ); } + // Calculate line number for reporting + const lineNumber = content.slice(0, pos).split(/\r?\n/).length; + if (edit.dryRun) { + // Create git-style diff preview + const preview = [ + `@@ line ${lineNumber} @@`, + '<<<<<<< ORIGINAL', + edit.oldText, + '=======', + edit.newText, + '>>>>>>> MODIFIED' + ].join('\n'); + previews.push({ - originalContent: searchStr, - newContent: edit.newText, - lineNumber: editPosition + 1, - matchedAnchor: edit.findAnchor, - contextVerified: true + original: edit.oldText, + modified: edit.newText, + lineNumber, + preview }); continue; } - // Apply the edit based on insertMode - switch (edit.insertMode) { - case 'before': - editContent = currentContent.slice(0, searchPos) + - edit.newText + currentContent.slice(searchPos); - break; - case 'after': - editContent = currentContent.slice(0, searchPos + searchStr.length) + - edit.newText + currentContent.slice(searchPos + searchStr.length); - break; - default: // 'replace' - editContent = currentContent.slice(0, searchPos) + - edit.newText + currentContent.slice(searchPos + searchStr.length); - } - - // Update content for next edit - if (edit.readBeforeEdit) { - await fs.writeFile(filePath, editContent, 'utf-8'); - currentContent = await fs.readFile(filePath, 'utf-8'); - } else { - currentContent = editContent; - } + // Apply the edit + content = content.slice(0, pos) + edit.newText + content.slice(pos + edit.oldText.length); } if (edits.some(e => e.dryRun)) { return previews; } - return currentContent; + return content; } // Tool handlers @@ -395,24 +305,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { { name: "edit_file", description: - "Make selective edits to a text file with advanced pattern matching and validation. " + - "Supports multiple edit modes:\n" + - "1. Line-based: Use startLine to specify exact positions\n" + - "2. Pattern-based: Use findAnchor to locate edit points by matching text\n" + - "3. Context-aware: Verify surrounding text with beforeContext/afterContext\n\n" + - "Features:\n" + - "- Dry run mode for previewing changes (dryRun: true)\n" + - "- Multiple insertion modes: 'replace', 'before', 'after'\n" + - "- Anchor-based positioning with offset support\n" + - "- Automatic state refresh between edits (readBeforeEdit)\n" + - "- Context verification to ensure edit safety\n\n" + - "Recommended workflow:\n" + - "1. Use dryRun to preview changes\n" + - "2. Use findAnchor for resilient positioning\n" + - "3. Enable readBeforeEdit for multi-step changes\n" + - "4. Verify context when position is critical\n\n" + - "This is safer than complete file overwrites as it verifies existing content " + - "and supports granular changes. Only works within allowed directories.", + "Make selective edits to a text file using simple search and replace with git-style preview format. " + + "Finds text to replace using substring matching and shows changes in a familiar git-diff format. " + + "Use dry run mode to preview changes before applying them. " + + "Only works within allowed directories.", inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput, }, { @@ -538,14 +434,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // If it's a dry run, format the previews if (Array.isArray(result)) { - const previewText = result.map(preview => - `Line ${preview.lineNumber}:\n` + - `${preview.matchedAnchor ? `Matched anchor: ${preview.matchedAnchor}\n` : ''}` + - `Context verified: ${preview.contextVerified}\n` + - `Original:\n${preview.originalContent}\n` + - `New:\n${preview.newContent}\n` - ).join('\n---\n'); - + const previewText = result.map(preview => preview.preview).join('\n\n'); return { content: [{ type: "text", text: `Edit preview:\n${previewText}` }], }; From da0ef8106740069b77876843f22570aa870a80df Mon Sep 17 00:00:00 2001 From: Marc Goodner Date: Tue, 3 Dec 2024 07:21:59 -0800 Subject: [PATCH 08/18] update readme --- src/filesystem/README.md | 32 +++++++++----------------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/src/filesystem/README.md b/src/filesystem/README.md index 99337d3..f0e34cd 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -37,33 +37,19 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio - `content` (string): File content - **edit_file** - - Make selective edits to files with advanced pattern matching + - Make selective edits using search and replace - Features: - - Multiple positioning modes: - - Line-based: Specify exact line numbers - - Pattern-based: Find positions using anchor text - - Context-aware: Verify surrounding content - - Insert modes: 'replace', 'before', or 'after' content - - Dry run preview of changes - - Cross-platform line ending support (CRLF/LF) - - Git-friendly content verification + - Simple substring matching for finding text + - Git-style preview format for changes + - Preview changes with dry run mode + - Preserves original file formatting and indentation - Inputs: - `path` (string): File to edit - `edits` (array): List of edit operations - - `startLine?` (number): Line number for edit (optional) - - `findAnchor?` (string): Text to locate edit position (optional) - - `anchorOffset` (number): Lines to offset from anchor (default: 0) - - `oldText` (string): Content to replace/verify - - `newText` (string): New content to insert - - `insertMode` (string): 'replace', 'before', or 'after' (default: 'replace') - - `beforeContext?` (string): Expected content before edit point (optional) - - `afterContext?` (string): Expected content after edit point (optional) - - `contextRadius` (number): Lines to check for context (default: 3) - - `verifyState` (boolean): Verify content matches before editing (default: true) - - `readBeforeEdit` (boolean): Refresh file state between edits (default: false) - - `dryRun` (boolean): Preview changes without applying them (default: false) + - `oldText` (string): Text to search for (can be substring) + - `newText` (string): Text to replace with + - `dryRun` (boolean): Preview changes without applying (default: false) - Returns preview information for dry runs, otherwise applies changes - - Preserves original line endings and handles Git auto CRLF/LF - **create_directory** - Create new directory or ensure it exists @@ -127,4 +113,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. \ No newline at end of file From de4976903408f72e7c1a7f2af442526bf4a63642 Mon Sep 17 00:00:00 2001 From: Marc Goodner Date: Tue, 3 Dec 2024 08:51:01 -0800 Subject: [PATCH 09/18] limitations added to readme --- src/filesystem/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/filesystem/README.md b/src/filesystem/README.md index f0e34cd..31995d8 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -42,7 +42,11 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio - Simple substring matching for finding text - Git-style preview format for changes - Preview changes with dry run mode - - Preserves original file formatting and indentation + - Preserves consistent indentation patterns + - Limitations: + - Intended for content changes, not code formatting + - Mixed tabs/spaces can cause pattern matching issues + - Use code formatters (e.g., Prettier, ESLint) before content edits - Inputs: - `path` (string): File to edit - `edits` (array): List of edit operations From dc9f56720feb4edc15d6dbd7d51b8c4ddd6ec92c Mon Sep 17 00:00:00 2001 From: Marc Goodner Date: Tue, 3 Dec 2024 16:45:04 -0800 Subject: [PATCH 10/18] same compare for edit and dry run --- src/filesystem/index.ts | 62 +++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 36 deletions(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index ff113af..bf43359 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -111,13 +111,13 @@ const EditOperation = z.object({ oldText: z.string().describe('Text to search for - can be a substring of the target'), // The new text to replace with newText: z.string().describe('Text to replace the found text with'), - // Optional: preview changes without applying them - dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format') }); const EditFileArgsSchema = z.object({ path: z.string(), edits: z.array(EditOperation), + // Optional: preview changes without applying them + dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format') }); const CreateDirectoryArgsSchema = z.object({ @@ -225,7 +225,7 @@ interface EditPreview { } // File editing utilities -async function applyFileEdits(filePath: string, edits: z.infer[]): Promise { +async function applyFileEdits(filePath: string, edits: Array<{oldText: string, newText: string}>, dryRun: boolean = false): Promise { let content = await fs.readFile(filePath, 'utf-8'); const previews: EditPreview[] = []; @@ -237,38 +237,29 @@ async function applyFileEdits(filePath: string, edits: z.infer>>>>>> MODIFIED' + ].join('\n'); - if (edit.dryRun) { - // Create git-style diff preview - const preview = [ - `@@ line ${lineNumber} @@`, - '<<<<<<< ORIGINAL', - edit.oldText, - '=======', - edit.newText, - '>>>>>>> MODIFIED' - ].join('\n'); - - previews.push({ - original: edit.oldText, - modified: edit.newText, - lineNumber, - preview - }); - continue; + previews.push({ + original: edit.oldText, + modified: edit.newText, + lineNumber, + preview + }); + + if (!dryRun) { + content = content.slice(0, pos) + edit.newText + content.slice(pos + edit.oldText.length); } - - // Apply the edit - content = content.slice(0, pos) + edit.newText + content.slice(pos + edit.oldText.length); } - if (edits.some(e => e.dryRun)) { - return previews; - } - - return content; + return dryRun ? previews : content; } // Tool handlers @@ -430,18 +421,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { 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); + const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun); // If it's a dry run, format the previews - if (Array.isArray(result)) { - const previewText = result.map(preview => preview.preview).join('\n\n'); + if (parsed.data.dryRun) { + const previewText = (result as EditPreview[]).map(preview => preview.preview).join('\n\n'); return { content: [{ type: "text", text: `Edit preview:\n${previewText}` }], }; } - - // Otherwise write the changes - await fs.writeFile(validPath, result, "utf-8"); + + await fs.writeFile(validPath, result as string, "utf-8"); return { content: [{ type: "text", text: `Successfully applied edits to ${parsed.data.path}` }], }; From 1ec75e87fc6ab1e843e866ac4fffa8cd4cd535af Mon Sep 17 00:00:00 2001 From: Marc Goodner Date: Wed, 4 Dec 2024 07:27:02 -0800 Subject: [PATCH 11/18] improve multiline text editing reliability by tracking positions and applying edits in reverse order --- src/filesystem/index.ts | 55 ++++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index bf43359..e736b1c 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -216,6 +216,29 @@ async function searchFiles( return results; } +interface Position { + start: number; + end: number; + lineNumber: number; +} + +function findTextPosition(content: string, searchText: string): Position { + // Handle different line endings + const normalized = content.replace(/\r\n/g, '\n'); + const searchNormalized = searchText.replace(/\r\n/g, '\n'); + + const pos = normalized.indexOf(searchNormalized); + if (pos === -1) { + throw new Error(`Text not found:\n${searchText}`); + } + + return { + start: pos, + end: pos + searchText.length, + lineNumber: normalized.slice(0, pos).split('\n').length + }; +} + // Edit preview type interface EditPreview { original: string; @@ -225,37 +248,39 @@ interface EditPreview { } // File editing utilities -async function applyFileEdits(filePath: string, edits: Array<{oldText: string, newText: string}>, dryRun: boolean = false): Promise { +async function applyFileEdits(filePath: string, edits: Array<{oldText: string, newText: string}>, dryRun = false): Promise { let content = await fs.readFile(filePath, 'utf-8'); const previews: EditPreview[] = []; - for (const edit of edits) { - const pos = content.indexOf(edit.oldText); - if (pos === -1) { - throw new Error( - `Search text not found in ${filePath}:\n${edit.oldText}` - ); - } - - const lineNumber = content.slice(0, pos).split(/\r?\n/).length; + // Find all positions first + const positions = edits.map(edit => ({ + edit, + position: findTextPosition(content, edit.oldText) + })); + + // Sort by position in reverse order + positions.sort((a, b) => b.position.start - a.position.start); + + // Apply edits from end to start + for (const {edit, position} of positions) { const preview = [ - `@@ line ${lineNumber} @@`, + `@@ line ${position.lineNumber} @@`, '<<<<<<< ORIGINAL', edit.oldText, '=======', edit.newText, - '>>>>>>> MODIFIED' + '>>>>>>> MODIFIED' ].join('\n'); previews.push({ original: edit.oldText, modified: edit.newText, - lineNumber, + lineNumber: position.lineNumber, preview }); - + if (!dryRun) { - content = content.slice(0, pos) + edit.newText + content.slice(pos + edit.oldText.length); + content = content.slice(0, position.start) + edit.newText + content.slice(position.end); } } From bb7925fe11e3500dad6fc1d4baf5e20925fc2358 Mon Sep 17 00:00:00 2001 From: Marc Goodner Date: Wed, 4 Dec 2024 07:35:12 -0800 Subject: [PATCH 12/18] improve whitespace sensitivity in multiline text matching --- src/filesystem/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index e736b1c..12dde55 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -231,10 +231,14 @@ function findTextPosition(content: string, searchText: string): Position { if (pos === -1) { throw new Error(`Text not found:\n${searchText}`); } + + // Map back to original content position + const originalPos = content.slice(0, pos).replace(/[ \t]+/g, ' ').length; + const originalEnd = originalPos + searchText.length; return { start: pos, - end: pos + searchText.length, + end: originalEnd, lineNumber: normalized.slice(0, pos).split('\n').length }; } From 02ff589f5803c8412eb50d4e80691acd5979d35d Mon Sep 17 00:00:00 2001 From: Marc Goodner Date: Wed, 4 Dec 2024 19:21:04 -0800 Subject: [PATCH 13/18] user aider inspired diff approach --- src/filesystem/index.ts | 200 ++++++++++++++++++++++++++-------------- 1 file changed, 133 insertions(+), 67 deletions(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 12dde55..3306f31 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -216,79 +216,146 @@ async function searchFiles( return results; } -interface Position { - start: number; - end: number; +interface DiffLine { + type: 'context' | 'addition' | 'deletion'; + content: string; lineNumber: number; } -function findTextPosition(content: string, searchText: string): Position { - // Handle different line endings - const normalized = content.replace(/\r\n/g, '\n'); - const searchNormalized = searchText.replace(/\r\n/g, '\n'); +function createUnifiedDiff(originalLines: string[], newLines: string[], contextSize: number = 3): string { + const differ = new Array(); + let lineNumber = 1; - const pos = normalized.indexOf(searchNormalized); - if (pos === -1) { - throw new Error(`Text not found:\n${searchText}`); + // Helper to add context lines + function addContext(lines: string[], start: number, count: number) { + for (let i = 0; i < count && start + i < lines.length; i++) { + differ.push({ + type: 'context', + content: lines[start + i], + lineNumber: start + i + 1 + }); + } } - // Map back to original content position - const originalPos = content.slice(0, pos).replace(/[ \t]+/g, ' ').length; - const originalEnd = originalPos + searchText.length; + // Find the differences using longest common subsequence + const changes: Array<{type: 'context' | 'addition' | 'deletion', line: string, index: number}> = []; + let i = 0, j = 0; - return { - start: pos, - end: originalEnd, - lineNumber: normalized.slice(0, pos).split('\n').length - }; -} + while (i < originalLines.length || j < newLines.length) { + if (i < originalLines.length && j < newLines.length && originalLines[i] === newLines[j]) { + changes.push({type: 'context', line: originalLines[i], index: i}); + i++; + j++; + } else { + if (i < originalLines.length) { + changes.push({type: 'deletion', line: originalLines[i], index: i}); + i++; + } + if (j < newLines.length) { + changes.push({type: 'addition', line: newLines[j], index: j}); + j++; + } + } + } -// Edit preview type -interface EditPreview { - original: string; - modified: string; - lineNumber: number; - preview: string; // Git-style diff format -} + // Group changes into hunks with context + let currentHunk: DiffLine[] = []; + let hunks: DiffLine[][] = []; + let lastChangeIndex = -1; -// File editing utilities -async function applyFileEdits(filePath: string, edits: Array<{oldText: string, newText: string}>, dryRun = false): Promise { - let content = await fs.readFile(filePath, 'utf-8'); - const previews: EditPreview[] = []; - - // Find all positions first - const positions = edits.map(edit => ({ - edit, - position: findTextPosition(content, edit.oldText) - })); - - // Sort by position in reverse order - positions.sort((a, b) => b.position.start - a.position.start); - - // Apply edits from end to start - for (const {edit, position} of positions) { - const preview = [ - `@@ line ${position.lineNumber} @@`, - '<<<<<<< ORIGINAL', - edit.oldText, - '=======', - edit.newText, - '>>>>>>> MODIFIED' - ].join('\n'); + for (let i = 0; i < changes.length; i++) { + const change = changes[i]; - previews.push({ - original: edit.oldText, - modified: edit.newText, - lineNumber: position.lineNumber, - preview - }); - - if (!dryRun) { - content = content.slice(0, position.start) + edit.newText + content.slice(position.end); + if (change.type !== 'context' || + (lastChangeIndex >= 0 && i - lastChangeIndex <= contextSize * 2)) { + if (change.type !== 'context') { + lastChangeIndex = i; + } + currentHunk.push({ + type: change.type, + content: change.line, + lineNumber: change.index + 1 + }); + } else { + if (currentHunk.length > 0) { + hunks.push(currentHunk); + currentHunk = []; + } } } - return dryRun ? previews : content; + if (currentHunk.length > 0) { + hunks.push(currentHunk); + } + + // Format the diff output + let diffOutput = ''; + + for (const hunk of hunks) { + const startLine = hunk[0].lineNumber; + const endLine = hunk[hunk.length - 1].lineNumber; + + diffOutput += `@@ -${startLine},${endLine} @@\n`; + + for (const line of hunk) { + const prefix = line.type === 'addition' ? '+' : + line.type === 'deletion' ? '-' : ' '; + diffOutput += `${prefix}${line.content}\n`; + } + + diffOutput += '\n'; + } + + return diffOutput; +} + +// File editing utilities +async function applyFileEdits( + filePath: string, + edits: Array<{oldText: string, newText: string}>, + dryRun = false +): Promise { + let content = await fs.readFile(filePath, 'utf-8'); + const originalLines = content.split('\n'); + let modifiedContent = content; + + // First, validate all edits can be applied + const positions = edits.map(edit => { + const pos = modifiedContent.indexOf(edit.oldText); + if (pos === -1) { + throw new Error(`Text not found:\n${edit.oldText}`); + } + return { + edit, + position: pos, + length: edit.oldText.length + }; + }); + + // Sort positions in reverse order to apply from end to start + positions.sort((a, b) => b.position - a.position); + + if (dryRun) { + // For dry run, create a unified diff preview + for (const {edit, position} of positions) { + modifiedContent = + modifiedContent.slice(0, position) + + edit.newText + + modifiedContent.slice(position + edit.oldText.length); + } + + const modifiedLines = modifiedContent.split('\n'); + return createUnifiedDiff(originalLines, modifiedLines); + } else { + // Apply the edits + for (const {edit, position} of positions) { + modifiedContent = + modifiedContent.slice(0, position) + + edit.newText + + modifiedContent.slice(position + edit.oldText.length); + } + return modifiedContent; + } } // Tool handlers @@ -325,9 +392,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { { name: "edit_file", description: - "Make selective edits to a text file using simple search and replace with git-style preview format. " + - "Finds text to replace using substring matching and shows changes in a familiar git-diff format. " + - "Use dry run mode to preview changes before applying them. " + + "Make selective edits to a text file using search and replace with unified diff previews. " + + "Shows changes in standard unified diff format with context lines, similar to git diff. " + + "Use dry run mode to preview changes in patch format before applying them. " + "Only works within allowed directories.", inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput, }, @@ -452,15 +519,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const validPath = await validatePath(parsed.data.path); const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun); - // If it's a dry run, format the previews + // If it's a dry run, show the unified diff if (parsed.data.dryRun) { - const previewText = (result as EditPreview[]).map(preview => preview.preview).join('\n\n'); return { - content: [{ type: "text", text: `Edit preview:\n${previewText}` }], + content: [{ type: "text", text: `Edit preview:\n${result}` }], }; } - await fs.writeFile(validPath, result as string, "utf-8"); + await fs.writeFile(validPath, result, "utf-8"); return { content: [{ type: "text", text: `Successfully applied edits to ${parsed.data.path}` }], }; From b6e052946a091cb053ff08c418dd65d0498926e5 Mon Sep 17 00:00:00 2001 From: Marc Goodner Date: Wed, 4 Dec 2024 20:13:58 -0800 Subject: [PATCH 14/18] use the diff package, aider inspired search --- src/filesystem/index.ts | 357 +++++++++++++++++++++++------------- src/filesystem/package.json | 4 +- 2 files changed, 229 insertions(+), 132 deletions(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 3306f31..88f68ab 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -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); @@ -216,146 +217,238 @@ async function searchFiles( return results; } -interface DiffLine { - type: 'context' | 'addition' | 'deletion'; - content: string; - lineNumber: number; +// file editing and diffing utilities +function createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string { + return createTwoFilesPatch( + filepath, + filepath, + originalContent, + newContent, + 'original', + 'modified' + ); } -function createUnifiedDiff(originalLines: string[], newLines: string[], contextSize: number = 3): string { - const differ = new Array(); - let lineNumber = 1; - - // Helper to add context lines - function addContext(lines: string[], start: number, count: number) { - for (let i = 0; i < count && start + i < lines.length; i++) { - differ.push({ - type: 'context', - content: lines[start + i], - lineNumber: start + i + 1 - }); - } - } - - // Find the differences using longest common subsequence - const changes: Array<{type: 'context' | 'addition' | 'deletion', line: string, index: number}> = []; - let i = 0, j = 0; - - while (i < originalLines.length || j < newLines.length) { - if (i < originalLines.length && j < newLines.length && originalLines[i] === newLines[j]) { - changes.push({type: 'context', line: originalLines[i], index: i}); - i++; - j++; - } else { - if (i < originalLines.length) { - changes.push({type: 'deletion', line: originalLines[i], index: i}); - i++; - } - if (j < newLines.length) { - changes.push({type: 'addition', line: newLines[j], index: j}); - j++; - } - } - } - - // Group changes into hunks with context - let currentHunk: DiffLine[] = []; - let hunks: DiffLine[][] = []; - let lastChangeIndex = -1; - - for (let i = 0; i < changes.length; i++) { - const change = changes[i]; - - if (change.type !== 'context' || - (lastChangeIndex >= 0 && i - lastChangeIndex <= contextSize * 2)) { - if (change.type !== 'context') { - lastChangeIndex = i; - } - currentHunk.push({ - type: change.type, - content: change.line, - lineNumber: change.index + 1 - }); - } else { - if (currentHunk.length > 0) { - hunks.push(currentHunk); - currentHunk = []; - } - } - } - - if (currentHunk.length > 0) { - hunks.push(currentHunk); - } - - // Format the diff output - let diffOutput = ''; - - for (const hunk of hunks) { - const startLine = hunk[0].lineNumber; - const endLine = hunk[hunk.length - 1].lineNumber; - - diffOutput += `@@ -${startLine},${endLine} @@\n`; - - for (const line of hunk) { - const prefix = line.type === 'addition' ? '+' : - line.type === 'deletion' ? '-' : ' '; - diffOutput += `${prefix}${line.content}\n`; - } - - diffOutput += '\n'; - } - - return diffOutput; +// Utility functions for text normalization and matching +function normalizeLineEndings(text: string): string { + return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); } -// File editing utilities -async function applyFileEdits( - filePath: string, - edits: Array<{oldText: string, newText: string}>, - dryRun = false -): Promise { - let content = await fs.readFile(filePath, 'utf-8'); - const originalLines = content.split('\n'); - let modifiedContent = content; +function normalizeWhitespace(text: string, preserveIndentation: boolean = true): string { + if (!preserveIndentation) { + // Collapse all whitespace to single spaces if not preserving indentation + return text.replace(/\s+/g, ' '); + } - // First, validate all edits can be applied - const positions = edits.map(edit => { - const pos = modifiedContent.indexOf(edit.oldText); - if (pos === -1) { - throw new Error(`Text not found:\n${edit.oldText}`); - } + // Preserve line structure but normalize inline whitespace + return text.split('\n').map(line => { + // Preserve leading whitespace + const indent = line.match(/^[\s\t]*/)?.[0] || ''; + // Normalize rest of line + const content = line.slice(indent.length).trim().replace(/\s+/g, ' '); + return indent + content; + }).join('\n'); +} + +interface EditOptions { + preserveIndentation?: boolean; + normalizeWhitespace?: boolean; + partialMatch?: boolean; +} + +interface EditMatch { + start: number; + end: number; + confidence: number; +} + +function findBestMatch(content: string, searchText: string, options: EditOptions): EditMatch | null { + const normalizedContent = normalizeLineEndings(content); + const normalizedSearch = normalizeLineEndings(searchText); + + // Try exact match first + const exactPos = normalizedContent.indexOf(normalizedSearch); + if (exactPos !== -1) { return { - edit, - position: pos, - length: edit.oldText.length + start: exactPos, + end: exactPos + searchText.length, + confidence: 1.0 }; - }); - - // Sort positions in reverse order to apply from end to start - positions.sort((a, b) => b.position - a.position); - - if (dryRun) { - // For dry run, create a unified diff preview - for (const {edit, position} of positions) { - modifiedContent = - modifiedContent.slice(0, position) + - edit.newText + - modifiedContent.slice(position + edit.oldText.length); + } + + // If whitespace normalization is enabled, try that next + if (options.normalizeWhitespace) { + const normContent = normalizeWhitespace(normalizedContent, options.preserveIndentation); + const normSearch = normalizeWhitespace(normalizedSearch, options.preserveIndentation); + const normPos = normContent.indexOf(normSearch); + + if (normPos !== -1) { + // Find the corresponding position in original text + const beforeMatch = normContent.slice(0, normPos); + const originalPos = findOriginalPosition(content, beforeMatch); + return { + start: originalPos, + end: originalPos + searchText.length, + confidence: 0.9 + }; + } + } + + // If partial matching is enabled, try to find the best partial match + if (options.partialMatch) { + const lines = normalizedContent.split('\n'); + const searchLines = normalizedSearch.split('\n'); + + let bestMatch: EditMatch | null = null; + let bestScore = 0; + + // Sliding window search through the content + for (let i = 0; i < lines.length - searchLines.length + 1; i++) { + let matchScore = 0; + let matchLength = 0; + + for (let j = 0; j < searchLines.length; j++) { + const contentLine = options.normalizeWhitespace + ? normalizeWhitespace(lines[i + j], options.preserveIndentation) + : lines[i + j]; + const searchLine = options.normalizeWhitespace + ? normalizeWhitespace(searchLines[j], options.preserveIndentation) + : searchLines[j]; + + const similarity = calculateSimilarity(contentLine, searchLine); + matchScore += similarity; + matchLength += lines[i + j].length + 1; // +1 for newline + } + + const averageScore = matchScore / searchLines.length; + if (averageScore > bestScore && averageScore > 0.7) { // Threshold for minimum match quality + bestScore = averageScore; + const start = lines.slice(0, i).reduce((acc, line) => acc + line.length + 1, 0); + bestMatch = { + start, + end: start + matchLength, + confidence: averageScore + }; + } } - const modifiedLines = modifiedContent.split('\n'); - return createUnifiedDiff(originalLines, modifiedLines); - } else { - // Apply the edits - for (const {edit, position} of positions) { - modifiedContent = - modifiedContent.slice(0, position) + - edit.newText + - modifiedContent.slice(position + edit.oldText.length); - } - return modifiedContent; + return bestMatch; } + + return null; +} + +function calculateSimilarity(str1: string, str2: string): number { + const len1 = str1.length; + const len2 = str2.length; + const matrix: number[][] = Array(len1 + 1).fill(null).map(() => Array(len2 + 1).fill(0)); + + for (let i = 0; i <= len1; i++) matrix[i][0] = i; + for (let j = 0; j <= len2; j++) matrix[0][j] = j; + + for (let i = 1; i <= len1; i++) { + for (let j = 1; j <= len2; j++) { + const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j - 1] + cost + ); + } + } + + const maxLength = Math.max(len1, len2); + return maxLength === 0 ? 1 : (maxLength - matrix[len1][len2]) / maxLength; +} + +function findOriginalPosition(original: string, normalizedPrefix: string): number { + let origPos = 0; + let normPos = 0; + + while (normPos < normalizedPrefix.length && origPos < original.length) { + if (normalizeWhitespace(original[origPos], true) === normalizedPrefix[normPos]) { + normPos++; + } + origPos++; + } + + return origPos; +} + +async function applyFileEdits( + filePath: string, + edits: Array<{oldText: string, newText: string}>, + dryRun = false, + options: EditOptions = { + preserveIndentation: true, + normalizeWhitespace: true, + partialMatch: true + } +): Promise { + const content = await fs.readFile(filePath, 'utf-8'); + let modifiedContent = content; + const failedEdits: Array<{edit: typeof edits[0], error: string}> = []; + const successfulEdits: Array<{edit: typeof edits[0], match: EditMatch}> = []; + + // Sort edits by position (if found) to apply them in order + for (const edit of edits) { + const match = findBestMatch(modifiedContent, edit.oldText, options); + + if (!match) { + failedEdits.push({ + edit, + error: 'No suitable match found' + }); + continue; + } + + // For low confidence matches in non-dry-run mode, we might want to throw + if (!dryRun && match.confidence < 0.8) { + failedEdits.push({ + edit, + error: `Match confidence too low: ${(match.confidence * 100).toFixed(1)}%` + }); + continue; + } + + successfulEdits.push({ edit, match }); + } + + // Sort successful edits by position (reverse order to maintain positions) + successfulEdits.sort((a, b) => b.match.start - a.match.start); + + // Apply successful edits + for (const { edit, match } of successfulEdits) { + modifiedContent = + modifiedContent.slice(0, match.start) + + edit.newText + + modifiedContent.slice(match.end); + } + + if (dryRun) { + let report = createUnifiedDiff(content, modifiedContent, filePath); + + if (failedEdits.length > 0) { + report += '\nFailed edits:\n' + failedEdits.map(({ edit, error }) => + `- Error: ${error}\n Old text: ${edit.oldText.split('\n')[0]}...\n` + ).join('\n'); + } + + if (successfulEdits.length > 0) { + report += '\nSuccessful edits:\n' + successfulEdits.map(({ edit, match }) => + `- Match confidence: ${(match.confidence * 100).toFixed(1)}%\n Position: ${match.start}-${match.end}\n` + ).join('\n'); + } + + return report; + } + + if (failedEdits.length > 0) { + const errors = failedEdits.map(({ error }) => error).join('\n'); + throw new Error(`Some edits failed:\n${errors}`); + } + + return modifiedContent; } // Tool handlers @@ -392,8 +485,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { { name: "edit_file", description: - "Make selective edits to a text file using search and replace with unified diff previews. " + + "Make selective edits to a text file using line-based pattern matching and replacement. " + + "Handles both single-line and multi-line edits, with smart positioning to handle multiple edits simultaneously. " + "Shows changes in standard unified diff format with context lines, similar to git diff. " + + "Provides detailed diff output for failed matches to aid debugging. " + "Use dry run mode to preview changes in patch format before applying them. " + "Only works within allowed directories.", inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput, diff --git a/src/filesystem/package.json b/src/filesystem/package.json index 581ad81..8229e0d 100644 --- a/src/filesystem/package.json +++ b/src/filesystem/package.json @@ -20,12 +20,14 @@ }, "dependencies": { "@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" } -} +} \ No newline at end of file From b04c9334bc6abef848816080c3526ff7f43cdfaf Mon Sep 17 00:00:00 2001 From: Marc Goodner Date: Wed, 4 Dec 2024 20:45:43 -0800 Subject: [PATCH 15/18] schema def issues --- src/filesystem/index.ts | 45 +++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 88f68ab..a3ce7f0 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -114,13 +114,23 @@ const EditOperation = z.object({ newText: z.string().describe('Text to replace the found text with'), }); +const EditOptions = z.object({ + preserveIndentation: z.boolean().default(true).describe('Preserve existing indentation patterns in the file'), + normalizeWhitespace: z.boolean().default(true).describe('Normalize whitespace while preserving structure'), + partialMatch: z.boolean().default(true).describe('Enable fuzzy matching with confidence scoring') +}); + const EditFileArgsSchema = z.object({ path: z.string(), edits: z.array(EditOperation), // Optional: preview changes without applying them - dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format') + dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format'), + // Optional: configure matching and formatting behavior + options: EditOptions.default({}) }); + + const CreateDirectoryArgsSchema = z.object({ path: z.string(), }); @@ -250,19 +260,13 @@ function normalizeWhitespace(text: string, preserveIndentation: boolean = true): }).join('\n'); } -interface EditOptions { - preserveIndentation?: boolean; - normalizeWhitespace?: boolean; - partialMatch?: boolean; -} - interface EditMatch { start: number; end: number; confidence: number; } -function findBestMatch(content: string, searchText: string, options: EditOptions): EditMatch | null { +function findBestMatch(content: string, searchText: string, options: z.infer): EditMatch | null { const normalizedContent = normalizeLineEndings(content); const normalizedSearch = normalizeLineEndings(searchText); @@ -379,11 +383,7 @@ async function applyFileEdits( filePath: string, edits: Array<{oldText: string, newText: string}>, dryRun = false, - options: EditOptions = { - preserveIndentation: true, - normalizeWhitespace: true, - partialMatch: true - } + options: z.infer = EditOptions.parse({}) ): Promise { const content = await fs.readFile(filePath, 'utf-8'); let modifiedContent = content; @@ -485,12 +485,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { { name: "edit_file", description: - "Make selective edits to a text file using line-based pattern matching and replacement. " + - "Handles both single-line and multi-line edits, with smart positioning to handle multiple edits simultaneously. " + - "Shows changes in standard unified diff format with context lines, similar to git diff. " + - "Provides detailed diff output for failed matches to aid debugging. " + - "Use dry run mode to preview changes in patch format before applying them. " + - "Only works within allowed directories.", + "Make selective edits to a text file using advanced pattern matching and smart formatting preservation. Features include:\n" + + "- Line-based and multi-line content matching\n" + + "- Whitespace normalization with indentation preservation\n" + + "- Fuzzy matching with confidence scoring\n" + + "- Multiple simultaneous edits with correct positioning\n" + + "- Indentation style detection and preservation\n" + + "- Detailed diff output with context in git format\n" + + "- Dry run mode for previewing changes\n" + + "- Failed match debugging with match confidence scores\n\n" + + "Configure behavior with options.preserveIndentation, options.normalizeWhitespace, and options.partialMatch. " + + "See schema for detailed option descriptions. Only works within allowed directories.", inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput, }, { @@ -612,7 +617,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { 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); + const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun, parsed.data.options); // If it's a dry run, show the unified diff if (parsed.data.dryRun) { From 7417d4dc075a702d7fc61eae55a540c8521dd171 Mon Sep 17 00:00:00 2001 From: Marc Goodner Date: Wed, 4 Dec 2024 20:51:39 -0800 Subject: [PATCH 16/18] update read me for current impl --- src/filesystem/README.md | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/filesystem/README.md b/src/filesystem/README.md index 31995d8..37bc290 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -37,23 +37,28 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio - `content` (string): File content - **edit_file** - - Make selective edits using search and replace + - Make selective edits using advanced pattern matching and formatting - Features: - - Simple substring matching for finding text - - Git-style preview format for changes + - 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 - - Preserves consistent indentation patterns - - Limitations: - - Intended for content changes, not code formatting - - Mixed tabs/spaces can cause pattern matching issues - - Use code formatters (e.g., Prettier, ESLint) before content edits + - 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) - - Returns preview information for dry runs, otherwise applies changes + - `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 From b477af5c0404bf53d6643f73fda1e126dd27a935 Mon Sep 17 00:00:00 2001 From: Marc Goodner Date: Thu, 5 Dec 2024 00:33:49 -0800 Subject: [PATCH 17/18] seriously, like aider --- src/filesystem/index.ts | 308 ++++++++++------------------------------ 1 file changed, 76 insertions(+), 232 deletions(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index a3ce7f0..23d989d 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -108,29 +108,16 @@ const WriteFileArgsSchema = z.object({ }); const EditOperation = z.object({ - // The text to search for - oldText: z.string().describe('Text to search for - can be a substring of the target'), - // The new text to replace with - newText: z.string().describe('Text to replace the found text with'), -}); - -const EditOptions = z.object({ - preserveIndentation: z.boolean().default(true).describe('Preserve existing indentation patterns in the file'), - normalizeWhitespace: z.boolean().default(true).describe('Normalize whitespace while preserving structure'), - partialMatch: z.boolean().default(true).describe('Enable fuzzy matching with confidence scoring') + 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), - // Optional: preview changes without applying them - dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format'), - // Optional: configure matching and formatting behavior - options: EditOptions.default({}) + dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format') }); - - const CreateDirectoryArgsSchema = z.object({ path: z.string(), }); @@ -228,227 +215,101 @@ async function searchFiles( } // 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, - originalContent, - newContent, + normalizedOriginal, + normalizedNew, 'original', 'modified' ); } -// Utility functions for text normalization and matching -function normalizeLineEndings(text: string): string { - return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); -} - -function normalizeWhitespace(text: string, preserveIndentation: boolean = true): string { - if (!preserveIndentation) { - // Collapse all whitespace to single spaces if not preserving indentation - return text.replace(/\s+/g, ' '); - } - - // Preserve line structure but normalize inline whitespace - return text.split('\n').map(line => { - // Preserve leading whitespace - const indent = line.match(/^[\s\t]*/)?.[0] || ''; - // Normalize rest of line - const content = line.slice(indent.length).trim().replace(/\s+/g, ' '); - return indent + content; - }).join('\n'); -} - -interface EditMatch { - start: number; - end: number; - confidence: number; -} - -function findBestMatch(content: string, searchText: string, options: z.infer): EditMatch | null { - const normalizedContent = normalizeLineEndings(content); - const normalizedSearch = normalizeLineEndings(searchText); - - // Try exact match first - const exactPos = normalizedContent.indexOf(normalizedSearch); - if (exactPos !== -1) { - return { - start: exactPos, - end: exactPos + searchText.length, - confidence: 1.0 - }; - } - - // If whitespace normalization is enabled, try that next - if (options.normalizeWhitespace) { - const normContent = normalizeWhitespace(normalizedContent, options.preserveIndentation); - const normSearch = normalizeWhitespace(normalizedSearch, options.preserveIndentation); - const normPos = normContent.indexOf(normSearch); - - if (normPos !== -1) { - // Find the corresponding position in original text - const beforeMatch = normContent.slice(0, normPos); - const originalPos = findOriginalPosition(content, beforeMatch); - return { - start: originalPos, - end: originalPos + searchText.length, - confidence: 0.9 - }; - } - } - - // If partial matching is enabled, try to find the best partial match - if (options.partialMatch) { - const lines = normalizedContent.split('\n'); - const searchLines = normalizedSearch.split('\n'); - - let bestMatch: EditMatch | null = null; - let bestScore = 0; - - // Sliding window search through the content - for (let i = 0; i < lines.length - searchLines.length + 1; i++) { - let matchScore = 0; - let matchLength = 0; - - for (let j = 0; j < searchLines.length; j++) { - const contentLine = options.normalizeWhitespace - ? normalizeWhitespace(lines[i + j], options.preserveIndentation) - : lines[i + j]; - const searchLine = options.normalizeWhitespace - ? normalizeWhitespace(searchLines[j], options.preserveIndentation) - : searchLines[j]; - - const similarity = calculateSimilarity(contentLine, searchLine); - matchScore += similarity; - matchLength += lines[i + j].length + 1; // +1 for newline - } - - const averageScore = matchScore / searchLines.length; - if (averageScore > bestScore && averageScore > 0.7) { // Threshold for minimum match quality - bestScore = averageScore; - const start = lines.slice(0, i).reduce((acc, line) => acc + line.length + 1, 0); - bestMatch = { - start, - end: start + matchLength, - confidence: averageScore - }; - } - } - - return bestMatch; - } - - return null; -} - -function calculateSimilarity(str1: string, str2: string): number { - const len1 = str1.length; - const len2 = str2.length; - const matrix: number[][] = Array(len1 + 1).fill(null).map(() => Array(len2 + 1).fill(0)); - - for (let i = 0; i <= len1; i++) matrix[i][0] = i; - for (let j = 0; j <= len2; j++) matrix[0][j] = j; - - for (let i = 1; i <= len1; i++) { - for (let j = 1; j <= len2; j++) { - const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; - matrix[i][j] = Math.min( - matrix[i - 1][j] + 1, - matrix[i][j - 1] + 1, - matrix[i - 1][j - 1] + cost - ); - } - } - - const maxLength = Math.max(len1, len2); - return maxLength === 0 ? 1 : (maxLength - matrix[len1][len2]) / maxLength; -} - -function findOriginalPosition(original: string, normalizedPrefix: string): number { - let origPos = 0; - let normPos = 0; - - while (normPos < normalizedPrefix.length && origPos < original.length) { - if (normalizeWhitespace(original[origPos], true) === normalizedPrefix[normPos]) { - normPos++; - } - origPos++; - } - - return origPos; -} - async function applyFileEdits( filePath: string, edits: Array<{oldText: string, newText: string}>, - dryRun = false, - options: z.infer = EditOptions.parse({}) + dryRun = false ): Promise { - const content = await fs.readFile(filePath, 'utf-8'); + // Read file content and normalize line endings + const content = normalizeLineEndings(await fs.readFile(filePath, 'utf-8')); + + // Apply edits sequentially let modifiedContent = content; - const failedEdits: Array<{edit: typeof edits[0], error: string}> = []; - const successfulEdits: Array<{edit: typeof edits[0], match: EditMatch}> = []; - - // Sort edits by position (if found) to apply them in order for (const edit of edits) { - const match = findBestMatch(modifiedContent, edit.oldText, options); + const normalizedOld = normalizeLineEndings(edit.oldText); + const normalizedNew = normalizeLineEndings(edit.newText); - if (!match) { - failedEdits.push({ - edit, - error: 'No suitable match found' - }); + // If exact match exists, use it + if (modifiedContent.includes(normalizedOld)) { + modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew); continue; } - // For low confidence matches in non-dry-run mode, we might want to throw - if (!dryRun && match.confidence < 0.8) { - failedEdits.push({ - edit, - error: `Match confidence too low: ${(match.confidence * 100).toFixed(1)}%` + // 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(); }); - continue; + + 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; + } } - successfulEdits.push({ edit, match }); - } - - // Sort successful edits by position (reverse order to maintain positions) - successfulEdits.sort((a, b) => b.match.start - a.match.start); - - // Apply successful edits - for (const { edit, match } of successfulEdits) { - modifiedContent = - modifiedContent.slice(0, match.start) + - edit.newText + - modifiedContent.slice(match.end); - } - - if (dryRun) { - let report = createUnifiedDiff(content, modifiedContent, filePath); - - if (failedEdits.length > 0) { - report += '\nFailed edits:\n' + failedEdits.map(({ edit, error }) => - `- Error: ${error}\n Old text: ${edit.oldText.split('\n')[0]}...\n` - ).join('\n'); + if (!matchFound) { + throw new Error(`Could not find exact match for edit:\n${edit.oldText}`); } - - if (successfulEdits.length > 0) { - report += '\nSuccessful edits:\n' + successfulEdits.map(({ edit, match }) => - `- Match confidence: ${(match.confidence * 100).toFixed(1)}%\n Position: ${match.start}-${match.end}\n` - ).join('\n'); - } - - return report; } - if (failedEdits.length > 0) { - const errors = failedEdits.map(({ error }) => error).join('\n'); - throw new Error(`Some edits failed:\n${errors}`); + // 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 modifiedContent; + return formattedDiff; } // Tool handlers @@ -485,17 +346,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { { name: "edit_file", description: - "Make selective edits to a text file using advanced pattern matching and smart formatting preservation. Features include:\n" + - "- Line-based and multi-line content matching\n" + - "- Whitespace normalization with indentation preservation\n" + - "- Fuzzy matching with confidence scoring\n" + - "- Multiple simultaneous edits with correct positioning\n" + - "- Indentation style detection and preservation\n" + - "- Detailed diff output with context in git format\n" + - "- Dry run mode for previewing changes\n" + - "- Failed match debugging with match confidence scores\n\n" + - "Configure behavior with options.preserveIndentation, options.normalizeWhitespace, and options.partialMatch. " + - "See schema for detailed option descriptions. Only works within allowed directories.", + "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, }, { @@ -617,18 +470,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { 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, parsed.data.options); - - // If it's a dry run, show the unified diff - if (parsed.data.dryRun) { - return { - content: [{ type: "text", text: `Edit preview:\n${result}` }], - }; - } - - await fs.writeFile(validPath, result, "utf-8"); + const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun); return { - content: [{ type: "text", text: `Successfully applied edits to ${parsed.data.path}` }], + content: [{ type: "text", text: result }], }; } From f7da6f41de04bf864de97470fb2422760a35c0d4 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Thu, 5 Dec 2024 23:15:48 +0000 Subject: [PATCH 18/18] `npm install` --- package-lock.json | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index b0b19b0..fa9ec69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1523,6 +1523,12 @@ "@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": { "version": "5.0.0", "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", "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": { "version": "16.4.6", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.6.tgz", @@ -4983,6 +4997,7 @@ } }, "src/aws-kb-retrieval-server": { + "name": "@modelcontextprotocol/server-aws-kb-retrieval", "version": "0.6.2", "license": "MIT", "dependencies": { @@ -5167,7 +5182,8 @@ "version": "0.6.2", "license": "MIT", "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" }, @@ -5175,6 +5191,7 @@ "mcp-server-filesystem": "dist/index.js" }, "devDependencies": { + "@types/diff": "^5.0.9", "@types/node": "^20.11.0", "shx": "^0.3.4", "typescript": "^5.3.3"