Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/5188-filesystem-move-exdev.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@modelcontextprotocol/server-filesystem": patch
---

`move_file` now moves a regular file between allowed directories on different filesystems (mounts, disks or shares), where it used to fail with `EXDEV`: it copies the file to a newly created destination, keeping its permissions and modification time, then removes the source, and removes a partial copy if anything fails. A directory still cannot be moved across filesystems, and now fails with an error that says so.
6 changes: 6 additions & 0 deletions src/filesystem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,12 @@ The server's directory access control follows this flow:
- `source` (string)
- `destination` (string)
- Fails if destination exists
- Across filesystems (the source and destination on different mounts or
disks), a regular file is moved by copying it to a newly created
destination, with its permissions and modification time, and then removing
the source. If the copy fails, the partial destination is removed and the
source is left in place. A directory cannot be moved across filesystems
and fails with an error; move its files one at a time instead

- **search_files**
- Recursively search for files/directories that match or do not match patterns
Expand Down
34 changes: 34 additions & 0 deletions src/filesystem/__tests__/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,40 @@ describe("Lib Functions", () => {

expect(mockPinned.renameIntoDirectory).not.toHaveBeenCalled();
});

it("copies and removes instead when the rename crosses a device (EXDEV)", async () => {
mockFs.lstat.mockRejectedValueOnce(
Object.assign(new Error("not found"), { code: "ENOENT" }),
);
mockPinned.renameIntoDirectory.mockImplementationOnce(() => {
throw Object.assign(new Error("EXDEV: cross-device link"), {
code: "EXDEV",
});
});

await moveFile("/test/source.txt", "/mnt/other/dest.txt");

expect(mockPinned.moveFileAcrossDevices).toHaveBeenCalledWith(
"/test/source.txt",
"/mnt/other/dest.txt",
);
});

it("reports a rename failure other than EXDEV as is, without copying", async () => {
mockFs.lstat.mockRejectedValueOnce(
Object.assign(new Error("not found"), { code: "ENOENT" }),
);
mockPinned.renameIntoDirectory.mockImplementationOnce(() => {
throw Object.assign(new Error("EACCES: permission denied"), {
code: "EACCES",
});
});

await expect(
moveFile("/test/source.txt", "/test/dest.txt"),
).rejects.toThrow("EACCES: permission denied");
expect(mockPinned.moveFileAcrossDevices).not.toHaveBeenCalled();
});
});
});

Expand Down
Loading
Loading