Give a filesystem server your home directory and you've handed a language model your SSH keys, your browser cookies, your tax returns, and that folder of half-finished side projects you'd rather no one read. The model probably won't touch any of it. "Probably" is not a security model.
This is the problem roots exist to solve, and it's a smaller, sharper idea than the name suggests. Let's get it straight.
Roots are the client telling the server where it's allowed to look
A root is a directory boundary, expressed as a file:// URI, that the client offers to the server. It's the answer to the server's implicit question: "what part of this machine am I supposed to operate on?" The client says "these folders, and nothing else," and a well-behaved server confines itself accordingly.
Note the direction, because it's the whole point. Roots flow client → server. The client (inside the host, where the user and their trust decisions live) is the one that knows what the current project is and which directories are fair game. The server doesn't get to decide its own scope; it's told. That keeps the authority where it belongs — with the side closest to the user.
Mechanically it's tiny. The client declares a roots capability at connect time. The server calls roots/list to learn the boundaries. When the user switches projects or opens a new folder, the client sends notifications/roots/list_changed, and the server re-fetches. That's the entire protocol surface: list, and a nudge when the list changes.
Roots are a contract, not a cage
Here's the part people miss and get burned by: roots are advisory. They are information the client provides and the server is expected to respect. The protocol does not reach into the operating system and physically prevent a server from reading outside its roots. A buggy server, or a malicious one, can ignore the boundary entirely.
So roots are a contract between cooperating parties, not a sandbox enforced by the kernel. That's a fine design — protocols coordinate, they don't jail — but it means roots are necessary and not sufficient. If you're connecting an untrusted third-party server, the fact that you handed it narrow roots is cold comfort if it decides not to honor them.
Which is why real file access security is layered:
- Roots tell honest servers where to stay. Set them as narrow as the task allows — the one project directory, not the parent that contains twelve.
- OS-level confinement stops dishonest ones. Run the server as a low-privilege user, in a container, or under a sandbox profile, so "the server tried to read
~/.ssh" fails at the filesystem layer, not just the protocol layer. - The reference filesystem server does enforce its own allowed directories — it's launched with explicit paths and refuses access outside them. That enforcement lives in the server's code, not the protocol, which is exactly why you want to use a well-audited server rather than a random one.
Treat roots as the polite request and OS permissions as the actual lock.
The filesystem server, configured sanely
The official filesystem reference server is the canonical example, and the right way to run it is with the tightest path you can stand:
{
"mcpServers": {
"fs": {
"command": "npx",
"args": [
"-y", "@modelcontextprotocol/server-filesystem",
"/Users/me/projects/current-thing"
]
}
}
}
One directory. Not /Users/me. Not /. The single most effective thing you can do for file-access safety is pass the narrowest path that lets the work happen, and widen it only when something concrete breaks. The default human instinct is to grant broadly so you don't get interrupted later. Resist it — every extra directory is blast radius you've signed up for.
Path traversal: the bug that never dies
Even a server that means well has to defend its own boundary, and the classic way that boundary leaks is path traversal. A tool that takes a filename and joins it to a root will happily resolve ../../../../etc/passwd straight out of the sandbox if it doesn't canonicalize and re-check.
from pathlib import Path
def safe_resolve(root: Path, user_path: str) -> Path:
target = (root / user_path).resolve()
if not target.is_relative_to(root.resolve()):
raise ValueError("path escapes root")
return target
resolve() collapses the .. segments and symlinks; the containment check is what actually enforces the boundary. Any server that exposes file paths to a model needs this or its equivalent, because the model — or something feeding the model — will eventually produce a traversal string, whether by accident or because someone slipped it into a document the model read.
Symlinks deserve a specific mention, because they're the sneaky version of the same problem. A file inside your root can be a symlink that points outside it. A check that only looks at the path string before following links will wave it through; resolve() matters precisely because it follows the link first and then tests containment against the real target. If your server reads files a model selected from a directory listing, assume one of those entries will someday be a symlink to somewhere it shouldn't go — and make sure the containment check runs on the resolved path, not the pretty one.
The mental model to keep
Roots answer "where," and they answer it from the trusted side, dynamically, with a notification when things change. That's genuinely useful — it lets a server follow you from project to project without reconfiguration. But roots describe an intention, and intentions don't stop attackers. The actual security comes from stacking the protocol-level boundary on top of an OS-level one and a server that validates every path it's handed.
Narrow roots, low privileges, canonicalized paths. Get those three right and file access stops being the scariest thing about MCP. Skip any one of them and you've just made a model a very capable, very literal-minded tenant of your hard drive.
Leave a Reply
Your email address will not be published.