Filesystem MCP Integration
Interview answer (say this first). A filesystem MCP server exposes file reads and writes as MCP tools or resources. The whole design is about containment: pick one root directory, resolve every requested path to its canonical form, and refuse anything that escapes the root. Defence in depth means path-traversal checks, a symlink policy, separate read and write permissions, size and binary limits, and an audit record for every operation. The MCP SDK adds its own resource-path checks, but you still own the sandbox logic.
Why this exists
Agents are much more useful when they can touch files: read source code, write a report, save a note, inspect logs. But a raw filesystem API is one of the most dangerous things to hand a model, because paths are just strings and the filesystem has only two rules: existence and permission.
Here are the failures, each verified in Python:
Tool: read_file(path: str)
Model calls: read_file("../secret.txt")
os.path.join("/srv/sandbox", "../secret.txt") -> /srv/sandbox/../secret.txt
Resolved path: /srv/secret.txt # outside the sandbox
A second failure is a symlink. The path stays inside the sandbox, but the file it points at does not:
/srv/sandbox/escape -> /etc/passwd
read_file("escape") # passes a naive "starts with /srv/sandbox" check
A third failure is scale. read_file("huge.bin") loads a multi-gigabyte binary into memory, then ships it to the model as context. A fourth is a write tool that overwrites anything the process user can write, including configuration or keys.
Filesystem MCP servers exist to expose a safe subset of the filesystem: one root, named operations, bounded sizes, and clear permissions. The boundary is path resolution and directory containment.
Note:
The one-sentence purpose. A filesystem MCP server is a sandbox: every path is resolved and checked against one root before any read, write, or delete happens.
Start from zero
Before going further, here are the words this topic keeps using.
| Word | Plain meaning |
|---|---|
| Filesystem | The operating system’s file and directory tree. |
| Path | A string naming a file or directory, such as notes/report.md. |
| Absolute path | A path from the root, such as /etc/hosts or C:\Windows. |
| Relative path | A path from the current directory, such as notes/report.md. |
| Root / sandbox root | The one directory the server is allowed to touch. |
. and .. | Current directory and parent directory. .. is the traversal tool. |
| Path traversal | Using .. or absolute paths to escape an intended directory. |
| Canonical path | The real, fully resolved path with ., .., and symlinks applied. |
resolve() | A Python method that returns the canonical path. |
| Symlink | A file that points at another path. Following it can leave the sandbox. |
| Hard link | A second name for the same file data. Harder to detect than a symlink. |
| Containment check | Asking “is this resolved path still inside the root?” |
| Null byte | The \x00 character, which can confuse path parsing. |
| TOCTOU | Time-of-check to time-of-use: the path changes between check and open. |
| Atomic write | Writing to a temporary file, then renaming, so readers never see a half file. |
| MIME type | A label for a file’s content, such as text/plain or image/png. |
| Binary file | A file that is not valid text (contains null bytes or non-UTF-8 data). |
| Size cap | A maximum number of bytes the server will read or return. |
| Allowlist | A fixed set of permitted extensions or paths. |
| Resource | An MCP read-only object addressed by URI, such as file://notes.txt. |
| Audit log | A record of who did what to which path, and when. |
Three distinctions matter:
- Lexical vs canonical.
sandbox/../secretlooks like it starts withsandbox. Only after resolution does the escape become visible. Never check the raw string. - Read vs write vs delete. These need different permissions and different approvals. Reading is often safe to automate; deleting almost never is.
- A path check vs safe opening. A check followed by a normal open has a TOCTOU window. Resolving first and re-validating the opened file descriptor narrows it, as does opening the raw path with
O_NOFOLLOW; combining an already-resolved path withO_NOFOLLOWdoes not, because the resolved path is no longer a symlink.
The core idea
Think of a hotel safe-deposit box. The clerk can fetch and store items in your box, and only your box. The clerk has no authority over the rest of the building, even if asked politely. The box number is checked against your booking every time.
The sandbox root is the box. The containment check is the booking check. It must run on the canonical path, after .. and symlinks are applied:
flowchart TD
U["Requested path<br/>'reports/../../etc/passwd'"] --> AB{"Absolute path?"}
AB -->|"yes"| X["Reject"]
AB -->|"no"| N{"Any null byte?"}
N -->|"yes"| X
N -->|"no"| J["Join to sandbox root"]
J --> R["resolve() to canonical path<br/>follow '.', '..', symlinks"]
R --> C{"is_relative_to(root)?"}
C -->|"no"| X
C -->|"yes"| S{"Type, size, extension OK?"}
S -->|"no"| X
S -->|"yes"| P{"Read or write?"}
P -->|"read"| RD["Return contents, capped + typed"]
P -->|"write"| A{"Approved?"}
A -->|"no"| X
A -->|"yes"| W["Atomic write via temp + replace"]
RD --> L["Audit log"]
W --> L
The same check protects MCP tools and MCP resources. The SDK’s server validates resource paths for traversal and absolute paths by default, but a tool that takes a path argument has no such automatic protection. You must call your own safe-path function in every tool.
| Tool design | Risk | Notes |
|---|---|---|
read_file(path) | Medium | Needs containment, size cap, binary policy. |
write_file(path, text) | High | Needs containment, atomic write, approval, extension allowlist. |
delete_file(path) | Very high | Needs approval, ideally a trash directory instead of real deletion. |
list_dir(path) | Low | Still needs containment; do not leak the parent tree. |
MCP resource file://{path} | Low | Read-only; SDK adds traversal checks. |
The pattern: read tools are common, write tools are rare, delete tools are exceptional.
How it works
- Choose one root at startup. Resolve it once. Do not let configuration point at
/or a home directory by accident. - Accept relative paths from the model. Absolute paths should be rejected outright; they are never inside the sandbox in a portable sense.
- Reject null bytes. A
\x00can terminate a name early in some layers. Reject before joining. - Join the root and the requested path. This is a lexical step only.
- Resolve the result.
Path.resolve()applies.,.., and symlinks, producing the canonical path. - Check containment on the canonical path.
candidate.is_relative_to(root)must be true, or reject. - Decide read or write. Look up the operation in your permission table. Reads may proceed; writes and deletes follow the approval rule.
- Check the file type and size. Refuse directories where a file is expected. Refuse files over the byte cap before reading them.
- Detect binary content. Return text as text; return binary as metadata, a hash, or base64 only if the client genuinely needs it.
- Write atomically. Write to a temporary file in the same directory, flush, then
os.replaceit over the destination. - Handle symlinks deliberately. Resolving follows them, so a symlink pointing outside the root is caught. If you would rather refuse links than resolve them, open the raw path (not its resolved form) with
O_NOFOLLOW, which blocks a symlink at the final component. - Audit every operation. Record the requested path, the canonical path, the operation, the size, the outcome, and the caller.
- Expose read-only data as MCP resources. Resources are a natural fit for files, and the SDK applies its own path-traversal and absolute-path checks to resource templates.
The syntax you will use
Define the root once. Everything is measured against this resolved path.
from pathlib import Path
ROOT = (Path.home() / "agent-workspace").resolve()
The safe join, in one function. This is the core defence.
def safe_path(root: Path, user_path: str) -> Path:
root = root.resolve()
if Path(user_path).is_absolute():
raise ValueError(f"absolute paths are not allowed: {user_path!r}")
candidate = (root / user_path).resolve()
if not candidate.is_relative_to(root):
raise ValueError(f"path escapes root: {user_path!r}")
return candidate
Reject null bytes explicitly. In this Python the resolve call itself raises, but an explicit check is clearer.
if "\x00" in user_path:
raise ValueError("null byte in path")
Allowlist extensions. The model can read any file in the sandbox, but only the types you meant to expose.
TEXT_EXT = {".txt", ".md", ".py", ".json", ".csv", ".log"}
if target.suffix.lower() not in TEXT_EXT:
raise ValueError(f"extension not allowed: {target.suffix}")
Cap the size before reading. Check metadata first, then read.
MAX_BYTES = 1_000_000
if target.stat().st_size > MAX_BYTES:
raise ValueError("file too large")
Detect text versus binary. Null bytes mean binary; a UTF-8 decode failure also means binary.
def is_probably_text(data: bytes) -> bool:
if b"\x00" in data:
return False
try:
data.decode("utf-8")
return True
except UnicodeDecodeError:
return False
Compose the checks into one bounded reader. This is the function the examples below call.
import base64, hashlib
def read_scoped(root: Path, rel: str, max_bytes: int = 1_000_000,
include_bytes: bool = False) -> dict:
target = safe_path(root, rel)
if target.suffix.lower() not in TEXT_EXT:
raise ValueError(f"extension not allowed: {target.suffix}")
size = target.stat().st_size
if size > max_bytes:
raise ValueError(f"file too large: {size} bytes")
data = target.read_bytes()
if is_probably_text(data):
return {"kind": "text", "text": data.decode("utf-8")}
result = { # binary: metadata by default
"kind": "binary",
"size": size,
"sha256": hashlib.sha256(data).hexdigest(),
}
if include_bytes: # bytes only when asked for
result["base64"] = base64.b64encode(data).decode("ascii")
return result
Write atomically. A crash mid-write leaves the temporary file, not a corrupted target.
import tempfile, os
def atomic_write(root: Path, rel: str, text: str) -> None:
target = safe_path(root, rel)
target.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=target.parent, prefix=".tmp-")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, target) # atomic on the same filesystem
except BaseException:
os.unlink(tmp)
raise
Refuse to follow a symlink at the final component. This is the alternative to resolving: pass the raw, un-resolved path, because O_NOFOLLOW cannot help on a path you have already resolved.
fd = os.open(user_path, os.O_RDONLY | os.O_NOFOLLOW)
# raises OSError if `user_path` itself is a symlink
Expose files as MCP resources. The template parameter is filled by the URI.
@mcp.resource("file://{path}")
def resource_file(path: str) -> str:
return safe_path(ROOT, path).read_text(encoding="utf-8")
Rely on the SDK’s resource security, but not for tools. MCPServer defaults to rejecting traversal, absolute paths, and null bytes in resource paths. Tool arguments bypass this entirely.
# default resource security (verified):
# reject_path_traversal=True, reject_absolute_paths=True, reject_null_bytes=True
Examples: simple to real
Example 1 — the naive join leaks. This is the bug the safe function prevents.
import os
os.path.join("/srv/sandbox", "../secret.txt")
# '/srv/sandbox/../secret.txt', which resolves to '/srv/secret.txt'
Verified: the joined path existed and read the secret file. A string prefix check would have passed it.
Example 2 — resolve plus containment blocks it. The same attack now fails.
safe_path(root, "../secret.txt") # ValueError: path escapes root
safe_path(root, "sub/../../secret.txt") # ValueError
safe_path(root, "/tmp/secret.txt") # ValueError: absolute paths are not allowed
Verified output. Note the third case: an absolute path is now rejected explicitly, before any join. Containment alone would have accepted an absolute path that happened to sit inside the root, which is why the check is separate.
Example 3 — a symlink cannot escape. The path looks local; resolution reveals the truth.
os.symlink("/tmp/secret.txt", root / "escape")
safe_path(root, "escape") # ValueError: path escapes root
Verified. Because resolve() follows the link, the containment check sees the real target and refuses.
Example 4 — size, extension, and binary limits. Metadata and content type decide before anything is returned.
read_scoped(root, "a.txt") # {'kind': 'text', 'text': 'hello world'}
read_scoped(root, "weird.log") # {'kind': 'binary', 'size': 11, 'sha256': '...'}
read_scoped(root, "weird.log", include_bytes=True)["base64"] # bytes only on request
read_scoped(root, "image.png") # ValueError: extension not allowed: .png
read_scoped(root, "a.txt", max_bytes=5) # ValueError: file too large: 11 bytes
Verified output. The .log file with null bytes was correctly classified as binary and returned as metadata plus a hash; base64 bytes appeared only with include_bytes=True, and the disallowed .png extension was refused before any read.
Example 5 — atomic write and no-follow. The write is all-or-nothing, and links are refused.
atomic_write(root, "sub/new.txt", "written atomically")
(root / "sub/new.txt").read_text() # 'written atomically'
os.symlink(root / "a.txt", root / "link.txt")
os.open(root / "link.txt", os.O_RDONLY | os.O_NOFOLLOW)
# OSError: Too many levels of symbolic links
Verified output. os.replace onto the destination is atomic within one filesystem.
Example 6 — the MCP resource path is checked by the SDK. Traversal and absolute paths never reach your function.
list_resource_templates() -> ['file://{path}']
read_resource("file://notes.txt") -> 'hello inside'
read_resource("file://../outside-secret.txt")-> MCPError: Unknown resource
read_resource("file:///etc/hosts") -> MCPError: Unknown resource
Verified over the MCP protocol against a real server. The resource template was blocked by the SDK’s built-in path security before it reached the function, while the read_file tool refused the same escape through its own safe-path check.
In production
- One root, resolved at startup. Refuse a root of
/, the home directory, or an environment-variable path that could be empty. - Check the canonical path, never the raw string.
..and symlinks make string prefixes meaningless. - Reject absolute paths from the model. They are either redundant or an escape attempt.
- Pick one TOCTOU strategy and state it. Either resolve first, open the canonical path, and re-validate the opened file descriptor against the root (the second check on the fd is what narrows the window); or skip resolution for the final open and call
os.openon the raw path withO_NOFOLLOWso a symlink at the last component is refused. Do not stack them: opening an already-resolved path withO_NOFOLLOWadds no protection, because that path is not a symlink. - Cap size on metadata, not on read. Check
stat().st_sizefirst so a huge file is never loaded. - Have an explicit binary policy. Most agents only need text. For binaries, return metadata plus a hash, and fetch bytes only on demand.
- Write atomically. Temporary file plus
os.replaceprevents half-written files when a process dies. - Prefer move-to-trash over delete. A
delete_filetool is nearly irreversible. Deleting into a.trashdirectory inside the root gives you a recovery path and an audit trail. - Separate read and write roots or servers. If an agent only needs to read documentation, give it a read-only server with no write tools. Capability removal beats policy.
- Block secret-looking paths. Refuse
.env,.git/config, private keys, and credential files by name even inside the root. - Audit the requested and the canonical path. The pair tells you whether an escape was attempted. Log the outcome, size, and caller.
- Watch directory-size blowups. Listing a huge tree is cheap to request and expensive to return. Cap entries and depth.
Interview questions
1. What is the core security property of a filesystem MCP server?
Answer. Containment: every operation must resolve to a path inside one configured root. You achieve it by rejecting null bytes, joining the root, resolving to the canonical path, and checking containment on the canonical result. If the resolved path is not inside the root, refuse.
Follow-up: “Why canonical and not the raw path?” Because .. and symlinks change the real target after the string is written. Only resolution shows where a path actually points.
Trap. Checking str(path).startswith(root) on the un-resolved string. sandbox/../secret passes that check and escapes.
2. How do symlinks create a path-traversal risk?
Answer. A symlink inside the root can point anywhere. If you follow it, you read or write the target, which may be outside the sandbox. Resolving the path follows the link, so the containment check catches it. If you do not want links followed at all, open the raw path with O_NOFOLLOW.
Follow-up: “What about hard links?” A hard link is a second name for the same data, inside or outside the root. Resolving does not reveal it. If hard links are a concern, compare device and inode against an allowlist, or rely on filesystem permissions.
Trap. Believing the sandbox directory is a real security boundary against a local attacker. It bounds the server’s own logic; OS permissions bound the process.
3. How do you decide read versus write versus delete permissions?
Answer. Reading is usually safe to automate. Writing needs containment, an extension allowlist, atomic replacement, and often approval. Deleting is exceptional: prefer a move-to-trash tool, require approval, and audit the target. Many deployments should ship with no delete tool at all.
Follow-up: “How do you enforce it?” Do not register the tool you do not want. A read-only server whose catalog has no write tool cannot be talked into writing, no matter what the prompt says.
Trap. Implementing one write_file tool and relying on the model to only use it for safe files. The tool either permits or forbids; intent is irrelevant.
4. Why cap file size and handle binary separately?
Answer. A filesystem read returns bytes into memory and then into the model’s context. A huge file wastes memory and tokens and can exceed the context window. Binary files are not useful as raw text. Cap size on metadata before reading, classify content as text or binary, and return binary as metadata or on demand.
Follow-up: “How do you detect binary?” Null bytes mean binary; a UTF-8 decode failure also indicates binary or a different encoding. Treat both as non-text unless you know the format.
Trap. Calling read_text() and catching the exception. That still loads the whole file before failing. Check metadata and content type first.
5. What is TOCTOU, and does it affect a filesystem server?
Answer. Time-of-check to time-of-use means the path changes between your containment check and the actual open. An attacker with local access could swap a checked file for a symlink. Mitigations include opening the raw path with O_NOFOLLOW, re-validating the open file descriptor, and relying on OS permissions, which are the real boundary against a local attacker.
Follow-up: “Is this a remote risk?” Mostly local. A remote model cannot race the server unless it also controls something on the host. Still, resolving and then re-validating the opened file descriptor is the correct habit.
Trap. Treating the sandbox as protection against a malicious local user. It is protection against the server’s own over-broad logic.
6. How should files be exposed as MCP resources versus tools?
Answer. Use resources for read-only data addressed by URI, and tools for operations with arguments and side effects. The SDK’s server applies traversal, absolute-path, and null-byte checks to resource paths automatically, which is a useful extra layer. Tool arguments get no such checks, so every tool must call the safe-path function itself.
Follow-up: “Can a resource have side effects?” It should not. Resources are defined as read-only context. Put writes behind tools where they can be gated and audited.
Trap. Assuming the SDK resource checks cover your tools. They do not; they only cover resource templates.
7. How do you audit filesystem operations?
Answer. Log the caller, the operation, the requested path, the canonical path, the byte count, and the outcome. The requested/canonical pair is the valuable part: a mismatch or a rejection is evidence of an escape attempt. For writes, log the destination and a content hash for later verification.
Follow-up: “Why hash the content?” It lets you prove what was written without storing the content itself, and it supports integrity checks later.
Trap. Logging only the canonical path. You then cannot see that someone tried ../../etc/passwd and was refused.
8. A model needs to write a report. How do you design that tool?
Answer. A single tool such as write_report(name, text) that confines writes to a reports/ subdirectory, allows only .md or .txt, caps size, writes atomically, requires approval, and audits the write. The model supplies content and a simple name, not an arbitrary path.
Follow-up: “Why not let it pass a full path?” Because a full path is exactly the attack surface. Constraining the shape of the input removes the need to sanitise it.
Trap. Accepting a path and “cleaning” it with string replacement. Sanitising strings is fragile; narrowing the interface is robust.
Remember this
- Containment is the whole game. Resolve to canonical form, then check
is_relative_to(root). - Never trust the raw path.
..and symlinks change the target; resolve first. - Read freely, write rarely, delete almost never. Design the catalog to match the risk.
- Cap size on metadata and classify text vs binary. Never load a huge or binary file blindly.
- Write atomically and audit requested plus canonical paths. Both protect the data and the evidence.