fix(filesystem): preserve inode and birthtime on write/edit (#4512) - #4579
Open
knoal wants to merge 1 commit into
Open
fix(filesystem): preserve inode and birthtime on write/edit (#4512)#4579knoal wants to merge 1 commit into
knoal wants to merge 1 commit into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes the file-server bug reported in
issue #4512
—
write_file/edit_filedestroy file creation time (birthtime) andfile identity due to the atomic-rename write strategy.
Root cause
writeFileContentandapplyFileEditsreplaced existing files via atemp-file +
fs.renamepattern. This created a new inode on everywrite, which destroyed:
created:field consistency — the server reportsbirthtime as first-class metadata via
get_file_info, then silentlydestroys it on its own writes.
Fix
Replaces the temp-file +
fs.renamepath with a singlefs.open+handle.writeFile+handle.closewrite that preserves the inode:O_TRUNCtruncates the existing file in place — same inode, samebirthtime, 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_CREATapproach provides thesame protection: an
O_CREATwrite fails with EEXIST if the path is asymlink that was created after validation, and
O_TRUNCrejectswrites that don't go to a real file.
Defense in depth: after
fs.open, wehandle.stat()and refuse towrite to anything that isn't a regular file (catches non-symlink races
like FIFO or device swaps).
Cross-platform note
On Windows,
O_NOFOLLOWis not supported in Node'sfs.openflags(it throws or is silently ignored depending on Node version). For
Windows we keep the original temp-file +
fs.renameapproach as afallback. 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
applyFileEditstests were tightly coupled to theold temp-file + rename pattern (asserted that
writeFilewas calledwith a
*.tmppath andrenamewas called with the temp path). Irewrote them to assert the new pattern:
openwith the right flags,handle.writeFilewith the content,handle.close, and cruciallythat
renameis not called. Added a new regression test forwriteFileContentthat simulates the EEXIST fallback path andverifies 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.tscreates actual files in atemp directory, writes to them via the real
fs, and usesstat()to verify that birthtime, inode, and hard-link count are all
preserved:
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 withwriteInPlace()helper, called from bothwriteFileContentandapplyFileEdits.src/filesystem/__tests__/lib.test.ts: rewrites the 8applyFileEdits tests; adds 1 new regression test for
writeFileContentEEXIST fallback.src/filesystem/__tests__/birthtime_real.test.ts: 3 newreal-filesystem tests (birthtime, inode, hard links).
Reproduction (pre-fix)
After this PR
Same scenario:
birthtimeMsandinoare both preserved acrossthe write. Hard links remain valid. File watchers tracking the inode
keep working.
Linked issues
write_file/edit_filedestroy file creation time (birthtime) and file identity due to atomic-rename write strategy #4512separate fix)
— knoal (via the operator, Alex Knotts)