Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: vi.fn(),
close: vi.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