Skip to content

feat(filesystem): add append_file and create_or_append_file tools - #2816

Open
agn-7 wants to merge 27 commits into
modelcontextprotocol:v2/mainfrom
agn-7:feat/filesystem-append-tools
Open

agn-7 wants to merge 27 commits into
modelcontextprotocol:v2/mainfrom
agn-7:feat/filesystem-append-tools

Conversation

@agn-7

@agn-7 agn-7 commented Oct 3, 2025 •

Copy link
Copy Markdown

Closes #1208

Description

Add append_file and create_or_append_file to the filesystem server. append_file adds the supplied text at physical EOF of an existing file and rejects missing files without creating them. create_or_append_file creates a missing file or appends to an existing one without overwriting previous content.

This update addresses the correctness report in #2816 (comment) and resolves conflicts and integrates the current v2/main development branch (SDK v2). Tool registrations now live in server.ts, matching the upstream refactor. The earlier name write_or_update_file was renamed to create_or_append_file before merge.

Server Details

  • Server: filesystem
  • Changes: two new tools, append/create helpers, README, a minor changeset, and regression tests.

Motivation and Context

Support append-only logs and incremental documents without reading and rewriting the whole file. The original request #2807 was closed as a duplicate of #1208.

Both tools append the content exactly as provided. Callers include a newline in content when desired.

How Has This Been Tested?

  • Node 22 on macOS: filesystem formatting, lint, typecheck, build, and all 377 runnable tests pass; two platform-specific tests are skipped.
  • Filesystem coverage passes the per-file 90% gate: 100% statements, lines and functions; 99.33% branches overall.
  • Real temporary-file tests through an SDK MCP client verify that missing files remain missing, original bytes and file identity are preserved, hard links see appended bytes, concurrent appends retain their content, and a competing creation is appended to rather than overwritten.
  • Controlled real filesystem swaps verify rejection of symlinks and replacement files between validation, lstat and open. Unit coverage exercises the Windows fallback when POSIX open flags are absent, permission errors, and handle cleanup.
  • Existing tool-discovery, schema, structured-result and stdio tests cover the two new tools.
  • Complete npm run local:gate passes (exit 0) on the exact published tree in an isolated checkout: install freshness, DCO, all TypeScript validation and coverage, all Python validation and coverage, the pinned skills validator, and every server transport smoke.
  • Inspector 2.9.0 CLI: the same tools/call requests pass with explicit --protocol-era legacy and --protocol-era modern. Appending -after to existing.txt yields before-after with the same inode; appending to missing.txt returns File does not exist and leaves it absent; create_or_append_file creates fresh.txt with created.
  • Claude Code 2.1.293, with only this local filesystem server in a strict MCP config and built-in tools disabled, was prompted to perform those three operations and read back the results. Its MCP calls returned successful append, the expected missing-file error, and successful creation; disk assertions confirm before-after, missing remains absent, and created. This client exposes no protocol-era switch, so the LLM run's era is not labelled.
  • Automated regressions additionally exercise the append/create contracts on both protocol eras, including legacy Roots and modern command-line allow-lists.

Breaking Changes

No existing published tool is changed. These are new tools.

Types of changes

  • Bug fix in the proposed append implementation
  • New feature
  • Breaking change
  • Documentation update

Checklist

  • Tool annotations and schemas follow the existing server conventions
  • Preserve upstream path validation and file-identity checks
  • Update the server README
  • Add a minor changeset
  • Test with Inspector in both eras and an LLM client (whose era cannot be selected)
  • New and existing filesystem tests pass locally
  • Add error handling and regression coverage
  • No new environment variables or configuration options

Additional context

The append path uses O_WRONLY | O_APPEND without O_CREAT or O_TRUNC, adds O_NOFOLLOW and O_NONBLOCK where supported, and compares the opened handle's regular-file status, device and inode with the pre-open lstat. Writes use that checked handle and close it in finally. This follows the current upstream file-identity boundary; it does not claim to eliminate every possible parent-directory race.

Creation uses an exclusive wx open. If another process creates the file first, the tool returns to the checked append path. The success message comes from the actual operation, replacing the old pre-operation existence guess.

The three historical feature commits now carry DCO signoffs. Their code, authorship and merge structure are preserved.

@agn-7

agn-7 commented Oct 8, 2025

Copy link
Copy Markdown
Author

Hi

I really need this feature. could you please take a look?

@olaservo @sebastien-rosset @Finndersen @domdomegg

This commit adds two new tools to the filesystem server:

1. **append_file**: Appends content to the end of an existing file.
   - File must already exist
   - Preserves existing content, adds new content at the end

