Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
538592c
feat(filesystem): add append_file and write_or_update_file tools
agn-7 Dec 24, 2025
22130ba
Merge branch 'main' into feat/filesystem-append-tools
agn-7 Jan 5, 2026
bccb563
Merge branch 'main' into feat/filesystem-append-tools
agn-7 Jan 8, 2026
72f6f59
Merge branch 'main' into feat/filesystem-append-tools
agn-7 Jan 15, 2026
f8a58da
Merge branch 'main' into feat/filesystem-append-tools
agn-7 Jan 30, 2026
d01f1e2
Merge branch 'main' into feat/filesystem-append-tools
agn-7 Feb 6, 2026
3190798
Merge branch 'main' into feat/filesystem-append-tools
agn-7 Feb 13, 2026
277dae8
Merge branch 'main' into feat/filesystem-append-tools
agn-7 Apr 27, 2026
28e3c6a
fix(filesystem): address review feedback on append/write_or_update tools
agn-7 Apr 27, 2026
81d7aa1
refactor(filesystem): rename write_or_update_file to create_or_append…
agn-7 Apr 27, 2026
7368e57
Merge branch 'main' into feat/filesystem-append-tools
agn-7 May 27, 2026
48aad36
Merge branch 'main' into feat/filesystem-append-tools
agn-7 May 28, 2026
a4839a8
Merge branch 'main' into feat/filesystem-append-tools
agn-7 Jun 7, 2026
cfa6abd
Merge branch 'main' into feat/filesystem-append-tools
agn-7 Jul 14, 2026
18fcd3d
Merge branch 'main' into feat/filesystem-append-tools
agn-7 Jul 30, 2026
a256c5a
chore: bring the contribution policy to main ahead of the v2.0.0 merge
cliffhall Oct 1, 2026
9e62490
docs(contributing): sync with v2/main after #4959
cliffhall Oct 4, 2026
e4e721e
chore: sync the PR template with v2/main after #4961
cliffhall Oct 4, 2026
a207787
chore: sync the bug report form with v2/main after #4965
cliffhall Oct 4, 2026
5abed86
Merge pull request #4939 from modelcontextprotocol/v2/chore/4938-cont…
cliffhall Oct 4, 2026
e87a2de
docs(contributing): sync with v2/main after 1cdd4a70
cliffhall Oct 10, 2026
956d36f
Merge pull request #5081 from modelcontextprotocol/v2/chore/5080-cont…
cliffhall Oct 10, 2026
c003d0d
Merge pull request #5090 from modelcontextprotocol/v2/chore/5080-rele…
cliffhall Oct 11, 2026
f39bc6b
ci: bring the main-source guard to main ahead of the next milestone m…
cliffhall Oct 11, 2026
bbb701e
Merge pull request #5122 from modelcontextprotocol/v2/chore/5119-main…
cliffhall Oct 11, 2026
b545744
Merge upstream main and fix filesystem append semantics
agn-7 Oct 12, 2026
9bb47c9
Merge upstream v2/main and verify append tools in both eras
agn-7 Oct 12, 2026
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
5 changes: 5 additions & 0 deletions .changeset/filesystem-append-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@modelcontextprotocol/server-filesystem": minor
---

Add `append_file` for existing files and `create_or_append_file` for creation or append. Append at physical EOF without replacing files, reject missing files for `append_file`, and preserve file identity checks against symlink swaps.
23 changes: 23 additions & 0 deletions src/filesystem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Published on npm as [`@modelcontextprotocol/server-filesystem`](https://www.npmj
## 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 @@ -108,6 +110,25 @@ The server's directory access control follows this flow:
overwrite is not crash-atomic: a crash mid-write can leave the file partly
written. A read-only file is refused rather than replaced

- **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; a missing file is rejected and stays missing
- Appends at physical EOF without replacing the file, preserving its inode and hard links
- Content is appended exactly as provided; include any desired newline in `content`
- 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 @@ -217,6 +238,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
38 changes: 37 additions & 1 deletion src/filesystem/__tests__/eras.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
createServer,
NO_ALLOWED_DIRECTORIES_MODERN_ERROR,
} from "../server.js";
import { makeTempDir, quietStderr } from "./helpers.js";
import { makeTempDir, quietStderr, textOf } from "./helpers.js";

