Skip to content

Commit d15eb75

Browse files
agn-7claude
andcommitted
feat(filesystem): add append_file and write_or_update_file tools
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 <[email protected]>
1 parent dcb47d2 commit d15eb75

File tree

4 files changed

+264
-2
lines changed

4 files changed

+264
-2
lines changed

src/filesystem/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio
55
## Features
66

77
- Read/write files
8+
- Append to existing files
9+
- Create files or append to existing ones
810
- Create/list/delete directories
911
- Move files/directories
1012
- Search files
@@ -92,6 +94,23 @@ The server's directory access control follows this flow:
9294
- `path` (string): File location
9395
- `content` (string): File content
9496

97+
- **append_file**
98+
- Append content to the end of an existing file
99+
- Inputs:
100+
- `path` (string): File location (must exist)
101+
- `content` (string): Content to append
102+
- File must already exist - use `write_file` to create new files
103+
- Preserves existing content, adds new content at the end
104+
105+
- **write_or_update_file**
106+
- Create new file or append to existing file
107+
- Inputs:
108+
- `path` (string): File location
109+
- `content` (string): Content to write or append
110+
- If file doesn't exist: creates it with the provided content
111+
- If file exists: appends new content to the end
112+
- Useful when you want to add content while preserving existing data
113+
95114
- **edit_file**
96115
- Make selective edits using advanced pattern matching and formatting
97116
- Features:
@@ -199,6 +218,8 @@ The mapping for filesystem tools is:
199218
| `list_allowed_directories` | `true` ||| Pure read |
200219
| `create_directory` | `false` | `true` | `false` | Re‑creating the same dir is a no‑op |
201220
| `write_file` | `false` | `true` | `true` | Overwrites existing files |
221+
| `append_file` | `false` | `false` | `false` | Appends to existing files; not idempotent |
222+
| `write_or_update_file` | `false` | `false` | `false` | Creates or appends; behavior depends on state |
202223
| `edit_file` | `false` | `false` | `true` | Re‑applying edits can fail or double‑apply |
203224
| `move_file` | `false` | `false` | `false` | Move/rename only; repeat usually errors |
204225

