Skip to content
19 changes: 19 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

- **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:
Expand Down
112 changes: 108 additions & 4 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,
writeOrUpdateFileContent,
// Search & filtering functions
searchFilesWithValidation,
// File editing functions
Expand Down Expand Up @@ -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');
});
});
});
});
62 changes: 62 additions & 0 deletions src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
getFileStats,
readFileContent,
writeFileContent,
appendFileContent,
writeOrUpdateFileContent,
searchFilesWithValidation,
applyFileEdits,
tailFile,
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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) {
Expand Down
64 changes: 64 additions & 0 deletions src/filesystem/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,70 @@ export async function writeFileContent(filePath: string, content: string): Promi
}
}

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