let allowed: string;
let other: string;
Expand Down Expand Up @@ -85,6 +85,42 @@ async function listAllowed(client: Client): Promise<string> {
return (result.content[0] as { text: string }).text;
}

describe.each(["auto", "legacy"] as const)(
"append tools with %s negotiation",
(mode) => {
it("keeps missing files missing, appends at EOF, and creates only through create_or_append_file", async () => {
const { client } = await connect([allowed], mode);
// Legacy clients supply `other` through Roots; modern clients use argv.
const root = mode === "legacy" ? other : allowed;
expect(await listAllowed(client)).toBe(`Allowed directories:\n${root}`);
const missing = path.join(root, "new.txt");
const rejected = (await client.callTool({
name: "append_file",
arguments: { path: missing, content: "bad" },
})) as CallToolResult;
expect(rejected.isError).toBe(true);
await expect(fs.lstat(missing)).rejects.toMatchObject({ code: "ENOENT" });

const created = (await client.callTool({
name: "create_or_append_file",
arguments: { path: missing, content: "before" },
})) as CallToolResult;
expect(created.isError).toBeFalsy();
expect(textOf(created)).toBe(
`Successfully created ${missing} with content`,
);
const before = await fs.stat(missing);
const appended = (await client.callTool({
name: "append_file",
arguments: { path: missing, content: "after" },
})) as CallToolResult;
expect(appended.isError).toBeFalsy();
expect(await fs.readFile(missing, "utf-8")).toBe("beforeafter");
expect((await fs.stat(missing)).ino).toBe(before.ino);
});
},
);