2. **write_or_update_file**: Creates a new file or appends to an existing file.
   - If file doesn't exist: creates it with the provided content
   - If file exists: appends new content to the end
   - Useful when you want to add content while preserving existing data

Changes include:
- Added `appendFileContent` and `writeOrUpdateFileContent` functions to lib.ts
- Added tool registrations and schemas to index.ts
- Updated README.md with new tool documentation
- Added comprehensive tests for the new functions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Benyamin <benyaminjmf@gmail.com>
@agn-7
agn-7 force-pushed the feat/filesystem-append-tools branch from d4d0d52 to d15eb75 Compare December 24, 2025 06:15
@agn-7

agn-7 commented Dec 24, 2025

Copy link
Copy Markdown
Author

I just resolved the merge conflicts in my previously submitted PR. Could you please review it to help prevent future conflicts?

@cliffhall cliffhall added enhancement New feature or request server-filesystem Reference implementation for the Filesystem MCP server - src/filesystem labels Apr 20, 2026
@cliffhall

Copy link
Copy Markdown
Member

@claude review

@claude

claude Bot commented Apr 21, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 1m 43s —— View job


PR Review: feat(filesystem): add append_file and write_or_update_file tools

  • Gather context and understand request
  • Read changed files
  • Review lib.ts changes
  • Review index.ts changes
  • Review test changes
  • Review README changes
  • Post comprehensive feedback

Thanks for this PR, @agn-7. The feature direction is sound — there's a real gap between "always overwrite" and "no append option." A few issues need addressing before merge.

Critical Issues

1. Memory-inefficient read-modify-write in appendFileContent (lib.ts:187-215)

Both new functions read the entire file into memory, concatenate, then write atomically. For their stated use case (log files, incrementally-built documents), this is O(n) memory in file size — exactly where you'd expect large files. Node's fs.appendFile with the 'a' flag is designed for this and would be far simpler:

export async function appendFileContent(filePath: string, content: string): Promise<void> {
  try {
    await fs.access(filePath);
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
      throw new Error(`File does not exist: ${filePath}`);
    }
    throw error;
  }
  await fs.appendFile(filePath, content, 'utf-8');
}

The existing atomic-rename pattern in writeFileContent makes sense because it prevents symlink-swap attacks on a full overwrite. For appending, fs.appendFile opens with O_WRONLY|O_CREAT|O_APPEND which is atomic at the OS level for the append operation itself. The upstream validatePath has already resolved symlinks, so the extra complexity isn't justified here. Fix this →

2. TOCTOU race between fs.access and fs.readFile (lib.ts:188-199)

fs.access(filePath)   ← file exists here
                      ← file deleted by another process
fs.readFile(filePath) ← throws ENOENT unexpectedly

The check-then-act pattern is inherently racy. Cleaner approach — just attempt the read directly:

let existingContent: string;
try {
  existingContent = await readFileContent(filePath);
} catch (error) {
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
    throw new Error(`File does not exist: ${filePath}`);
  }
  throw error;
}

This eliminates the race and removes a redundant syscall.

3. Unreliable birthtime === mtime heuristic (index.ts:416-419)

const message = stats.birthtime.getTime() === stats.mtime.getTime()
  ? `Successfully created ${args.path} with content`
  : `Successfully appended content to ${args.path}`;

After an atomic rename(), the target file's metadata reflects the temp file that replaced it — birthtime and mtime will almost never be equal on Linux (where birthtime support varies by filesystem). This heuristic will give wrong feedback in nearly all cases. Track file existence before the operation instead:

const existed = await fs.access(validPath).then(() => true).catch(() => false);
await writeOrUpdateFileContent(validPath, args.content);
const message = existed
  ? `Successfully appended content to ${args.path}`
  : `Successfully created ${args.path} with content`;

Fix this →


Design Issues

4. Code duplication (lib.ts:229-247)

The if (fileExists) branch in writeOrUpdateFileContent is a verbatim copy of the core logic in appendFileContent. If the atomic-write block changes (e.g., a bug fix), it needs updating in two places. The fix branch should just call appendFileContent:

if (fileExists) {
  await appendFileContent(filePath, content);
} else {
  await writeFileContent(filePath, content);
}

5. Semantics of write_or_update_file name (index.ts:394)

"Update" typically implies overwriting or modifying existing content. The actual behavior here is "create or append," which may surprise users who interpret "update" as replacing content. Consider create_or_append_file for clarity, though this is a judgment call and any name change should be weighed against discoverability. At minimum, the description is clear, so this is low priority.


Minor Issues

6. Three identical Zod schemas (index.ts:105-118)

WriteFileArgsSchema, AppendFileArgsSchema, and WriteOrUpdateFileArgsSchema are identical { path, content } shapes. These can share a single schema definition to reduce repetition.

7. Tests verify mock calls, not behavior