src/filesystem/__tests__/lib.test.ts

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
getFileStats,
1515
readFileContent,
1616
writeFileContent,
17+
appendFileContent,
18+
writeOrUpdateFileContent,
1719
// Search & filtering functions
1820
searchFilesWithValidation,
1921
// File editing functions
@@ -279,13 +281,115 @@ describe('Lib Functions', () => {
279281
describe('writeFileContent', () => {
280282
it('writes file content', async () => {
281283
mockFs.writeFile.mockResolvedValueOnce(undefined);
282-
284+
283285
await writeFileContent('/test/file.txt', 'new content');
284-
286+
285287
expect(mockFs.writeFile).toHaveBeenCalledWith('/test/file.txt', 'new content', { encoding: "utf-8", flag: 'wx' });
286288
});
287289
});
288290

291+
describe('appendFileContent', () => {
292+
it('throws error if file does not exist', async () => {
293+
const error = new Error('ENOENT');
294+
(error as any).code = 'ENOENT';
295+
mockFs.access.mockRejectedValue(error);
296+
297+
await expect(appendFileContent('/test/nonexistent.txt', 'new content'))
298+
.rejects.toThrow('File does not exist');
299+
});
300+
301+
it('appends content to existing file', async () => {
302+
mockFs.access.mockResolvedValue(undefined);
303+
mockFs.readFile.mockResolvedValue('existing content' as any);
304+
mockFs.writeFile.mockResolvedValue(undefined);
305+
mockFs.rename.mockResolvedValue(undefined);
306+
307+
await appendFileContent('/test/file.txt', '\nnew content');
308+
309+
expect(mockFs.readFile).toHaveBeenCalledWith('/test/file.txt', 'utf-8');
310+
expect(mockFs.writeFile).toHaveBeenCalledWith(
311+
expect.stringContaining('.tmp'),
312+
'existing content\nnew content',
313+
'utf-8'
314+
);
315+
expect(mockFs.rename).toHaveBeenCalled();
316+
});
317+
318+
it('handles write errors and cleans up temp file', async () => {
319+
mockFs.access.mockResolvedValue(undefined);
320+
mockFs.readFile.mockResolvedValue('existing content' as any);
321+
mockFs.writeFile.mockResolvedValue(undefined);
322+
mockFs.rename.mockRejectedValue(new Error('Rename failed'));
323+
mockFs.unlink.mockResolvedValue(undefined);
324+
325+
await expect(appendFileContent('/test/file.txt', 'new content'))
326+
.rejects.toThrow('Rename failed');
327+
328+
expect(mockFs.unlink).toHaveBeenCalled();
329+
});
330+
});
331+
332+
describe('writeOrUpdateFileContent', () => {
333+
it('creates new file if it does not exist', async () => {
334+
const error = new Error('ENOENT');
335+
(error as any).code = 'ENOENT';
336+
mockFs.access.mockRejectedValue(error);
337+
mockFs.writeFile.mockResolvedValue(undefined);
338+
339+
await writeOrUpdateFileContent('/test/newfile.txt', 'initial content');
340+
341+
expect(mockFs.writeFile).toHaveBeenCalledWith(
342+
'/test/newfile.txt',
343+
'initial content',
344+
{ encoding: 'utf-8', flag: 'wx' }
345+
);
346+
});
347+
348+
it('appends to existing file', async () => {
349+
mockFs.access.mockResolvedValue(undefined);
350+
mockFs.readFile.mockResolvedValue('existing content' as any);
351+
mockFs.writeFile.mockResolvedValue(undefined);
352+
mockFs.rename.mockResolvedValue(undefined);
353+
354+
await writeOrUpdateFileContent('/test/file.txt', '\nappended content');
355+
356+
expect(mockFs.readFile).toHaveBeenCalledWith('/test/file.txt', 'utf-8');
357+
expect(mockFs.writeFile).toHaveBeenCalledWith(
358+
expect.stringContaining('.tmp'),
359+
'existing content\nappended content',
360+
'utf-8'
361+
);
362+
expect(mockFs.rename).toHaveBeenCalled();
363+
});
364+
365+
it('handles file exists error during creation by using atomic write', async () => {
366+
const notFoundError = new Error('ENOENT');
367+
(notFoundError as any).code = 'ENOENT';
368+
const existsError = new Error('EEXIST');
369+
(existsError as any).code = 'EEXIST';
370+
371+
mockFs.access.mockRejectedValue(notFoundError);
372+
mockFs.writeFile
373+
.mockRejectedValueOnce(existsError)
374+
.mockResolvedValueOnce(undefined);
375+
mockFs.rename.mockResolvedValue(undefined);
376+
377+
await writeOrUpdateFileContent('/test/file.txt', 'content');
378+
379+
expect(mockFs.writeFile).toHaveBeenCalledTimes(2);
380+
expect(mockFs.rename).toHaveBeenCalled();
381+
});
382+
383+
it('propagates non-ENOENT access errors', async () => {
384+
const error = new Error('Permission denied');
385+
(error as any).code = 'EACCES';
386+
mockFs.access.mockRejectedValue(error);
387+
388+
await expect(writeOrUpdateFileContent('/test/file.txt', 'content'))
389+
.rejects.toThrow('Permission denied');
390+
});
391+
});
392+
289393
});
290394

