Skip to content

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

Open

Description

@mv-jumbo

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

  1. Start the server with an allowed directory on the affected volume (D:\Repositories\vault).
  2. 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.
  3. Immediately call write_file on the same path with "content": "test v2\n". This fails.
  4. 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

  1. charan-rathore commented on Oct 15, 2026

    @charan-rathore

    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.

  2. SU-AN-coder commented on Oct 18, 2026

    @SU-AN-coder

    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: and D:, stat only, no write involved — lstat on a file, then fstat on the same file through an opened handle:

    node lstat dev fstat dev ino
    22.20.0 716763443 716763443 equal
    23.6.1 0 716763443 equal
    23.11.1 0 716763443 equal
    24.9.0 716763443 716763443 equal
    25.1.0 716763443 716763443 equal

    Same 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-windows in .github/workflows/typescript.yml pins node-version: 22, and the legs that do run 24 and 26 are ubuntu-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:

    node 23.6.1
      write_file (new file)  OK
      write_file (overwrite) FAILED  Refusing to write: ...\_mcp-write-test.md was replaced while it was being opened
      edit_file              FAILED  Refusing to write: ...\_mcp-write-test.md was replaced while it was being opened
      on-disk content unchanged ("test v1\n")
    
    node 22.20.0: all three OK
    

    edit_file always 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:

    failed passed skipped total
    22.20.0, current v2/main 10 335 2 347
    23.6.1, current v2/main 35 310 2 347
    24.9.0, current v2/main 10 335 2 347
    23.6.1, with the change below 10 339 2 351
    24.9.0, with the change below 10 339 2 351

    The 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 every edit_file test. The change below fixes all 25 and adds nothing to the failure set on any of the three versions.

    Prompt

    In src/filesystem/lib.ts, overwriteInPlace's identity check refuses when
    opened.dev !== expected.dev. On Windows with Node 23, lstat reports dev 0 while
    the same file through the opened handle reports the real volume serial, so every
    overwrite of an existing file is refused. Treat a zero device from the
    path-based stat as unknown on win32, leaving the rest of the check alone:
    
      const deviceUnavailable = process.platform === "win32" && expected.dev === 0;
      if (
        !opened.isFile() ||
        opened.ino !== expected.ino ||
        (!deviceUnavailable && opened.dev !== expected.dev)
      ) { ... }
    
    isFile(), the inode comparison and any nonzero device mismatch keep refusing, so
    a symlink or a different file swapped between the lstat and the open is still
    caught. Record the exception in the TOCTOU bullet of the doc comment above
    overwriteInPlace. Add to the overwrite describe block in
    src/filesystem/__tests__/lib.test.ts, in the style of the existing "another
    device" test, stubbing the platform with Object.defineProperty(process,
    "platform", ...) as win32.test.ts does:
    (1) win32, lstat dev 0, handle dev 716763443, equal ino: overwrites and writes;
    (2) the same with a different ino: still refuses;
    (3) win32, dev 2 vs dev 3: still refuses;
    (4) off Windows, dev 0 vs a real serial: still refuses.
    The first fails on the parent commit; the other three pin what must not change.
    Add a patch changeset for @modelcontextprotocol/server-filesystem.
    

    Verified locally on that prompt

    v2/main at 38569bd. npm run validate in src/filesystem green (prettier, eslint, tsc -p tsconfig.test.json, build, 351 tests); npm run coverage green with lib.ts at 100% statements and branches; root npm run validate and npm run validate:guards green. 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 for write_file and edit_file on 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. engines says >=20, and 23.x is what npx resolves to on that line, which is how this report arrived — that is the only reason I would fix it in the server.

  3. charan-rathore commented on Oct 18, 2026

    @charan-rathore

    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.

  4. added
    server-filesystemReference implementation for the Filesystem MCP server - src/filesystem
    on Oct 19, 2026
  5. cliffhall commented on Oct 19, 2026

    @cliffhall
    Member

    Triage: Priority Medium (total 8)

    • Severity 4: On Windows under Node 23.x the path-based lstat reports device 0 while the opened handle reports the real volume serial, so the identity check in overwriteInPlace refuses every overwrite of an existing file; the check is unchanged on v2/main, but 2.0.0 raised the supported Node floor to 24, where the two stats agree, so the defect now affects only users on the unsupported 23 line (engines is advisory, so npx still runs there).
    • Urgency 3: idle more than 90 days, or wanted soon
    • Bonuses: +1 bug label

    Still 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.

  6. youlemei2022-web commented on Oct 22, 2026

    @youlemei2022-web

    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 dev is 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:

    1. Compare the stats with { bigint: true } first. The dev truncation 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.
    2. If it still reports 0, define identity as ino plus size plus mtimeNs and 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.
    3. If you keep the escape hatch, gate it on 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 !== 0 for 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.

  7. SU-AN-coder commented on Oct 23, 2026

    @SU-AN-coder

    You are right about the degradation: where the path-based stat reports dev 0, the check compares ino and nothing else — 0 === 0 on both sides — so a same-ino file on another volume goes unchallenged. Accepted, and it is worse than you describe.

    The fallback is not inode equality on Windows either. Stats.ino from a non-bigint stat is a double, and Windows file ids are 64-bit. Above 2^53 the number form rounds: on Windows 11, NTFS C:, node 20.18.0, one file's id read 57139420272459647 as a BigInt and 57139420272459650 as a number (0xcb00000002ff7f, 56 bits). A file on D: came back 52 bits and matched exactly. So the escape hatch compares rounded ids on the one platform it exists for, which is a second, independent reason to want the BigInt route.

    1. { bigint: true }

    Structurally it cannot recover the value, and I cannot measure 23.x/Windows to be sure. Two parts:

    • The 32-bit axis does not exist on Windows. dev there is the NTFS volume serial, a 32-bit quantity: C: 3438858590 (0xccf8d15e), D: 716763443 (0x2ab8f133 — the serial in the original report's table, so the same volumes). Measured on win32 with node 20.18.0 and 22.20.0: number dev === BigInt dev exactly, on both volumes, path stat and handle stat alike. Nothing wider is being truncated; the 0 is already in what the path-based stat filled.
    • use_bigint does not change the call. In src/node_file.cc (v23.6.1, same shape in v24.9.0) stat/lstat/fstat all call uv_fs_stat/uv_fs_lstat/uv_fs_fstat, and pass use_bigint only into FillGlobalStatsArray — one uv_stat_t, rendered either as a number or as a BigInt. The 0 is written into that struct by libuv itself: the STATUS_NOT_IMPLEMENTED branch of fs__stat_handle sets the volume serial to 0. So I expect the test to print 0n; I did not design for the opposite, but expectation is not measurement.

    Why I cannot measure it: there is no Node 23 on this Windows install. The only node.exe binaries present are 8.11.4 and 8.16.0 (Adobe), 20.18.0 (LM Studio) and D:\New Folder\Node\node.exe, which is 22.20.0; no nvm/fnm/Volta/scoop, nothing in Downloads. I did not install one, so that column stays open — it is a one-liner for anyone with 23.x:

    const fs = require("fs"); const p = "C:\\path\\to\\an\\existing\\file";
    console.log(fs.lstatSync(p).dev, fs.lstatSync(p, { bigint: true }).dev,
                typeof fs.lstatSync(p, { bigint: true }).dev);

    Two things that came out of looking for it, relevant to option 3: the five rows line up with the libuv bundled in each release — broken is 1.49.2 (23.6.1) and 1.50.0 (23.11.1); 1.51.0 is clean, and 22.20.0 ships 1.51.0 too (read from each tag's deps/uv/include/uv/version.h). So the exposure boundary is libuv < 1.51.0, of which the 23 line is currently the only representative — not "odd-numbered releases". I could not pin the change that accounts for it: the only volume-serial edit in src/win/fs.c between 1.50.0 and 1.51.0 is cosmetic (.QuadPart → .LowPart at both the assignment and the st_dev read). Correlation, not cause.

    2. ino plus size plus mtimeNs

    Measured: mtimeNs is undefined on non-bigint Stats on every Node I can run (win32 20.18.0, win32 22.20.0, linux 24.13.1), so this cannot be built on the number stats the code uses today — it needs { bigint: true } on both the path stat and handle.stat(). Once there it is strictly stricter than ino-only and needs no platform branch. Costs visible in the current code: expected and opened become BigIntStats, so the fixture at src/filesystem/__tests__/lib.test.ts:83 and the "refuses to write when the opened file is on another device" test at line 572 have to move to BigInt fields — leave them mixed and every comparison refuses, because 1n !== 1. One property to keep in mind while weighing it: on NTFS mtimeNs has 100 ns resolution, not ns (measured: the values end in 00), which is still ~10^7 ticks per second of discrimination.

    3. Gating the hatch

    No strong objection, one correction: gate on the fact (win32 && expected.dev === 0), which is already inert on every clean line, and let the CI assertion below be what tells you the premise changed. A process.versions.node parse buys little there and adds a second branch to a TOCTOU check. If the version line is wanted anyway, the Node major is the right axis (deletable at 23's EOL) and the comment should record that the real boundary is libuv < 1.51.0.

    Order I would take

    The one-liner first: if it returns the serial, options 2 and 3 are moot. If it returns 0n, take option 2 — it deletes my special case instead of gating it. Option 3 only if the BigInt and mtimeNs footprint is judged too much for this check.

    The CI assertion

    Put it in the filesystem suite rather than in a workflow step, so it runs under every Windows leg and any future Node line. test-windows (.github/workflows/typescript.yml:155, runs-on: windows-latest, node-version: 22 at line 169, npm test) passes it today, and note it could not have failed for this bug: 22 bundles libuv 1.51.0, the validate matrix (line 94: node: [22, 24, 26]) is ubuntu-latest only, and no leg of the matrix runs 23 on Windows — which is why it was never seen. For a file created in the workspace, assert both:

    • lstatSync(p).dev !== 0 (your point), and
    • lstatSync(p).dev === fstatSync(fd).dev — non-zero alone would not catch a serial that went constant or wrong, and path/handle agreement is exactly the premise overwriteInPlace rests on.

    If option 2 lands, that suite is also where to pin that the check is on BigInt stats: the same file's statSync(p, { bigint: true }).ino is a BigInt and equal to the handle's.

  8. youlemei2022-web commented on Oct 23, 2026

    @youlemei2022-web

    The 2^53 rounding is the part that settles it — a fallback that compares rounded ids is worse than no fallback, because it fails open while looking like it enforces something. One more data point from a Windows 11 / NTFS C: box on Node 24: lstatSync(...).ino came back 9007199254771186, which is already past 2^53, so this is not a 23.x-only artifact of the volume-serial bug. Any identity comparison built on the number form is lossy on this platform regardless of Node line.

    Given that, the ordering I would argue for is:

    1. statSync(p, { bigint: true }) first — not to recover dev, but because ino is unusable as a number at all. Even if dev still reports 0n, ino is then exact, and ino + size + mtimeNs becomes a real comparison instead of a rounded one.
    2. If you want the volume serial back, take it from the open handle rather than the path. fs.openSync(p, 'r+') then fstatSync(fd, { bigint: true }) gives the value the overwrite path already trusts, and it is the same handle you are about to write through, so there is no window between check and use.
    3. Keep the path-based lstat only for the "was this replaced" question, and make its failure mode "refuse" rather than "skip". A check that silently degrades to 0 === 0 is worse than a check that errors, because the caller cannot tell the difference.

    The assertion worth adding to the Windows matrix is the cheap version of all this: lstatSync(p, { bigint: true }).ino !== BigInt(Number(lstatSync(p).ino)) on a file with a high id. If that passes today, it fails the moment someone reintroduces a number round-trip.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingserver-filesystemReference implementation for the Filesystem MCP server - src/filesystemv2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions