Skip to content
Open
21 changes: 21 additions & 0 deletions src/filesystem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 |

Expand Down
95 changes: 93 additions & 2 deletions src/filesystem/__tests__/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
getFileStats,
readFileContent,
writeFileContent,
appendFileContent,
createOrAppendFileContent,
// Search & filtering functions
searchFilesWithValidation,
// File editing functions
Expand Down Expand Up @@ -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', () => {
Expand Down
67 changes: 66 additions & 1 deletion src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
getFileStats,
readFileContent,
writeFileContent,
appendFileContent,
createOrAppendFileContent,
searchFilesWithValidation,
applyFileEdits,
tailFile,
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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<typeof AppendFileArgsSchema>) => {
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<typeof CreateOrAppendFileArgsSchema>) => {
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",
{
Expand Down
23 changes: 23 additions & 0 deletions src/filesystem/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,29 @@ export async function writeFileContent(filePath: string, content: string): Promi
}
}

export async function appendFileContent(filePath: string, content: string): Promise<void> {
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<void> {
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 {
Expand Down
Loading