fix(filesystem): write in place to preserve birthtime and file identity (#4512)#4516
Open
0ywfe wants to merge 1 commit into
Open
fix(filesystem): write in place to preserve birthtime and file identity (#4512)#45160ywfe wants to merge 1 commit into
0ywfe wants to merge 1 commit into
Conversation
…ty (modelcontextprotocol#4512) writeFileContent and applyFileEdits replaced existing files via a temp-file + fs.rename pattern. While this provided crash-atomicity and symlink safety, every write created a new inode, destroying the file's creation timestamp (birthtime) and severing hard links. The server's own get_file_info reports birthtime as a first-class field, making this internally inconsistent. Replace the temp+rename pattern for existing files with an in-place write using O_RDWR | O_NOFOLLOW: 1. fs.open(filePath, O_RDWR | O_NOFOLLOW) — rejects symlinks with ELOOP, matching the security guarantee of the rename pattern. 2. fh.truncate(0) + fh.write(content) — writes through the existing file handle, preserving the inode, birthtime, and hard links. New files still use the wx flag (exclusive create), which is unchanged. Trade-off: in-place writes lose crash-atomicity (a crash mid-write can leave a partially written file). The rename pattern avoided this. If crash-atomicity is considered essential for a specific deployment, a follow-up could add an opt-in flag to select the rename strategy. Tests updated to mock the new fs.open + FileHandle path. Added a dedicated test for the EEXIST -> writeInPlace flow in writeFileContent.
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
Closes #4512 —
write_fileandedit_filedestroyed the file's creation timestamp (birthtime) and broke hard links on every write because the temp-file +fs.renamepattern replaced the inode.Root cause
Both
writeFileContentandapplyFileEditsused temp-file +fs.renamefor existing files. While this provided crash-atomicity and symlink safety,renamereplaces the target with a different file (new inode), destroying:birthtimeon macOS/APFS, creation time on Windows/NTFS,crtimeon ext4)The server's own
get_file_inforeportscreated:as a first-class field — silently destroying it on write is internally inconsistent.Fix
Replace the temp+rename pattern for existing files with in-place writes via
O_RDWR | O_NOFOLLOW:fs.open(filePath, O_RDWR | O_NOFOLLOW)— rejects symlinks withELOOP, matching the security guarantee the rename pattern provided (no writes through pre-existing symlinks).fh.truncate(0)+fh.write(content)— writes through the existing file handle, preserving the inode, birthtime, and hard links.New files still use the
wxflag (exclusive create) — unchanged.Both
writeFileContent(EEXIST branch) andapplyFileEdits(non-dry-run branch) now use the sharedwriteInPlacehelper.Trade-off
In-place writes lose crash-atomicity: a crash mid-write can leave a partially written file, which the rename pattern avoided. For the MCP filesystem server's use case (writing text files on behalf of an AI agent, not a database), birthtime/identity preservation is the more useful default. If crash-atomicity is essential for a specific deployment, a follow-up could add an opt-in flag to select the rename strategy.
Test plan
npm run build— TypeScript cleannpm test— all 153 tests pass (7 test files)applyFileEditstests to mock the newfs.open+FileHandlepath instead ofwriteFile+renamewriteFileContent's EEXIST →writeInPlaceflowO_NOFOLLOWrejects symlinks withELOOPon macOS (Node.js exposesfs.constants.O_NOFOLLOW)Files
src/filesystem/lib.ts—writeInPlacehelper, updatedwriteFileContent+applyFileEdits, removed unusedrandomBytesimportsrc/filesystem/__tests__/lib.test.ts— updated mocks + new test