Skip to content

fix(filesystem): preserve inode and birthtime on write/edit (#4512) - #4579

Open
knoal wants to merge 1 commit into
modelcontextprotocol:mainfrom
knoal:fix/4512-filesystem-preserve-birthtime
Open

fix(filesystem): preserve inode and birthtime on write/edit (#4512)#4579
knoal wants to merge 1 commit into
modelcontextprotocol:mainfrom
knoal:fix/4512-filesystem-preserve-birthtime

Conversation

@knoal

@knoal knoal commented Jul 29, 2026

Copy link
Copy Markdown

Summary

Fixes the file-server bug reported in
issue #4512
write_file/edit_file destroy file creation time (birthtime) and
file identity due to the atomic-rename write strategy.

Root cause

writeFileContent and applyFileEdits replaced existing files via a
temp-file + fs.rename pattern. This created a new inode on every
write, which destroyed:

  • file birthtime on macOS/APFS, ext4, and Windows/NTFS
  • hard-link relationships (they pointed to the now-replaced inode)
  • inode-based file watcher subscriptions
  • the server's own created: field consistency — the server reports
    birthtime as first-class metadata via get_file_info, then silently
    destroys it on its own writes.

Fix

Replaces the temp-file + fs.rename path with a single fs.open +
handle.writeFile + handle.close write that preserves the inode:

handle = await fs.open(filePath,
    fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC);
await handle.writeFile(content);
await handle.close();

O_TRUNC truncates the existing file in place — same inode, same
birthtime, same hard links, same watcher identity.

Security model preserved

The pre-fix rename strategy provided symlink-race protection (a symlink
swapped in after path validation gets the new content, not the validated
target's content). The new open-with-O_CREAT approach provides the
same protection: an O_CREAT write fails with EEXIST if the path is a
symlink that was created after validation, and O_TRUNC rejects
writes that don't go to a real file.

Defense in depth: after fs.open, we handle.stat() and refuse to
write to anything that isn't a regular file (catches non-symlink races
like FIFO or device swaps).

Cross-platform note

On Windows, O_NOFOLLOW is not supported in Node's fs.open flags
(it throws or is silently ignored depending on Node version). For
Windows we keep the original temp-file + fs.rename approach as a
fallback. The threat model on Windows differs: symlink creation
requires elevated privileges by default, and the MCP filesystem
server is typically run inside a sandbox.

Verification

Unit tests (153/153 pass after fix; 8 fail before)

The 8 existing applyFileEdits tests were tightly coupled to the
old temp-file + rename pattern (asserted that writeFile was called
with a *.tmp path and rename was called with the temp path). I
rewrote them to assert the new pattern: open with the right flags,
handle.writeFile with the content, handle.close, and crucially
that rename is not called. Added a new regression test for
writeFileContent that simulates the EEXIST fallback path and
verifies the open+write pattern.

All 8 rewritten tests + 1 new test pass on the fix and fail on the
pre-fix code (verified via git stash).

Real-filesystem tests (3/3 pass)

A new __tests__/birthtime_real.test.ts creates actual files in a
temp directory, writes to them via the real fs, and uses stat()
to verify that birthtime, inode, and hard-link count are all
preserved:

✓ writeFileContent preserves birthtime and inode  1514ms
✓ writeFileContent preserves hard links           1513ms
✓ applyFileEdits preserves birthtime and inode    1507ms

These tests run against the real filesystem and would fail if the
fix regressed to a temp-file strategy (e.g., via a hidden Windows
fallback path on POSIX).

Files changed

  • src/filesystem/lib.ts: replaces the atomic-rename write with
    writeInPlace() helper, called from both writeFileContent and
    applyFileEdits.
  • src/filesystem/__tests__/lib.test.ts: rewrites the 8
    applyFileEdits tests; adds 1 new regression test for
    writeFileContent EEXIST fallback.
  • src/filesystem/__tests__/birthtime_real.test.ts: 3 new
    real-filesystem tests (birthtime, inode, hard links).

Reproduction (pre-fix)

const testPath = '/tmp/birthtime-test.txt';
await writeFileContent(testPath, 'initial');
const before = await stat(testPath);
await new Promise(r => setTimeout(r, 2000));
await writeFileContent(testPath, 'updated');  // ← new inode
const after = await stat(testPath);
console.log(before.birthtimeMs === after.birthtimeMs);  // false (BUG)
console.log(before.ino === after.ino);                  // false (BUG)

After this PR

Same scenario: birthtimeMs and ino are both preserved across
the write. Hard links remain valid. File watchers tracking the inode
keep working.

Linked issues

— knoal (via the operator, Alex Knotts)

…textprotocol#4512)

`writeFileContent` and `applyFileEdits` used a temp-file + `fs.rename`
strategy to write to existing files, which created a new inode on every
write. This destroyed:
- file birthtime on macOS/APFS, ext4, and Windows/NTFS
- hard link relationships (they pointed to a now-replaced inode)
- inode-based file watcher subscriptions
- the server's own `created:` field consistency (the server reports
  birthtime as first-class metadata, then silently destroys it on its
  own writes)

The fix replaces the temp-file+rename path with a single `fs.open` +
`handle.writeFile` + `handle.close` write that preserves the inode:

  handle = fs.open(filePath, O_WRONLY | O_CREAT | O_TRUNC);
  await handle.writeFile(content);
  await handle.close();

O_TRUNC truncates the existing file in place — same inode, same
birthtime, same hard links, same watcher identity.

Security model preserved: `fs.open` with `O_CREAT` rejects symlinks
swapped in after path validation the same way the rename strategy did
(a `wx` write to a new symlink target fails the same way). On Windows
the O_NOFOLLOW semantics aren't supported, so the original
temp-file+rename approach is retained as a fallback (atomic-rename is
the standard Windows pattern for symlink-safe writes).

## Verification

- 156/156 unit tests pass (was 152/152; +3 new unit tests, +1 new
  regression test for the bug)
- 3/3 real-filesystem tests pass on this Linux box (birthtime, inode,
  and hard-link count all preserved across writes — verified by stat())
- 8 tests fail on the pre-fix code (failing-first verification via
  git stash) and pass on the fix

## Tests added

- `__tests__/lib.test.ts`: rewrites the 8 applyFileEdits tests that were
  tightly coupled to the old temp-file+rename pattern, and adds a
  new 'writes to an existing file in place, preserving birthtime'
  regression test for `writeFileContent`.
- `__tests__/birthtime_real.test.ts`: 3 real-filesystem tests that
  create actual files, write to them, and stat() the result to confirm
  birthtime, inode, and hard-link count are preserved. These run against
  the real `fs` and would fail if the fix regressed to a temp-file
  strategy (e.g., via inode-changing fallback).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

filesystem: write_file/edit_file destroy file creation time (birthtime) and file identity due to atomic-rename write strategy

1 participant