mcp-filesystem
Provides a hardened filesystem interface that lets a local model read and write files only within user-specified directories, with symlink protection and atomic operations.
README
mcp-filesystem
A hardened filesystem MCP server. Gives a local model read and write access to a set of directories you choose — and nothing else.
Built on the MCP TypeScript SDK v2
against the 2026-07-28 protocol revision, with backward compatibility for
2025-era clients on the same endpoint. Runs over stdio (for LM Studio,
Claude Desktop, and anything else that spawns a local process) or Streamable
HTTP (for a containerised shared endpoint).
Why this one
Most filesystem MCP servers check that a path starts with an allowed prefix and call it a day. That misses three things that matter:
- Symlinks. A link planted inside the sandbox pointing at
/etcdefeats a prefix check entirely. - Writes through symlinked directories.
realpaththrows on paths that don't exist yet, so servers that only resolve existing files will happily createsandbox/linkdir/payload.shoutside the sandbox. - Prefix collisions.
/data-secretsstarts with/data.
This server resolves every path to its physical location before deciding — walking up to the deepest existing ancestor when the target doesn't exist yet — and compares against realpath'd roots with separator-aware matching. The test suite asserts each of those escapes fails.
Tools
| Tool | Purpose |
|---|---|
read_file |
Read a text file, with line numbers, paging (offset/limit) and tail |
read_multiple_files |
Read up to 50 files in one call, sharing a byte budget |
get_file_info |
Size, type, timestamps, permissions, text/binary detection |
list_allowed_directories |
What's reachable, and the active limits |
list_directory |
One level, dirs first, optional sizes and timestamps |
directory_tree |
Indented recursive tree, skipping node_modules/.git/dist/… |
search_files |
Find by glob (**/*.ts) |
grep_files |
Search file contents by regex, with context lines |
write_file |
Atomic whole-file write |
append_file |
Append, with optional newline normalisation |
edit_file |
Exact-string replacement, returns a unified diff, supports dry_run |
create_directory |
mkdir -p |
move_file |
Move/rename, cross-filesystem safe |
copy_file |
Copy file or tree |
delete_file |
Delete, with an explicit recursive gate |
Writes are atomic: content goes to a temp file in the same directory, is
fsync'd, then renamed over the target. A crash or full disk leaves the
original intact rather than truncated.
Quick start
npm install
npm run build
npm test
Then point a client at it:
node dist/index.js --root ./workspace
Or try it interactively without configuring a client:
npx @modelcontextprotocol/inspector node dist/index.js --root ./workspace
LM Studio
LM Studio reads ~/.lmstudio/mcp.json (on Windows,
C:\Users\<you>\.lmstudio\mcp.json). Open it from Program → Install → Edit
mcp.json, add an entry under mcpServers, then reload LM Studio.
Running it natively
The lowest-friction option, and the one to start with.
{
"mcpServers": {
"filesystem": {
"command": "node",
"args": [
"/absolute/path/to/mcp-file-system/dist/index.js",
"--root", "/absolute/path/to/your/project",
"--read-only"
]
}
}
}
Drop --read-only once you trust it. Add more --root flags for more
directories.
These two paths must be absolute. The host spawns the server as a child process with an unpredictable working directory, so a relative path will not resolve. On the command line, where you control the working directory, relative paths like
--root ./workspaceare fine.On Windows either write forward slashes (
C:/Users/you/projects) or double the backslashes, since a single\is an escape character inside a JSON string.
Running it in Docker
Docker gives you a kernel-enforced boundary underneath the server's own checks, which is the real argument for it: even a bug in the sandbox code can't reach anything you didn't mount.
docker build -t mcp-filesystem:latest .
{
"mcpServers": {
"filesystem": {
"command": "docker",
"args": [
"run", "-i", "--rm", "--init",
"--network", "none",
"-v", "/absolute/path/to/your/project:/data:ro",
"mcp-filesystem:latest",
"--stdio", "--read-only"
]
}
}
}
Notes:
-iis required. Without it the container gets no stdin and the JSON-RPC handshake never happens — this is the single most common misconfiguration.--network noneis worth setting: this server has no reason to reach the network, and removing the interface removes a whole class of exfiltration.:roon the mount makes read-only enforcement the kernel's job. To allow writes, drop:roand drop--read-only.- Docker requires the host side of
-vto be an absolute path. - On Docker Desktop for Windows, the drive you are mounting from must be shared under Settings → Resources → File sharing.
- On a Linux host, add
--user "$(id -u):$(id -g)"so written files are owned by you rather than uid 1000.
Mount several directories by repeating -v and passing matching --root
flags:
"-v", "/absolute/path/to/your/code:/data/code:ro",
"-v", "/absolute/path/to/your/notes:/data/notes",
"mcp-filesystem:latest",
"--stdio", "--root", "/data/code", "--root", "/data/notes"
HTTP transport
For a long-lived container that several clients share:
docker compose up -d
curl http://127.0.0.1:3000/health
Point a client at http://127.0.0.1:3000/.
This server has no authentication. Anyone who can reach the port has
whatever filesystem access the server has. docker-compose.yml publishes to
127.0.0.1 only. If you bind it anywhere else, put an authenticating reverse
proxy in front, and expect the startup log to warn you.
When bound to loopback the server validates Host and Origin headers to
block DNS-rebinding — a web page you visit resolving an attacker-controlled
domain to 127.0.0.1 and POSTing to this port.
Configuration
Every flag has an environment-variable equivalent, which is what the container uses. CLI flags win.
| Flag | Env | Default | Meaning |
|---|---|---|---|
--root <dir> |
FS_ALLOWED_ROOTS (comma-separated) |
required | Allowed directory. Repeatable. |
--read-only |
FS_READ_ONLY |
false |
Refuse all mutating tools |
--deny <glob> |
FS_DENY_PATTERNS |
see below | Additional blocked patterns |
--allow-default-denied |
FS_ALLOW_DEFAULT_DENIED |
false |
Drop the built-in deny list |
--follow-symlinks |
FS_FOLLOW_SYMLINKS |
false |
Allow symlinks that stay in the sandbox |
--max-read-bytes <n> |
FS_MAX_READ_BYTES |
10485760 |
Per-file read cap |
--max-write-bytes <n> |
FS_MAX_WRITE_BYTES |
10485760 |
Per-file write cap |
--max-results <n> |
FS_MAX_RESULTS |
1000 |
Cap on list/search/grep results |
--max-depth <n> |
FS_MAX_DEPTH |
20 |
Recursion depth |
--stdio / --http |
FS_TRANSPORT |
stdio |
Transport |
--host / --port |
FS_HTTP_HOST / FS_HTTP_PORT |
127.0.0.1 / 3000 |
HTTP bind |
--audit / --no-audit |
FS_AUDIT |
true |
JSON audit line per call on stderr |
The server refuses to start with no roots configured. A filesystem server with no sandbox is not a safe default, and defaulting to the working directory just makes the mistake quiet.
Default deny list
Blocked unless you pass --allow-default-denied: .env and .env.*, *.pem,
*.key, *.p12, *.pfx, *.keystore, id_rsa/id_dsa/id_ecdsa/
id_ed25519, .ssh/, .aws/, .gnupg/, .kube/config, .npmrc, .netrc,
.pypirc, .docker/config.json, .git/, .svn/, .hg/, shadow.
This exists so that a careless -v $HOME:/data is survivable. It is a safety
net, not a substitute for mounting the right directory.
Security model
What's enforced
- Physical path resolution (
realpath) before every containment decision, including for paths that don't exist yet - Separator-aware root matching (
/datanever matches/data-secrets) - Symlinks rejected by default, in any path position — not just the leaf
- NUL-byte rejection (
safe.txt\0/../../etc/passwdtruncates in the syscall) - Windows: alternate data streams (
file:stream), reserved device names (CON,NUL,COM1…), device-namespace paths (\\?\,\\.\), and case-insensitive containment - Read-only mode gates mutating tools before the handler runs
- Both operands checked on
move/copy— a source-only check is a write primitive for the whole host - Allowed roots cannot themselves be deleted or moved
- Size caps checked via
statbefore allocating - Binary detection, so binaries aren't returned as token-burning garbage
- Regex screening and a wall-clock deadline on
grep_files - Error messages never echo host paths;
SecurityErrorreturns a vague message to the model and logs the real reason to the audit stream, so the sandbox isn't an oracle for mapping your filesystem
What isn't
- TOCTOU. Between resolving a path and opening it, a local attacker who can
write inside your allowed roots could swap a file for a symlink. Closing this
needs
openat2(RESOLVE_BENEATH)on Linux, which Node doesn't expose. The practical mitigation is the container boundary — mount only what you mean to expose. - Authentication. Neither transport authenticates. stdio inherits the trust of whoever spawned the process; HTTP is loopback-only for that reason.
- Resource exhaustion. Caps and deadlines bound most things, but a pathological regex can still burn one 15-second deadline of CPU. The compose file sets memory and CPU limits.
- Prompt injection. If a file inside your sandbox contains instructions and your model follows them, this server will faithfully execute whatever tools the model calls next. Read-only mode is the mitigation that actually works.
Container hardening (in docker-compose.yml): non-root user, read_only
root filesystem, all capabilities dropped, no-new-privileges, tmpfs /tmp,
memory and CPU limits.
Audit log
One JSON object per line on stderr — never stdout, which is the JSON-RPC
channel under stdio. console.log is monkey-patched to redirect to stderr at
startup so a stray debug statement can't corrupt the protocol stream.
{"ts":"2026-08-21T19:12:03.441Z","tool":"read_file","outcome":"ok","durationMs":3,"paths":["src/index.ts"],"bytes":4821}
{"ts":"2026-08-21T19:12:07.882Z","tool":"read_file","outcome":"denied","durationMs":1,"detail":"physical containment failed: /data/../etc/passwd -> /etc/passwd"}
Logged paths are sandbox-relative. The detail field carries the full reason
and is only ever written here, never returned to the model.
docker compose logs -f filesystem | jq 'select(.outcome=="denied")'
Tests
npm run build && npm test
test/sandbox.test.ts is the suite that matters — every case is an attempt to
reach a file outside the root. If one of them starts passing where it should
throw, the server is broken in the only way that's genuinely dangerous.
Symlink tests skip themselves on Windows unless Developer Mode is on, since creating symlinks otherwise needs admin rights.
Project layout
src/
index.ts entrypoint, transport selection, shutdown
config.ts CLI + env parsing, root resolution
security/
sandbox.ts path resolution and containment — the security core
audit.ts structured stderr logging, stdout protection
tools/
context.ts registration wrapper: read-only gate, errors, audit
read.ts read_file, read_multiple_files, get_file_info, ...
write.ts write_file, append_file, edit_file
listing.ts list_directory, directory_tree
manage.ts create_directory, move_file, copy_file, delete_file
search.ts search_files, grep_files
util/
walk.ts sandbox-aware directory traversal with cycle guard
binary.ts binary detection, BOM handling
errors.ts error taxonomy and fs error translation
format.ts output formatting for model consumption
License
MIT
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.