From d15eb75a77e43844d328b5792601e5f452a41de2 Mon Sep 17 00:00:00 2001 From: Benyamin Date: Wed, 24 Dec 2025 09:14:44 +0300 Subject: [PATCH] feat(filesystem): add append_file and write_or_update_file tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds two new tools to the filesystem server: 1. **append_file**: Appends content to the end of an existing file. - File must already exist - Preserves existing content, adds new content at the end 2. **write_or_update_file**: Creates a new file or appends to an existing file. - If file doesn't exist: creates it with the provided content - If file exists: appends new content to the end - Useful when you want to add content while preserving existing data Changes include: - Added `appendFileContent` and `writeOrUpdateFileContent` functions to lib.ts - Added tool registrations and schemas to index.ts - Updated README.md with new tool documentation - Added comprehensive tests for the new functions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/filesystem/README.md | 21 ++++++ src/filesystem/__tests__/lib.test.ts | 108 ++++++++++++++++++++++++++- src/filesystem/index.ts | 73 ++++++++++++++++++ src/filesystem/lib.ts | 64 ++++++++++++++++ 4 files changed, 264 insertions(+), 2 deletions(-) diff --git a/src/filesystem/README.md b/src/filesystem/README.md index e9ddc2b1e2..b0ef236900 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -5,6 +5,8 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio ## Features - Read/write files +- Append to existing files +- Create files or append to existing ones - Create/list/delete directories - Move files/directories - Search files @@ -92,6 +94,23 @@ The server's directory access control follows this flow: - `path` (string): File location - `content` (string): File content +- **append_file** + - Append content to the end of an existing file + - Inputs: + - `path` (string): File location (must exist) + - `content` (string): Content to append + - File must already exist - use `write_file` to create new files + - Preserves existing content, adds new content at the end + +- **write_or_update_file** + - Create new file or append to existing file + - Inputs: + - `path` (string): File location + - `content` (string): Content to write or append + - If file doesn't exist: creates it with the provided content + - If file exists: appends new content to the end + - Useful when you want to add content while preserving existing data + - **edit_file** - Make selective edits using advanced pattern matching and formatting - Features: @@ -199,6 +218,8 @@ The mapping for filesystem tools is: | `list_allowed_directories` | `true` | – | – | Pure read | | `create_directory` | `false` | `true` | `false` | Re‑creating the same dir is a no‑op | | `write_file` | `false` | `true` | `true` | Overwrites existing files | +| `append_file` | `false` | `false` | `false` | Appends to existing files; not idempotent | +| `write_or_update_file` | `false` | `false` | `false` | Creates or appends; behavior depends on state | | `edit_file` | `false` | `false` | `true` | Re‑applying edits can fail or double‑apply | | `move_file` | `false` | `false` | `false` | Move/rename only; repeat usually errors | diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index bfe8987bfd..4ee4682be9 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -14,6 +14,8 @@ import { getFileStats, readFileContent, writeFileContent, + appendFileContent, + writeOrUpdateFileContent, // Search & filtering functions searchFilesWithValidation, // File editing functions @@ -279,13 +281,115 @@ describe('Lib Functions', () => { describe('writeFileContent', () => { it('writes file content', async () => { mockFs.writeFile.mockResolvedValueOnce(undefined); - + await writeFileContent('/test/file.txt', 'new content'); - + expect(mockFs.writeFile).toHaveBeenCalledWith('/test/file.txt', 'new content', { encoding: "utf-8", flag: 'wx' }); }); }); + describe('appendFileContent', () => { + it('throws error if file does not exist', async () => { + const error = new Error('ENOENT'); + (error as any).code = 'ENOENT'; + mockFs.access.mockRejectedValue(error); + + await expect(appendFileContent('/test/nonexistent.txt', 'new content')) + .rejects.toThrow('File does not exist'); + }); + + it('appends content to existing file', async () => { + mockFs.access.mockResolvedValue(undefined); + mockFs.readFile.mockResolvedValue('existing content' as any); + mockFs.writeFile.mockResolvedValue(undefined); + mockFs.rename.mockResolvedValue(undefined); + + await appendFileContent('/test/file.txt', '\nnew content'); + + expect(mockFs.readFile).toHaveBeenCalledWith('/test/file.txt', 'utf-8'); + expect(mockFs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('.tmp'), + 'existing content\nnew content', + 'utf-8' + ); + expect(mockFs.rename).toHaveBeenCalled(); + }); + + it('handles write errors and cleans up temp file', async () => { + mockFs.access.mockResolvedValue(undefined); + mockFs.readFile.mockResolvedValue('existing content' as any); + mockFs.writeFile.mockResolvedValue(undefined); + mockFs.rename.mockRejectedValue(new Error('Rename failed')); + mockFs.unlink.mockResolvedValue(undefined); + + await expect(appendFileContent('/test/file.txt', 'new content')) + .rejects.toThrow('Rename failed'); + + expect(mockFs.unlink).toHaveBeenCalled(); + }); + }); + + describe('writeOrUpdateFileContent', () => { + it('creates new file if it does not exist', async () => { + const error = new Error('ENOENT'); + (error as any).code = 'ENOENT'; + mockFs.access.mockRejectedValue(error); + mockFs.writeFile.mockResolvedValue(undefined); + + await writeOrUpdateFileContent('/test/newfile.txt', 'initial content'); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + '/test/newfile.txt', + 'initial content', + { encoding: 'utf-8', flag: 'wx' } + ); + }); + + it('appends to existing file', async () => { + mockFs.access.mockResolvedValue(undefined); + mockFs.readFile.mockResolvedValue('existing content' as any); + mockFs.writeFile.mockResolvedValue(undefined); + mockFs.rename.mockResolvedValue(undefined); + + await writeOrUpdateFileContent('/test/file.txt', '\nappended content'); + + expect(mockFs.readFile).toHaveBeenCalledWith('/test/file.txt', 'utf-8'); + expect(mockFs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('.tmp'), + 'existing content\nappended content', + 'utf-8' + ); + expect(mockFs.rename).toHaveBeenCalled(); + }); + + it('handles file exists error during creation by using atomic write', async () => { + const notFoundError = new Error('ENOENT'); + (notFoundError as any).code = 'ENOENT'; + const existsError = new Error('EEXIST'); + (existsError as any).code = 'EEXIST'; + + mockFs.access.mockRejectedValue(notFoundError); + mockFs.writeFile + .mockRejectedValueOnce(existsError) + .mockResolvedValueOnce(undefined); + mockFs.rename.mockResolvedValue(undefined); + + await writeOrUpdateFileContent('/test/file.txt', 'content'); + + expect(mockFs.writeFile).toHaveBeenCalledTimes(2); + expect(mockFs.rename).toHaveBeenCalled(); + }); + + it('propagates non-ENOENT access errors', async () => { + const error = new Error('Permission denied'); + (error as any).code = 'EACCES'; + mockFs.access.mockRejectedValue(error); + + await expect(writeOrUpdateFileContent('/test/file.txt', 'content')) + .rejects.toThrow('Permission denied'); + }); + }); + }); describe('Search & Filtering Functions', () => { diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 48a599fae1..d3002ccd42 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -21,6 +21,8 @@ import { getFileStats, readFileContent, writeFileContent, + appendFileContent, + writeOrUpdateFileContent, searchFilesWithValidation, applyFileEdits, tailFile, @@ -96,6 +98,16 @@ const WriteFileArgsSchema = z.object({ content: z.string(), }); +const AppendFileArgsSchema = z.object({ + path: z.string(), + content: z.string(), +}); + +const WriteOrUpdateFileArgsSchema = z.object({ + path: z.string(), + content: z.string(), +}); + const EditOperation = z.object({ oldText: z.string().describe('Text to search for - must match exactly'), newText: z.string().describe('Text to replace with') @@ -343,6 +355,67 @@ server.registerTool( } ); +server.registerTool( + "append_file", + { + title: "Append File", + description: + "Append content to the end of an existing file. This operation adds new content " + + "to the file without modifying existing content. The file must already exist - " + + "use write_file to create new files or write_or_update_file to create or append. " + + "Only works within allowed directories.", + inputSchema: { + path: z.string(), + content: z.string() + }, + outputSchema: { content: z.string() }, + annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: false } + }, + async (args: z.infer) => { + const validPath = await validatePath(args.path); + await appendFileContent(validPath, args.content); + const text = `Successfully appended content to ${args.path}`; + return { + content: [{ type: "text" as const, text }], + structuredContent: { content: text } + }; + } +); + +server.registerTool( + "write_or_update_file", + { + title: "Write or Update File", + description: + "Create a new file with content, or append to an existing file. If the file " + + "does not exist, it will be created with the provided content. If the file " + + "already exists, the new content will be appended to the end without overwriting " + + "existing content. This is useful when you want to add content to a file but " + + "preserve existing data. Only works within allowed directories.", + inputSchema: { + path: z.string(), + content: z.string() + }, + outputSchema: { content: z.string() }, + annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: false } + }, + async (args: z.infer) => { + const validPath = await validatePath(args.path); + await writeOrUpdateFileContent(validPath, args.content); + + // Determine if file was created or updated for better feedback + const stats = await fs.stat(validPath); + const message = stats.birthtime.getTime() === stats.mtime.getTime() + ? `Successfully created ${args.path} with content` + : `Successfully appended content to ${args.path}`; + + return { + content: [{ type: "text" as const, text: message }], + structuredContent: { content: message } + }; + } +); + server.registerTool( "edit_file", { diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index 240ca0d476..5daf116e9d 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -161,6 +161,70 @@ export async function writeFileContent(filePath: string, content: string): Promi } } +export async function appendFileContent(filePath: string, content: string): Promise { + // Check if file exists + try { + await fs.access(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`File does not exist: ${filePath}`); + } + throw error; + } + + // Read existing content + const existingContent = await readFileContent(filePath); + + // Combine existing and new content + const combinedContent = existingContent + content; + + // Use atomic write to update the file + const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`; + try { + await fs.writeFile(tempPath, combinedContent, 'utf-8'); + await fs.rename(tempPath, filePath); + } catch (error) { + try { + await fs.unlink(tempPath); + } catch {} + throw error; + } +} + +export async function writeOrUpdateFileContent(filePath: string, content: string): Promise { + // Check if file exists + let fileExists = false; + try { + await fs.access(filePath); + fileExists = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + if (fileExists) { + // File exists, append the content + const existingContent = await readFileContent(filePath); + const combinedContent = existingContent + content; + + // Use atomic write to update the file + const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`; + try { + await fs.writeFile(tempPath, combinedContent, 'utf-8'); + await fs.rename(tempPath, filePath); + } catch (error) { + try { + await fs.unlink(tempPath); + } catch {} + throw error; + } + } else { + // File doesn't exist, create it with the content + await writeFileContent(filePath, content); + } +} + // File Editing Functions interface FileEdit {