The new tests (e.g., 'appends content to existing file') assert that the right mock functions were called with the right arguments but don't test that the resulting file content is correct end-to-end. This is consistent with the existing test style in this file, so it's not a blocker, but the tests would catch more bugs if at least a couple used real temp files (similar to how the Python servers test).


What Looks Good

  • The atomic rename pattern carried over from writeFileContent is the right idea
  • Tool annotations (readOnlyHint, destructiveHint, idempotentHint) are set correctly
  • README is updated with the new tools and the annotations table
  • Error handling for non-ENOENT errors propagates correctly
  • validatePath is called before these functions (ensuring security checks precede file operations)

agn-7 and others added 2 commits April 27, 2026 09:39
- Use fs.appendFile directly instead of read-modify-write (O(1) memory)
- Eliminate TOCTOU race between fs.access and fs.readFile
- Replace unreliable birthtime===mtime heuristic with pre-op existence check
- Have writeOrUpdateFileContent delegate to appendFileContent (no duplication)
- Consolidate three identical Zod schemas into one PathContentArgsSchema

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Benyamin <benyaminjmf@gmail.com>
@agn-7

agn-7 commented Apr 27, 2026

Copy link
Copy Markdown
Author

Pushed 54ed837 addressing the review. Per-item response:

1. Memory-inefficient read-modify-write — fixed. appendFileContent now calls fs.appendFile(path, content, { encoding: 'utf-8', flag: 'a' }) directly. validatePath already resolves symlinks before this point, so the OS-level O_APPEND is sufficient and we drop the temp-file dance.

2. TOCTOU race — fixed. Removed the fs.access precheck. We attempt the append directly and translate ENOENT into the existing "File does not exist" error. One syscall, no race window.

3. Unreliable birthtime === mtime heuristic — fixed. The write_or_update_file handler now records existed via fs.access(...).then(()=>true).catch(()=>false) before the write and uses that boolean to pick the response message.

4. Code duplication — fixed. writeOrUpdateFileContent now calls appendFileContent first and falls back to writeFileContent only on the File does not exist error. Single source of truth for the append path.

6. Three identical Zod schemas — consolidated into PathContentArgsSchema, with WriteFileArgsSchema / AppendFileArgsSchema / WriteOrUpdateFileArgsSchema aliasing it for readability at the call sites.

5. write_or_update_file naming — leaving as-is for now. Renaming would be a breaking change for users who may already have wired this tool name from the existing PR; happy to rename to create_or_append_file if maintainers prefer.

7. Mock-based tests — left as-is, per the reviewer's note that this matches the existing style. Tests were updated to reflect the new direct-fs.appendFile implementation; all 153 filesystem tests pass.

cc @cliffhall @olaservo

…_file

Renames the tool, schema, and underlying function to accurately describe
the behavior (create-or-append, not update/overwrite) per review feedback.

- Tool: write_or_update_file -> create_or_append_file
- Schema: WriteOrUpdateFileArgsSchema -> CreateOrAppendFileArgsSchema
- Function: writeOrUpdateFileContent -> createOrAppendFileContent
- README updated; tests updated; append_file description updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Benyamin <benyaminjmf@gmail.com>
@agn-7

agn-7 commented Apr 27, 2026

Copy link
Copy Markdown
Author

Follow-up on item 5: rename applied in ebc44b2.

write_or_update_file → create_or_append_file (and the corresponding WriteOrUpdateFileArgsSchema / writeOrUpdateFileContent symbols renamed to match). Reasoning: since the PR has not merged yet, there are no real users wired to the old name, so the discoverability concern outweighs backward-compatibility. The new name now reflects the actual semantics (create-or-append, not update/overwrite). README and append_file description updated; all 153 tests still pass.

@agn-7

agn-7 commented May 3, 2026

Copy link
Copy Markdown
Author

@claude review, I fixed the things you mentioned.

@agn-7

agn-7 commented Jul 30, 2026

Copy link
Copy Markdown
Author

this PR also resolves this issue #1208