describe("on 2026-07-28", () => {
it("serves only the command-line directories, and never asks for roots", async () => {
const { client, rootsAsked } = await connect([allowed], "auto");
Expand Down
151 changes: 151 additions & 0 deletions src/filesystem/__tests__/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
getFileStats,
readFileContent,
writeFileContent,
appendFileContent,
createOrAppendFileContent,
moveFile,
// Search & filtering functions
searchFilesWithValidation,
Expand Down Expand Up @@ -402,6 +404,155 @@ describe("Lib Functions", () => {
});

describe("File Operations", () => {
describe("appendFileContent", () => {
it("reports a missing file without trying to create it", async () => {
mockFs.lstat.mockRejectedValueOnce(
Object.assign(new Error("missing"), { code: "ENOENT" }),
);
await expect(
appendFileContent("/test/missing.txt", "text"),
).rejects.toMatchObject({
message: "File does not exist: /test/missing.txt",
code: "ENOENT",
});
expect(mockFs.open).not.toHaveBeenCalled();
});

it("reports a file removed between lstat and open as missing", async () => {
mockFs.lstat.mockResolvedValueOnce(REGULAR_FILE);
mockFs.open.mockRejectedValueOnce(
Object.assign(new Error("missing"), { code: "ENOENT" }),
);
await expect(
appendFileContent("/test/file.txt", "text"),
).rejects.toThrow("File does not exist");
});

it("refuses a non-regular path before opening it", async () => {
mockFs.lstat.mockResolvedValueOnce({
...REGULAR_FILE,
isFile: () => false,
} as Stats);
await expect(appendFileContent("/test/link", "text")).rejects.toThrow(
"not a regular file",
);
expect(mockFs.open).not.toHaveBeenCalled();
});

it.each([
{ ...REGULAR_FILE, ino: 43 } as Stats,
{ ...REGULAR_FILE, dev: 2 } as Stats,
{ ...REGULAR_FILE, isFile: () => false } as Stats,
])("refuses a replaced file and closes its handle", async (stats) => {
mockFs.lstat.mockResolvedValueOnce(REGULAR_FILE);
const handle = createMockWriteHandle(stats);
mockFs.open.mockResolvedValueOnce(asWriteHandle(handle));
await expect(
appendFileContent("/test/file.txt", "text"),
).rejects.toThrow("was replaced");
expect(handle.writeFile).not.toHaveBeenCalled();
expect(handle.close).toHaveBeenCalledOnce();
});

it("retains the identity check without POSIX flags on Windows", async () => {
const constants = mockFs.constants as {
O_NOFOLLOW?: number;
O_NONBLOCK?: number;
};
const noFollow = constants.O_NOFOLLOW;
const nonBlock = constants.O_NONBLOCK;
constants.O_NOFOLLOW = undefined;
constants.O_NONBLOCK = undefined;
mockFs.lstat.mockResolvedValueOnce(REGULAR_FILE);
const handle = createMockWriteHandle({
...REGULAR_FILE,
ino: 43,
} as Stats);
mockFs.open.mockResolvedValueOnce(asWriteHandle(handle));
try {
await expect(
appendFileContent("/test/file.txt", "text"),
).rejects.toThrow("was replaced");
expect(mockFs.open).toHaveBeenCalledWith(
"/test/file.txt",
fs.constants.O_WRONLY | fs.constants.O_APPEND,
);
expect(handle.writeFile).not.toHaveBeenCalled();
} finally {
constants.O_NOFOLLOW = noFollow;
constants.O_NONBLOCK = nonBlock;
}
});

it.each(["stat", "writeFile"] as const)(
"closes the handle when %s fails",
async (operation) => {
mockFs.lstat.mockResolvedValueOnce(REGULAR_FILE);
const handle = createMockWriteHandle();
const error = Object.assign(new Error("permission denied"), {
code: "EACCES",
});
handle[operation].mockRejectedValueOnce(error);
mockFs.open.mockResolvedValueOnce(asWriteHandle(handle));
await expect(
appendFileContent("/test/file.txt", "text"),
).rejects.toBe(error);
expect(handle.close).toHaveBeenCalledOnce();
},
);

it("propagates an open error without creating a file", async () => {
mockFs.lstat.mockResolvedValueOnce(REGULAR_FILE);
const error = Object.assign(new Error("permission denied"), {
code: "EACCES",
});
mockFs.open.mockRejectedValueOnce(error);
await expect(appendFileContent("/test/file.txt", "text")).rejects.toBe(
error,
);
});
});

describe("createOrAppendFileContent", () => {
it("propagates append errors other than missing files", async () => {
const error = Object.assign(new Error("permission denied"), {
code: "EACCES",
});
mockFs.lstat.mockRejectedValueOnce(error);
await expect(
createOrAppendFileContent("/test/file.txt", "text"),
).rejects.toBe(error);
expect(mockFs.open).not.toHaveBeenCalled();
});

it("propagates exclusive creation errors", async () => {
mockFs.lstat.mockRejectedValueOnce(
Object.assign(new Error("missing"), { code: "ENOENT" }),
);
const error = Object.assign(new Error("permission denied"), {
code: "EACCES",
});
mockFs.open.mockRejectedValueOnce(error);
await expect(
createOrAppendFileContent("/test/file.txt", "text"),
).rejects.toBe(error);
});

it("closes a newly created handle when writing fails", async () => {
mockFs.lstat.mockRejectedValueOnce(
Object.assign(new Error("missing"), { code: "ENOENT" }),
);
const handle = createMockWriteHandle();
const error = Object.assign(new Error("disk full"), { code: "ENOSPC" });
handle.writeFile.mockRejectedValueOnce(error);
mockFs.open.mockResolvedValueOnce(asWriteHandle(handle));
await expect(
createOrAppendFileContent("/test/file.txt", "text"),
).rejects.toBe(error);
expect(handle.close).toHaveBeenCalledOnce();
});
});

describe("getFileStats", () => {
it("returns file statistics", async () => {
const mockStats = {
Expand Down
Loading
Loading