291395
describe('Search & Filtering Functions', () => {

src/filesystem/index.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
getFileStats,
2222
readFileContent,
2323
writeFileContent,
24+
appendFileContent,
25+
writeOrUpdateFileContent,
2426
searchFilesWithValidation,
2527
applyFileEdits,
2628
tailFile,
@@ -96,6 +98,16 @@ const WriteFileArgsSchema = z.object({
9698
content: z.string(),
9799
});
98100

101+
const AppendFileArgsSchema = z.object({
102+
path: z.string(),
103+
content: z.string(),
104+
});
105+
106+
const WriteOrUpdateFileArgsSchema = z.object({
107+
path: z.string(),
108+
content: z.string(),
109+
});
110+
99111
const EditOperation = z.object({
100112
oldText: z.string().describe('Text to search for - must match exactly'),
101113
newText: z.string().describe('Text to replace with')
@@ -343,6 +355,67 @@ server.registerTool(
343355
}
344356
);
345357

358+
server.registerTool(
359+
"append_file",
360+
{
361+
title: "Append File",
362+
description:
363+
"Append content to the end of an existing file. This operation adds new content " +
364+
"to the file without modifying existing content. The file must already exist - " +
365+
"use write_file to create new files or write_or_update_file to create or append. " +
366+
"Only works within allowed directories.",
367+
inputSchema: {
368+
path: z.string(),
369+
content: z.string()
370+
},
371+
outputSchema: { content: z.string() },
372+
annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: false }
373+
},
374+
async (args: z.infer<typeof AppendFileArgsSchema>) => {
375+
const validPath = await validatePath(args.path);
376+
await appendFileContent(validPath, args.content);
377+
const text = `Successfully appended content to ${args.path}`;
378+
return {
379+
content: [{ type: "text" as const, text }],
380+
structuredContent: { content: text }
381+
};
382+
}
383+
);
384+
385+
server.registerTool(
386+
"write_or_update_file",
387+
{
388+
title: "Write or Update File",
389+
description:
390+
"Create a new file with content, or append to an existing file. If the file " +
391+
"does not exist, it will be created with the provided content. If the file " +
392+
"already exists, the new content will be appended to the end without overwriting " +
393+
"existing content. This is useful when you want to add content to a file but " +
394+
"preserve existing data. Only works within allowed directories.",
395+
inputSchema: {
396+
path: z.string(),
397+
content: z.string()
398+
},
399+
outputSchema: { content: z.string() },
400+
annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: false }
401+
},
402+
async (args: z.infer<typeof WriteOrUpdateFileArgsSchema>) => {
403+
const validPath = await validatePath(args.path);
404+
await writeOrUpdateFileContent(validPath, args.content);
405+
406+
// Determine if file was created or updated for better feedback
407+
const stats = await fs.stat(validPath);
408+
const message = stats.birthtime.getTime() === stats.mtime.getTime()
409+
? `Successfully created ${args.path} with content`
410+
: `Successfully appended content to ${args.path}`;
411+
412+
return {
413+
content: [{ type: "text" as const, text: message }],
414+
structuredContent: { content: message }
415+
};
416+
}
417+
);
418+
346419
server.registerTool(
347420
"edit_file",
348421
{

src/filesystem/lib.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,70 @@ export async function writeFileContent(filePath: string, content: string): Promi
161161
}
162162
}
163163

164+
export async function appendFileContent(filePath: string, content: string): Promise<void> {
165+
// Check if file exists
166+
try {
167+
await fs.access(filePath);
168+
} catch (error) {
169+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
170+
throw new Error(`File does not exist: ${filePath}`);
171+
}
172+
throw error;
173+
}
174+
175+
// Read existing content
176+
const existingContent = await readFileContent(filePath);
177+
178+
// Combine existing and new content
179+
const combinedContent = existingContent + content;
180+
181+
// Use atomic write to update the file
182+
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
183+
try {
184+
await fs.writeFile(tempPath, combinedContent, 'utf-8');
185+
await fs.rename(tempPath, filePath);
186+
} catch (error) {
187+
try {
188+
await fs.unlink(tempPath);
189+
} catch {}
190+
throw error;
191+
}
192+
}
193+
194+
export async function writeOrUpdateFileContent(filePath: string, content: string): Promise<void> {
195+
// Check if file exists
196+
let fileExists = false;
197+
try {
198+
await fs.access(filePath);
199+
fileExists = true;
200+
} catch (error) {
201+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
202+
throw error;
203+
}
204+
}
205+
206+
if (fileExists) {
207+
// File exists, append the content
208+
const existingContent = await readFileContent(filePath);
209+
const combinedContent = existingContent + content;
210+
211+
// Use atomic write to update the file
212+
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
213+
try {
214+
await fs.writeFile(tempPath, combinedContent, 'utf-8');
215+
await fs.rename(tempPath, filePath);
216+
} catch (error) {
217+
try {
218+
await fs.unlink(tempPath);
219+
} catch {}
220+
throw error;
221+
}
222+
} else {
223+
// File doesn't exist, create it with the content
224+
await writeFileContent(filePath, content);
225+
}
226+
}
227+
164228

165229
// File Editing Functions
166230
interface FileEdit {

0 commit comments

Comments
 (0)