diff --git a/package-lock.json b/package-lock.json index b0b19b05..fa9ec691 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" diff --git a/src/filesystem/README.md b/src/filesystem/README.md index c2950cd5..37bc290f 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -36,6 +36,30 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio - `path` (string): File location - `content` (string): File content +- **edit_file** + - Make selective edits using advanced pattern matching and formatting + - Features: + - Line-based and multi-line content matching + - Whitespace normalization with indentation preservation + - Fuzzy matching with confidence scoring + - Multiple simultaneous edits with correct positioning + - Indentation style detection and preservation + - Git-style diff output with context + - Preview changes with dry run mode + - Failed match debugging with confidence scores + - Inputs: + - `path` (string): File to edit + - `edits` (array): List of edit operations + - `oldText` (string): Text to search for (can be substring) + - `newText` (string): Text to replace with + - `dryRun` (boolean): Preview changes without applying (default: false) + - `options` (object): Optional formatting settings + - `preserveIndentation` (boolean): Keep existing indentation (default: true) + - `normalizeWhitespace` (boolean): Normalize spaces while preserving structure (default: true) + - `partialMatch` (boolean): Enable fuzzy matching (default: true) + - Returns detailed diff and match information for dry runs, otherwise applies changes + - Best Practice: Always use dryRun first to preview changes before applying them + - **create_directory** - Create new directory or ensure it exists - Input: `path` (string) @@ -98,4 +122,4 @@ Add this to your `claude_desktop_config.json`: ## License -This MCP server is licensed under the MIT License. This means you are free to use, modify, and distribute the software, subject to the terms and conditions of the MIT License. For more details, please see the LICENSE file in the project repository. +This MCP server is licensed under the MIT License. This means you are free to use, modify, and distribute the software, subject to the terms and conditions of the MIT License. For more details, please see the LICENSE file in the project repository. \ No newline at end of file diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index b4c4e92d..23d989d0 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); @@ -106,6 +107,17 @@ const WriteFileArgsSchema = z.object({ content: z.string(), }); +const EditOperation = z.object({ + oldText: z.string().describe('Text to search for - must match exactly'), + newText: z.string().describe('Text to replace with') +}); + +const EditFileArgsSchema = z.object({ + path: z.string(), + edits: z.array(EditOperation), + dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format') +}); + const CreateDirectoryArgsSchema = z.object({ path: z.string(), }); @@ -202,6 +214,104 @@ async function searchFiles( return results; } +// file editing and diffing utilities +function normalizeLineEndings(text: string): string { + return text.replace(/\r\n/g, '\n'); +} + +function createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string { + // Ensure consistent line endings for diff + const normalizedOriginal = normalizeLineEndings(originalContent); + const normalizedNew = normalizeLineEndings(newContent); + + return createTwoFilesPatch( + filepath, + filepath, + normalizedOriginal, + normalizedNew, + 'original', + 'modified' + ); +} + +async function applyFileEdits( + filePath: string, + edits: Array<{oldText: string, newText: string}>, + dryRun = false +): Promise { + // Read file content and normalize line endings + const content = normalizeLineEndings(await fs.readFile(filePath, 'utf-8')); + + // Apply edits sequentially + let modifiedContent = content; + for (const edit of edits) { + const normalizedOld = normalizeLineEndings(edit.oldText); + const normalizedNew = normalizeLineEndings(edit.newText); + + // If exact match exists, use it + if (modifiedContent.includes(normalizedOld)) { + modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew); + continue; + } + + // Otherwise, try line-by-line matching with flexibility for whitespace + const oldLines = normalizedOld.split('\n'); + const contentLines = modifiedContent.split('\n'); + let matchFound = false; + + for (let i = 0; i <= contentLines.length - oldLines.length; i++) { + const potentialMatch = contentLines.slice(i, i + oldLines.length); + + // Compare lines with normalized whitespace + const isMatch = oldLines.every((oldLine, j) => { + const contentLine = potentialMatch[j]; + return oldLine.trim() === contentLine.trim(); + }); + + if (isMatch) { + // Preserve original indentation of first line + const originalIndent = contentLines[i].match(/^\s*/)?.[0] || ''; + const newLines = normalizedNew.split('\n').map((line, j) => { + if (j === 0) return originalIndent + line.trimStart(); + // For subsequent lines, try to preserve relative indentation + const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || ''; + const newIndent = line.match(/^\s*/)?.[0] || ''; + if (oldIndent && newIndent) { + const relativeIndent = newIndent.length - oldIndent.length; + return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart(); + } + return line; + }); + + contentLines.splice(i, oldLines.length, ...newLines); + modifiedContent = contentLines.join('\n'); + matchFound = true; + break; + } + } + + if (!matchFound) { + throw new Error(`Could not find exact match for edit:\n${edit.oldText}`); + } + } + + // Create unified diff + const diff = createUnifiedDiff(content, modifiedContent, filePath); + + // Format diff with appropriate number of backticks + let numBackticks = 3; + while (diff.includes('`'.repeat(numBackticks))) { + numBackticks++; + } + const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`; + + if (!dryRun) { + await fs.writeFile(filePath, modifiedContent, 'utf-8'); + } + + return formattedDiff; +} + // Tool handlers server.setRequestHandler(ListToolsRequestSchema, async () => { return { @@ -233,6 +343,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { "Handles text content with proper encoding. Only works within allowed directories.", inputSchema: zodToJsonSchema(WriteFileArgsSchema) as ToolInput, }, + { + name: "edit_file", + description: + "Make line-based edits to a text file. Each edit replaces exact line sequences " + + "with new content. Returns a git-style diff showing the changes made. " + + "Only works within allowed directories.", + inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput, + }, { name: "create_directory", description: @@ -346,6 +464,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + case "edit_file": { + const parsed = EditFileArgsSchema.safeParse(args); + if (!parsed.success) { + throw new Error(`Invalid arguments for edit_file: ${parsed.error}`); + } + const validPath = await validatePath(parsed.data.path); + const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun); + return { + content: [{ type: "text", text: result }], + }; + } + case "create_directory": { const parsed = CreateDirectoryArgsSchema.safeParse(args); if (!parsed.success) { diff --git a/src/filesystem/package.json b/src/filesystem/package.json index e6e2f43d..bb9797ce 100644 --- a/src/filesystem/package.json +++ b/src/filesystem/package.json @@ -19,11 +19,13 @@ "watch": "tsc --watch" }, "dependencies": { - "@modelcontextprotocol/sdk": "1.0.1", + "@modelcontextprotocol/sdk": "0.5.0", + "diff": "^5.1.0", "glob": "^10.3.10", "zod-to-json-schema": "^3.23.5" }, "devDependencies": { + "@types/diff": "^5.0.9", "@types/node": "^20.11.0", "shx": "^0.3.4", "typescript": "^5.3.3"