cliffhall and others added 2 commits October 1, 2026 09:58
CONTRIBUTING.md, the PR template and the issue forms, byte-identical to
v2/main, so the default branch states the issues-not-PRs policy before
external pull requests are turned off. Nothing else changes and nothing
is released.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
Takes CONTRIBUTING.md from v2/main, which dropped the transitional
blank-issue fallback (modelcontextprotocol#4940), so this branch stays byte-identical to
v2/main and main does not receive the stale sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
cliffhall and others added 5 commits October 3, 2026 23:35


Takes .github/pull_request_template.md from v2/main, which reduced it to
the issues-not-PRs banner (modelcontextprotocol#4960), so this branch stays byte-identical
to v2/main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
…ol#4965

Takes .github/ISSUE_TEMPLATE/1-bug_report.yml from v2/main, whose
Server version placeholder now shows both version schemes (modelcontextprotocol#4964), so
this branch stays byte-identical to v2/main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
…l/v2/chore/4938-contribution-policy-to-main

chore: bring the contribution policy to main ahead of the v2.0.0 merge
Brings main's CONTRIBUTING.md byte-identical to v2/main's, so the v1.0.0 milestone merge (modelcontextprotocol#5080) produces v2/main's tree without a conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
…l/v2/chore/5080-contributing-sync-to-main

docs(contributing): sync main with v2/main ahead of the v1.0.0 merge
@X1pheR

X1pheR commented Oct 10, 2026

Copy link
Copy Markdown

One remaining correctness issue in the latest head (2925cdf): appendFileContent calls fs.appendFile(path, content, { encoding: 'utf-8', flag: 'a' }). In Node 22, flag 'a' includes O_CREAT, so append_file silently creates a previously nonexistent file, contrary to the PR's documented must-already-exist contract. The mocked ENOENT test misses this because the real fs.appendFile cannot return ENOENT for an absent filename when its parent directory exists.

I reproduced this with an isolated real filesystem test in Node 22: appendFileSync(missingPath, 'hello', { flag: 'a' }) created the file. Opening with fs.constants.O_WRONLY | fs.constants.O_APPEND without O_CREAT instead returned ENOENT and did not create the missing file.

Could you make append_file use a real open-without-create path and add a temp-directory integration regression that verifies both (a) an existing file gains bytes at physical EOF without inode replacement and (b) a missing file stays missing? For symlink swaps, please preserve or strengthen the filesystem server's current validation boundary when selecting open flags, including platform differences. The create-or-append tool can deliberately retain creation semantics as a separate path.

This overlaps a maintained downstream's verified physical EOF append requirement, so I'd rather help the existing PR reach correct semantics than open a duplicate PR.

cliffhall and others added 4 commits October 10, 2026 22:25
…l/v2/chore/5080-release-v1.0.0-sdk1

Release v1.0.0: merge v2/release/1.0.0 into main
…erge

The guard (modelcontextprotocol#5119, PR modelcontextprotocol#5120 on v2/main) runs on pull_request_target, which
loads the workflow and script from main. Bring the same three files here
byte-for-byte, so the check can run on, and be required for, PRs into main
before the next milestone merge carries them.

Part of modelcontextprotocol#5119

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
…l/v2/chore/5119-main-source-bootstrap

ci: bring the main-source guard to main (bootstrap for modelcontextprotocol#5119)
Open existing files with O_APPEND and without O_CREAT; preserve inode and symlink-swap checks across platforms. Use exclusive creation for create_or_append_file and append after a competing creation instead of overwriting. Move tool registrations into the refactored server and add real filesystem regression coverage.

Signed-off-by: Benyamin <benyaminjmf@gmail.com>
@agn-7

agn-7 commented Oct 12, 2026 •

Copy link
Copy Markdown
Author

Thanks @X1pheR — fixed in 9bb47c9. append_file now opens without O_CREAT, preserves file identity, and follows the current symlink-swap checks, including the Windows fallback. Real filesystem regressions cover missing files staying missing, exact EOF append without inode replacement, and creation races.

This same PR is now updated against v2/main and retargeted there to satisfy the development-branch rules. Merge conflicts are resolved and the old feature commits carry DCO signoffs. All 377 runnable filesystem tests and coverage pass on Node 22; the complete pre-push gate passed for the final tree. Inspector smoke tests pass in both protocol eras, and a Claude Code client successfully exercised append, the expected missing-file error, and creation. The PR description records the implementation and verification evidence.

@cliffhall, I'd like to carry this existing filesystem contribution through review and contribute more in this area. Could you review the updated PR or advise the next step within the maintainers' workflow? I'd appreciate the opportunity to complete this contribution in coordination with you.

@agn-7
agn-7 force-pushed the feat/filesystem-append-tools branch from 2925cdf to b545744 Compare October 12, 2026 07:19
@changeset-bot

changeset-bot Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9bb47c9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@modelcontextprotocol/server-filesystem Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@agn-7 agn-7 changed the title feat(filesystem): add append_file and write_or_update_file tools feat(filesystem): add append_file and create_or_append_file tools Oct 12, 2026
Signed-off-by: Benyamin <benyaminjmf@gmail.com>
@agn-7
agn-7 changed the base branch from main to v2/main October 12, 2026 07:31

This branch has not been deployed

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

Labels

enhancement New feature or request server-filesystem Reference implementation for the Filesystem MCP server - src/filesystem

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Filesystem] add append tools

3 participants