diff --git a/src/filesystem/README.md b/src/filesystem/README.md index c099da1e8c..82e9c8261d 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 + +- **create_or_append_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 | +| `create_or_append_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` | `true` | Deletes source file | diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index f7e585af22..8e6c8daf54 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -14,6 +14,8 @@ import { getFileStats, readFileContent, writeFileContent, + appendFileContent, + createOrAppendFileContent, // Search & filtering functions searchFilesWithValidation, // File editing functions @@ -303,13 +305,102 @@ 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.appendFile.mockRejectedValue(error); + + await expect(appendFileContent('/test/nonexistent.txt', 'new content')) + .rejects.toThrow('File does not exist'); + }); + + it('appends content to existing file', async () => { + mockFs.appendFile.mockResolvedValue(undefined); + + await appendFileContent('/test/file.txt', '\nnew content'); + + expect(mockFs.appendFile).toHaveBeenCalledWith( + '/test/file.txt', + '\nnew content', + { encoding: 'utf-8', flag: 'a' } + ); + }); + + it('propagates non-ENOENT errors', async () => { + const error = new Error('Permission denied'); + (error as any).code = 'EACCES'; + mockFs.appendFile.mockRejectedValue(error); + + await expect(appendFileContent('/test/file.txt', 'new content')) + .rejects.toThrow('Permission denied'); + }); + }); + + describe('createOrAppendFileContent', () => { + it('creates new file if it does not exist', async () => { + const error = new Error('ENOENT'); + (error as any).code = 'ENOENT'; + mockFs.appendFile.mockRejectedValue(error); + mockFs.writeFile.mockResolvedValue(undefined); + + await createOrAppendFileContent('/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.appendFile.mockResolvedValue(undefined); + + await createOrAppendFileContent('/test/file.txt', '\nappended content'); + + expect(mockFs.appendFile).toHaveBeenCalledWith( + '/test/file.txt', + '\nappended content', + { encoding: 'utf-8', flag: 'a' } + ); + expect(mockFs.writeFile).not.toHaveBeenCalled(); + }); + + it('falls back to atomic rename when target file already exists at create time', async () => { + const notFoundError = new Error('ENOENT'); + (notFoundError as any).code = 'ENOENT'; + const existsError = new Error('EEXIST'); + (existsError as any).code = 'EEXIST'; + + mockFs.appendFile.mockRejectedValue(notFoundError); + mockFs.writeFile + .mockRejectedValueOnce(existsError) + .mockResolvedValueOnce(undefined); + mockFs.rename.mockResolvedValue(undefined); + + await createOrAppendFileContent('/test/file.txt', 'content'); + + expect(mockFs.writeFile).toHaveBeenCalledTimes(2); + expect(mockFs.rename).toHaveBeenCalled(); + }); + + it('propagates non-ENOENT append errors', async () => { + const error = new Error('Permission denied'); + (error as any).code = 'EACCES'; + mockFs.appendFile.mockRejectedValue(error); + + await expect(createOrAppendFileContent('/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 7b67e63e58..2f3cacbb3d 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -21,6 +21,8 @@ import { getFileStats, readFileContent, writeFileContent, + appendFileContent, + createOrAppendFileContent, searchFilesWithValidation, applyFileEdits, tailFile, @@ -110,10 +112,13 @@ const ReadMultipleFilesArgsSchema = z.object({ .describe("Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories."), }); -const WriteFileArgsSchema = z.object({ +const PathContentArgsSchema = z.object({ path: z.string(), content: z.string(), }); +const WriteFileArgsSchema = PathContentArgsSchema; +const AppendFileArgsSchema = PathContentArgsSchema; +const CreateOrAppendFileArgsSchema = PathContentArgsSchema; const EditOperation = z.object({ oldText: z.string().describe('Text to search for - must match exactly'), @@ -362,6 +367,66 @@ 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 create_or_append_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( + "create_or_append_file", + { + title: "Create or Append 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); + const existed = await fs.access(validPath).then(() => true).catch(() => false); + await createOrAppendFileContent(validPath, args.content); + + const message = existed + ? `Successfully appended content to ${args.path}` + : `Successfully created ${args.path} with content`; + + 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 17e4654cd5..8bacfb9ade 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -184,6 +184,29 @@ export async function writeFileContent(filePath: string, content: string): Promi } } +export async function appendFileContent(filePath: string, content: string): Promise { + try { + await fs.appendFile(filePath, content, { encoding: 'utf-8', flag: 'a' }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`File does not exist: ${filePath}`); + } + throw error; + } +} + +export async function createOrAppendFileContent(filePath: string, content: string): Promise { + try { + await appendFileContent(filePath, content); + } catch (error) { + if (error instanceof Error && error.message.startsWith('File does not exist:')) { + await writeFileContent(filePath, content); + return; + } + throw error; + } +} + // File Editing Functions interface FileEdit {