Skip to main content
Glama
donliggett

mcp-filesystem

README.md
# 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](https://ts.sdk.modelcontextprotocol.io/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 `/etc` defeats a
  prefix check entirely.
- **Writes through symlinked directories.** `realpath` throws on paths that
  don't exist yet, so servers that only resolve *existing* files will happily
  create `sandbox/linkdir/payload.sh` outside the sandbox.
- **Prefix collisions.** `/data-secrets` starts 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

```bash
npm install
npm run build
npm test
```

Then point a client at it:

```bash
node dist/index.js --root ./workspace
```

Or try it interactively without configuring a client:

```bash
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.

```json
{
  "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 ./workspace` are 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.

```bash
docker build -t mcp-filesystem:latest .
```

```json
{
  "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:

- `-i` is required. Without it the container gets no stdin and the JSON-RPC
  handshake never happens — this is the single most common misconfiguration.
- `--network none` is worth setting: this server has no reason to reach the
  network, and removing the interface removes a whole class of exfiltration.
- `:ro` on the mount makes read-only enforcement the kernel's job. To allow
  writes, drop `:ro` **and** drop `--read-only`.
- Docker requires the host side of `-v` to 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:

```bash
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 (`/data` never matches `/data-secrets`)
- Symlinks rejected by default, in any path position — not just the leaf
- NUL-byte rejection (`safe.txt\0/../../etc/passwd` truncates 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 `stat` before 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; `SecurityError` returns 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.

```json
{"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.

```bash
docker compose logs -f filesystem | jq 'select(.outcome=="denied")'
```

---

## Tests

```bash
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