diff --git a/src/filesystem/README.md b/src/filesystem/README.md index 973f76ef66..f21f7539b8 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: diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index cc13ef0353..042ba7a18d 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 @@ -683,19 +685,121 @@ describe('Lib Functions', () => { read: jest.fn(), close: jest.fn() } as any; - + // Simulate reading exactly the requested number of lines mockFileHandle.read .mockResolvedValueOnce({ bytesRead: 12, buffer: Buffer.from('line1\nline2\n') }) .mockResolvedValueOnce({ bytesRead: 0 }); mockFileHandle.close.mockResolvedValue(undefined); - + mockFs.open.mockResolvedValue(mockFileHandle); - + const result = await headFile('/test/file.txt', 2); - + expect(mockFileHandle.close).toHaveBeenCalled(); }); }); + + 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'); + }); + }); }); }); diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 7888196285..91fda5139e 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -24,6 +24,8 @@ import { getFileStats, readFileContent, writeFileContent, + appendFileContent, + writeOrUpdateFileContent, searchFilesWithValidation, applyFileEdits, tailFile, @@ -99,6 +101,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') @@ -223,6 +235,25 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { "Handles text content with proper encoding. Only works within allowed directories.", inputSchema: zodToJsonSchema(WriteFileArgsSchema) as ToolInput, }, + { + name: "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: zodToJsonSchema(AppendFileArgsSchema) as ToolInput, + }, + { + name: "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: zodToJsonSchema(WriteOrUpdateFileArgsSchema) as ToolInput, + }, { name: "edit_file", description: @@ -417,6 +448,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + case "append_file": { + const parsed = AppendFileArgsSchema.safeParse(args); + if (!parsed.success) { + throw new Error(`Invalid arguments for append_file: ${parsed.error}`); + } + const validPath = await validatePath(parsed.data.path); + await appendFileContent(validPath, parsed.data.content); + return { + content: [{ type: "text", text: `Successfully appended content to ${parsed.data.path}` }], + }; + } + + case "write_or_update_file": { + const parsed = WriteOrUpdateFileArgsSchema.safeParse(args); + if (!parsed.success) { + throw new Error(`Invalid arguments for write_or_update_file: ${parsed.error}`); + } + const validPath = await validatePath(parsed.data.path); + await writeOrUpdateFileContent(validPath, parsed.data.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 ${parsed.data.path} with content` + : `Successfully appended content to ${parsed.data.path}`; + + return { + content: [{ type: "text", text: message }], + }; + } + case "edit_file": { const parsed = EditFileArgsSchema.safeParse(args); if (!parsed.success) { 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 {