Skip to content
Agentic Control Plane

The filesystem MCP server control model, explained

The filesystem server is the reference server for this series’ most personal surface: the agent’s own disk. It’s maintained by the Model Context Protocol project itself (modelcontextprotocol/servers), survived the May 2025 reference-server cull, and at ~232k weekly npm downloads has the highest raw install volume of the ten servers we surveyed. Its control model is also the simplest: one boundary — a list of allowed directories — and nothing else. This page is what that one boundary checks, who can move it, and the two times it broke. Sources: the official README and the linked issues and advisories.

This page covers the server’s own controls. For the cross-server picture, see the MCP server controls comparison; for what a control layer adds on top, the coverage matrix.

What’s at stake on this surface

Unlike GitHub or Supabase, there’s no service on the other side of this server — the blast radius is the local machine. The tool surface includes write_file, edit_file, move_file, and create_directory alongside the reads (read_text_file, read_multiple_files, search_files, directory_tree). File operations here are effectively destructive: an overwrite has no previous version, a move has no trash, and none of it has an undo. The reference incident for this surface is the Amazon Q wipe — an agent with file and shell access executing deletion it was injected into running. That’s the sentence the one control below exists to narrow.

The allowed-directories boundary

Directories are passed as CLI arguments in the client config:

{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": [
        "-y", "@modelcontextprotocol/server-filesystem",
        "/Users/you/projects/one",
        "/Users/you/projects/two"
      ]
    }
  }
}

Every tool call validates its path arguments against that list; list_allowed_directories reports the current state; the server errors at startup if the list would be empty. That’s the entire native model — one list, checked in-process on every call. It’s a real restriction and the right first move: an agent that only needs one project directory should be handed exactly one. But note what kind of control it is: a path check in the same process the agent drives, configured in a file the agent can typically edit, with no distinction between reading a path and destroying it.

The roots quirk: the client can rewrite the fence

The README offers a second way to set allowed directories — the MCP roots protocol — and documents the interaction plainly: roots provided by the client completely replace the server-side allowed directories. At initialization, if the client advertises roots support, the server fetches roots/list and swaps the list in; a roots/list_changed notification repeats the swap mid-session. Issue #3602 documents the practical consequence against v2026.1.14: start the server with four CLI directories, connect a roots-capable client, and list_allowed_directories returns only the client’s roots — the CLI directories are dropped, silently.

The project’s framing is ergonomic — roots let the boundary follow the client’s workspace without a restart — and as a workspace convenience that’s fair. As a control it means the boundary is client-writable at runtime. The client is the process the agent lives in; whatever the agent’s session can convince the client stack to send as roots is the new fence. A directory list that the side being restricted can replace mid-session is configuration, not enforcement — the same shape as GitHub’s dynamic toolsets, one layer down. If your safety story is “the server can only see /projects/one,” verify it holds against your actual client, not just your config file.

The check has failed twice

Two CVEs, both disclosed mid-2025, both in the boundary check itself:

  • CVE-2025-53110 — the allowed-directory check used path-prefix matching, so /tmp/allowed_evil passed a check meant for /tmp/allowed. CVSS 7.3.
  • CVE-2025-53109 — a symlink placed inside an allowed directory could be followed out of it. CVSS 7.3.

Both were patched in July 2025 (versions 0.6.4 / 2025.7.01), and neither has known exploitation. The point isn’t that the maintainers were careless — prefix bugs and symlink races are the two classic ways path restriction fails, and plenty of mature software has shipped both. The point is that when the only control is an in-process path check, every bug in that check is a full boundary failure with the disk as blast radius. There is no second layer to hold.

What doesn’t exist

  • No read-only mode. There is no flag, and no way to serve only the read tools. The tools carry MCP annotations (readOnlyHint, destructiveHint) so clients can tell reads from writes — but annotations are hints to the client, not enforcement by the server.
  • No per-tool filtering. You cannot launch the server without write_file or move_file on the surface.
  • No approvals. No call can be held for a human by the server; any confirmation dialog is the client’s courtesy.
  • No audit. The server writes no record of which files an agent read, wrote, moved, or deleted — the general MCP audit gap, on the surface where “what did it delete” is the first question anyone asks.

The honest read-only workaround is the documented Docker setup: bind-mount directories into the container with the ro option, and the OS refuses writes regardless of what the tool layer does. That’s enforcement below the server — the strongest property available on this surface, the same layer-below logic as Supabase’s read-only Postgres role, and the reason sandboxing is the control that contains the blast. What it costs: a Docker dependency, per-directory mount configuration, and it’s all-or-nothing per mount — there is still no “read anywhere, write this one place with approval.”

Where the native model ends

Grade this server on its own terms — it’s a reference implementation, it documents what it does, and what it does is one directory list. The shape of what’s missing:

  • The one boundary is client-writable at runtime. Roots replace the CLI list by design; the fence answers to the side being fenced.
  • The boundary is binary. A directory is fully in — readable, writable, movable, overwritable — or fully out. “Read broadly, write narrowly” is not expressible.
  • The check is in-process, and its two known failures were both full escapes.
  • Nothing is held and nothing is recorded. No approvals, no trail, no way to reconstruct what an agent did to the disk.

The practical posture: pass the narrowest directory list that does the job, pin the package version past July 2025, test what list_allowed_directories actually returns under your client before trusting the config, and prefer the Docker ro mount over trusting the in-process check for anything the agent shouldn’t write. Then put the read/write distinction, the approval on destructive calls, and the record of what actually happened in a layer the agent can’t edit — because on this surface, the native model doesn’t have one.