Repository navigation
filesystem 1.0.0: write_file/edit_file refuse every overwrite on Windows ("was replaced while it was being opened") because lstat reports dev 0n while fstat reports the volume serial #5154
Description
Which server?
filesystem (@modelcontextprotocol/server-filesystem)
Server version
1.0.0 (npm)
How did you run the server?
npx (npm package)
Transport
stdio
Protocol era
Modern (2026-07-28)
MCP client and version
Claude Desktop 2.37337.1
Operating system and runtime
Windows 11 Home, version 10.0.26200 (build 26200), x64. Node [v23.6.1]. Allowed root D:\Repositories\vault on a local [NTFS] volume.
Server configuration
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"D:\Repositories\vault"
]
}
Unpinned, so npx resolved to 1.0.0. Pinning @modelcontextprotocol/server-filesystem@2026.8.31 with the same arguments makes overwrites work again.
Steps to reproduce
- Start the server with an allowed directory on the affected volume (D:\Repositories\vault).
- Call write_file with {"path": "D:\Repositories\vault\61 - Cooking\_mcp-write-test.md", "content": "test v1\n"}. This succeeds because the file is new.
- Immediately call write_file on the same path with "content": "test v2\n". This fails.
- Call edit_file on the same path with oldText: "test v1", newText: "test v2". This fails with the same error.
Expected behavior
The existing file is overwritten in place, as described for write_file and edit_file in the 1.0.0 changelog (#5022).
Actual behavior
Both tools refuse with Refusing to write: was replaced while it was being opened. The file was not replaced:
get_file_info right after the failure shows created and modified both at 10:28:10. Nothing touched the file between its creation and the failed overwrite.
The file was created by the server itself 9 seconds before the overwrite attempt. No editor, sync client or indexer had it open.
The error is a false positive from the identity check in overwriteInPlace (src/filesystem/lib.ts, added in 7dc802f / #5022). That check compares dev and ino from fs.lstat(path) with dev and ino from handle.stat().
Confirmed cause: running both stats on the same file, with no write involved, gives the same ino but a different dev:
lstat: { dev: 0n, ino: 162974011515481835n }
fstat: { dev: 3156072087n, ino: 162974011515481835n }
The path-based stat reports dev as 0. The handle-based stat reports the real volume serial. In libuv’s Windows code these come from different sources: fs__stat_path, the GetFileInformationByName fast path, takes VolumeSerialNumber.LowPart from FILE_STAT_BASIC_INFORMATION, while fs__stat_handle takes it from FileFsVolumeInformation. On this system the fast path yields 0, so opened.dev !== expected.dev is always true and no existing file can be overwritten. Whether this depends on the filesystem, Windows build or volume type is not established. Windows CI passing suggests it doesn’t happen on every setup.
Side note: the ino here (about 1.6 × 10¹⁷) is above Number.MAX_SAFE_INTEGER, so the non-bigint stat the server uses rounds it. The comparison still matches here because both sides round the same way, but { bigint: true } would make it exact.
Suggested direction, for the maintainers to judge: avoid mixing path-based and handle-based dev values. For example, take both values from handles, or treat dev of 0 from lstat on Windows as unknown and rely on ino plus isFile(). The symlink-swap protection should stay.
Logs or error output
Diagnostic (node -e, lstat vs fstat with { bigint: true } on the same file, read-only):
lstat: { dev: 0n, ino: 162974011515481835n }
fstat: { dev: 3156072087n, ino: 162974011515481835n }
Already prototyped a fix?
pinning @modelcontextprotocol/server-filesystem@2026.8.31, which still uses the temp-file + rename overwrite, avoids the problem
Before you submit
- I searched existing issues and this is not a duplicate.
- This is not a security vulnerability report (those go through the private advisory form).
Activity
charan-rathore commentedon Oct 15, 2026
Confirmed the root cause on current main (bbb701e). overwriteInPlace lstats the path, opens it, then requires the opened handle's device to equal the lstat device. On Windows the path-based lstat can report dev 0 (its fast path omits the volume ID) while the same file seen through the opened handle reports the real device, so every overwrite of an existing file is refused as "was replaced while it was being opened" even though the inode matches and nothing was swapped.
Fix direction that keeps the TOCTOU protection intact: treat the device as matching when opened.dev equals expected.dev, or when the platform is win32 and the lstat-reported device is 0. The isFile and inode checks stay unconditional, and any nonzero device mismatch still refuses, so symlink swaps and real cross-device replacements behave exactly as today on every platform.
I verified the behavior matrix with mocked lstat and handle stats: the win32 dev-0 same-inode case is wrongly rejected today and passes with that one relaxation, while non-Windows dev-0, win32 inode-mismatch, and win32 nonzero-device-mismatch cases all still refuse. The full filesystem suite (345 tests), tsc, and prettier stay green.
fix(filesystem): treat an unusable path device as unknown on Windows (m…
SU-AN-coder commentedon Oct 18, 2026
Confirmed on Windows, with the version range narrowed: this is the Node 23 line, which is why the Windows CI leg never saw it.
The trigger is the Node version
Windows 11 Home 10.0.26200 (the build in the report), NTFS on both
C:andD:, stat only, no write involved —lstaton a file, thenfstaton the same file through an opened handle:devdevinoSame file, same inode, same volume; only
dev, and only on 23.x. The libuv fast-path explanation in the report fits that, but the observable fact is enough on its own: on 23.x, a path-based stat on Windows cannot supply a usable device.test-windowsin.github/workflows/typescript.ymlpinsnode-version: 22, and the legs that do run 24 and 26 areubuntu-latest. So no leg of the matrix can see this: 22 on Windows is clean, 24/26 on Linux is clean.What a user on 23.x gets
Published
1.0.0, over stdio, driven the way a client does it — create the file, overwrite it, edit it:edit_filealways rewrites through that path, so on 23.x on Windows the tool cannot write at all.This repository's own suite, same machine
src/filesystem,vitest run:v2/mainv2/mainv2/mainThe 10 failures in every row but the second are one set: the symlink tests, which need Developer Mode for symlink creation and fail identically on 22. Row two adds 25 —
write_file's in-place tests (keeps the inode, birthtime and permission bits on overwrite (#4512),keeps a hard link on overwrite (#4512),truncates the old content when the new content is shorter,overwrites without a rename, so a rename failing with EPERM does not matter (#3199), …) and everyedit_filetest. The change below fixes all 25 and adds nothing to the failure set on any of the three versions.Prompt
Verified locally on that prompt
v2/mainat 38569bd.npm run validateinsrc/filesystemgreen (prettier, eslint,tsc -p tsconfig.test.json, build, 351 tests);npm run coveragegreen withlib.tsat 100% statements and branches; rootnpm run validateandnpm run validate:guardsgreen. On the Windows machine the suite goes 35 → 10 failed (the unrelated symlink set) under 23.6.1, and the stdio client run above succeeds forwrite_fileandedit_fileon 23.6.1, 24.9.0 and 22.20.0. The change is one commit on my fork,fix/filesystem-win-device-unknown, DCO signed off, if it is useful as a reference; I read every line of that diff myself.One judgement call that is yours, not mine: whether a server-side workaround is wanted for a defect that appears on an odd-numbered line, or whether Windows on 23.x should just be treated as broken.
enginessays>=20, and 23.x is whatnpxresolves to on that line, which is how this report arrived — that is the only reason I would fix it in the server.charan-rathore commentedon Oct 18, 2026
Your CI read is right: test-windows in typescript.yml pins node-version: 22, and with 22 reporting the real serial on both sides in your matrix, no leg could see this.
On the judgment call: engines on v2/main says >=20, and an unpinned npx on the 23 line resolves straight into the broken window, so users land here without doing anything unusual. I'd take the dev-0-as-unknown guard on win32 over declaring the line broken: ino and nonzero-dev mismatches still refuse, so the TOCTOU check keeps its teeth.
cliffhall commentedon Oct 19, 2026
Triage: Priority Medium (total 8)
buglabelStill present on v2 (checked by reading the current code).
Board: Servers V2 (#43), Status
Incoming, awaiting maintainer review (no milestone yet). Part of the backlog triage in #4875.youlemei2022-web commentedon Oct 22, 2026
On the dev-0 guard: agreed that it beats declaring the 23 line unsupported, but it also degrades the check to inode-only for every file on the volume, since
devis 0 on both sides. Two files from different volumes that happen to share an ino then compare equal — rare, and exactly the class of thing the check exists to catch.Cheaper alternatives, in the order I would try them:
{ bigint: true }first. Thedevtruncation to 32 bits is a separate axis from the "path-based lstat returns 0" defect, and if the bigint path reports the real serial on 23 you get the full check back with no special case. That is one test, not a redesign.inoplussizeplusmtimeNsand refuse when any of the three differ. A same-ino, same-size, same-mtime file on another volume is a far smaller window than ino equality alone.process.platform === "win32"and the 23 version line so it can be deleted in one commit when 23 goes EOL, rather than becoming a permanent branch nobody remembers.One CI assertion is worth adding regardless: on the Windows matrix, assert that
lstatSync(p).dev !== 0for an ordinary file in the workspace. If that ever flips on a supported Node line, the premise of the guard has changed and the fallback silently becomes the default path.