Why Filesystem MCP rejects edit_file dry runs in read-only directories

Filesystem MCP 2.3.0 can read a file in a directory marked --read-only-dir and still return Access denied - read-only directory for an edit_file dry run. The handler checks write access first. The dryRun flag only takes effect later, when the edit helper decides whether to overwrite the file. Filesystem server 2.3.0 release, edit handler

Send the same edit to two files

Use Node.js 24 or newer. Create two scratch directories named work and reference, each with a flags.txt containing theme=light and a newline. Replace /tmp/mcp-demo with the scratch directory that contains them. The --read-only-dir flag also adds its path to the allowed directories. Node requirement, startup path handling

npx -y @modelcontextprotocol/inspector \
  npx -y @modelcontextprotocol/[email protected] \
  /tmp/mcp-demo/work --read-only-dir /tmp/mcp-demo/reference

In MCP Inspector, connect to the server, open Tools, list the tools, and select edit_file. Use the arguments from the request below. The schema requires a path and an array of oldText and newText replacements. The optional dryRun flag defaults to false. MCP Inspector, input schema, MCP tools specification

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "edit_file",
    "arguments": {
      "path": "/tmp/mcp-demo/work/flags.txt",
      "edits": [{ "oldText": "theme=light", "newText": "theme=dark" }],
      "dryRun": true
    }
  }
}

I ran this comparison with the published server, Node 24.13.0, and a client speaking MCP 2025-11-25 over standard input and output. The call for work/flags.txt returned a diff. Changing only the path to reference/flags.txt returned a tool result with isError: true and text beginning Access denied - read-only directory. Both files still contained theme=light afterward.

-theme=light
+theme=dark

The check that runs before the preview

  1. The edit_file handler calls validatePath with access mode "write" for every call. At this point, it has yet to pass dryRun to the helper that applies edits. Tool handler
  2. validatePath resolves the target and checks the allowed directories. For a write, it also calls assertWritable, which throws if the resolved path lies inside a protected directory. In the same test, a symlink in work pointing to reference/flags.txt returned the same error and named the resolved target. Path validation, resolved path check, read-only enforcement
  3. Only an accepted path reaches applyFileEdits. It reads the file, applies the replacements in memory, and builds the diff. When dryRun is true, it skips overwriting the file. The handler returns the diff as text and as structuredContent.content. Edit helper, diff and overwrite, tool result

What to check when the preview fails

When the error names a read-only directory, list_allowed_directories shows the protected path in a separate section. read_text_file can still read it. To generate a preview, copy the text to a scratch file in a writable allowed directory and run the dry run there. Directory listing implementation, read-only directory behavior

With global --read-only, the server removes tools whose readOnlyHint is anything other than true. In the test, tools/list still contained read_text_file, while a direct edit_file call returned the JSON-RPC error Tool edit_file not found. The raw response distinguishes that registration error from the path error, which arrives inside a tool result with isError: true. Global read-only filter, MCP error handling