teachyou.ai academy
← All posts
MCP

MCP Server for File Systems: Safe Read/Write Access for Agents

Pramod Dutta · May 26, 2026 · 15 min read

Why file system access is the scariest tool you'll ever give an agent

The moment you let an AI agent read or write files, you've handed it a loaded gun pointed at your project directory. Most people don't think about this until something breaks: an agent asked to "clean up old logs" deletes a config file, or a coding assistant with unrestricted file access writes outside the project root because a path traversal string like ../../etc/passwd slipped through unchecked. File systems are unforgiving. Unlike a database where a bad query might return wrong rows, a bad file write can destroy data permanently, and a bad file read can leak secrets straight into a model's context window.

This is exactly the problem the Model Context Protocol was designed to solve at the tooling layer. An MCP server for file systems is not just "give the LLM open() and write()." It's a deliberate boundary — a small program that sits between the agent and the disk, exposing a curated set of operations (list directory, read file, write file, create directory, move, delete) while enforcing rules the agent cannot override: which directories are visible, which extensions are writable, how big a file can be, and whether an operation needs human approval before it executes.

If you're building agentic systems — coding assistants, document processors, data pipelines that read CSVs and write reports — you will eventually need this. This article walks through what an MCP file system server actually is, how to build one with real code, and the safety patterns that separate a production-grade server from a demo that will eventually delete something important.

What MCP actually adds over "just give the model a shell"

Before MCP, the common pattern was: give the LLM a bash or exec tool and let it run cat, ls, cp, rm directly. This works for demos and fails in production for three reasons.

First, there's no permission model. A shell tool is all-or-nothing — if the agent can run rm -rf, it can run it anywhere the process has permissions, including your home directory if you forgot to sandbox the working directory.

Second, there's no structured feedback. When a shell command fails, the agent gets raw stderr text and has to guess what went wrong. A well-designed MCP tool returns a structured error like {"error": "path_outside_allowed_root", "path": "/etc/passwd"}, which the model can reason about directly.

Third, there's no audit trail by default. MCP servers are regular programs — you control logging, so every read and write can be recorded with the tool name, arguments, and timestamp, which matters when you're debugging why an agent touched a file it shouldn't have.

MCP formalizes the boundary between "language model that reasons" and "capability that acts." The server declares a fixed set of tools with typed schemas (read_file(path: string), write_file(path: string, content: string)), the client (Claude, or any MCP-compatible host) discovers those tools at connection time, and the model can only ever call what's declared — it cannot invent a new capability like delete_all_files unless you wrote that tool yourself. That's the core safety property: the attack surface is exactly the tool list, nothing more.

Anatomy of a minimal file system MCP server

Let's build one. We'll use Python with the official MCP SDK, which handles the JSON-RPC transport so you only write tool logic. The server exposes three tools: list_directory, read_file, and write_file, all scoped to a single allowed root.

import os
from pathlib import Path
from mcp.server.fastmcp import FastMCP

ALLOWED_ROOT = Path("/Users/pramod/projects/sandbox").resolve()

mcp = FastMCP("filesystem-server")

def resolve_safe_path(user_path: str) -> Path:
    """Resolve a path and guarantee it stays inside ALLOWED_ROOT."""
    candidate = (ALLOWED_ROOT / user_path).resolve()
    if not str(candidate).startswith(str(ALLOWED_ROOT)):
        raise ValueError(f"Path escapes sandbox: {user_path}")
    return candidate

@mcp.tool()
def list_directory(path: str = ".") -> list[str]:
    """List files and folders inside the sandboxed root."""
    target = resolve_safe_path(path)
    if not target.is_dir():
        raise ValueError(f"Not a directory: {path}")
    return sorted(os.listdir(target))

@mcp.tool()
def read_file(path: str) -> str:
    """Read a text file's contents. Max 200KB."""
    target = resolve_safe_path(path)
    if not target.is_file():
        raise ValueError(f"Not a file: {path}")
    if target.stat().st_size > 200_000:
        raise ValueError("File too large to read (limit: 200KB)")
    return target.read_text(encoding="utf-8", errors="replace")

@mcp.tool()
def write_file(path: str, content: str) -> str:
    """Write text content to a file inside the sandbox."""
    target = resolve_safe_path(path)
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(content, encoding="utf-8")
    return f"Wrote {len(content)} characters to {path}"

if __name__ == "__main__":
    mcp.run()

Three things matter in this code, and they're the same three things people skip when they rush a prototype:

  • resolve_safe_path is called on every tool, not just the "dangerous" ones. Read paths need the same guard as write paths — a model reading ../../../.ssh/id_rsa is just as bad as writing there.
  • Every check uses .resolve() before comparing paths. Comparing raw strings lets .. and symlinks slip through; resolving to an absolute path first closes that hole.
  • The size limit on read_file isn't arbitrary — it protects your context window. A 50MB log file dumped into a conversation will blow past token limits and cost you real money before the model even gets to respond.

The permission model: read-only, write-scoped, and human-in-the-loop tiers

Not every agent task needs the same level of trust. A documentation-search agent should never write. A refactoring agent needs to write but only inside src/. A "clean up my downloads folder" agent should propose deletions and wait for a human to confirm before anything is removed. Design your server around tiers rather than a single on/off switch.

A practical structure looks like this:

  • Read-only tier: list_directory, read_file, search_files. No write tools registered at all — if the tool isn't exposed, the model literally cannot call it, which is stronger than "the model was told not to."
  • Scoped-write tier: write_file and create_directory, restricted to specific subdirectories (e.g., /output or /drafts) via a second allow-list separate from the read root.
  • Approval-gated tier: destructive operations (delete_file, move_file, overwriting an existing file) return a pending confirmation token instead of executing immediately, and a second call with that token — issued only after a human clicks approve — actually performs the action.

Here's what the approval-gated delete looks like in practice:

import uuid

PENDING_DELETIONS: dict[str, Path] = {}

@mcp.tool()
def request_delete(path: str) -> str:
    """Request permission to delete a file. Returns a confirmation token."""
    target = resolve_safe_path(path)
    if not target.exists():
        raise ValueError(f"File does not exist: {path}")
    token = str(uuid.uuid4())
    PENDING_DELETIONS[token] = target
    return f"Confirmation required. Call confirm_delete(token='{token}') to proceed."

@mcp.tool()
def confirm_delete(token: str) -> str:
    """Execute a previously requested deletion."""
    target = PENDING_DELETIONS.pop(token, None)
    if target is None:
        raise ValueError("Invalid or expired confirmation token")
    target.unlink()
    return f"Deleted {target}"

In a chat client like Claude Desktop or Claude Code, this pattern pairs naturally with the host's own tool-approval UI — the user sees "Claude wants to call confirm_delete" and has to click allow. You get two layers of confirmation: your server's token logic, and the host's permission prompt. Belt and suspenders.

Path traversal, symlinks, and the bugs that actually bite

Most file system security failures in agent tooling come down to a handful of repeat offenders. If you fix these five, you've covered the majority of real incidents.

  1. Path traversal via `..` — covered above, fixed by resolving to an absolute path and checking the prefix.
  2. Symlink escapes — a file inside your sandbox can be a symlink pointing outside it. Path.resolve() follows symlinks by default in Python, which is actually what you want here: it resolves to the *real* target, so your prefix check still catches it. If you switch languages or libraries, verify this behavior explicitly — some path APIs don't resolve symlinks unless you ask.
  3. Case-insensitive filesystem mismatches — on macOS and Windows, /Sandbox and /sandbox can refer to the same directory depending on the filesystem, which can confuse naive string-based allow-lists. Normalize case when the underlying OS is case-insensitive, or better, compare resolved Path objects rather than strings.
  4. Race conditions between check and use (TOCTOU) — you check that a path is safe, then a moment later something (a concurrent process, or the agent itself in a follow-up call) changes what that path points to. For a single-user local server this risk is low, but if your MCP server is shared across sessions or exposed to multiple agents concurrently, open the file handle immediately after validation rather than re-resolving the path in a separate step.
  5. Unbounded recursive operations — a search_files or delete_directory tool that recurses without a depth or count limit can walk into a massive node_modules tree or a mounted network drive and hang the server, or in the delete case, remove far more than intended. Always cap recursion depth and require an explicit recursive: true flag for anything that touches subdirectories, defaulting to false.

A good habit: write a small test suite specifically for the sandbox boundary, independent of your functional tests.

import pytest

def test_blocks_parent_traversal():
    with pytest.raises(ValueError):
        resolve_safe_path("../../etc/passwd")

def test_blocks_absolute_path_outside_root():
    with pytest.raises(ValueError):
        resolve_safe_path("/etc/passwd")

def test_allows_nested_path_inside_root():
    result = resolve_safe_path("subdir/file.txt")
    assert str(result).startswith(str(ALLOWED_ROOT))

def test_blocks_symlink_escape(tmp_path):
    outside = tmp_path / "outside.txt"
    outside.write_text("secret")
    link = ALLOWED_ROOT / "escape_link"
    link.symlink_to(outside)
    with pytest.raises(ValueError):
        resolve_safe_path("escape_link")

Run this suite on every change to the server. Sandbox regressions are the kind of bug that passes code review because the "happy path" still works perfectly.

Handling large files, binary content, and context budget

File system tools have a problem unique to LLM agents: even a "successful" read can be a failure if it blows the context window. A 10,000-line CSV read in full doesn't just cost tokens, it often pushes earlier context out of the window entirely, degrading the agent's memory of what it was doing.

Design your read_file tool to be context-aware, not just disk-aware:

  • Offer pagination or line ranges. read_file(path, start_line=1, end_line=200) lets an agent read a large file incrementally, which also means it can stop early once it finds what it needs.
  • Reject binary files from text-read tools. Detect binary content (null bytes in the first few KB is a decent heuristic) and return a clear error rather than dumping garbled bytes into the conversation.
  • Return metadata before content for large files. A get_file_info(path) tool that returns size, line count, and last-modified date lets the agent decide *how* to read a file before committing tokens to reading all of it.
  • Summarize directory listings for huge folders. If list_directory on node_modules would return 40,000 entries, cap the response and tell the agent how many were truncated, rather than silently returning a partial list that looks complete.
@mcp.tool()
def read_file_range(path: str, start_line: int = 1, end_line: int = 200) -> str:
    """Read a specific line range from a text file."""
    target = resolve_safe_path(path)
    lines = target.read_text(encoding="utf-8", errors="replace").splitlines()
    if start_line < 1 or start_line > len(lines):
        raise ValueError(f"start_line out of range (file has {len(lines)} lines)")
    selected = lines[start_line - 1:end_line]
    return "\n".join(selected)

This one change — line-ranged reads instead of whole-file reads — is often the single biggest improvement you can make to an agent's effectiveness on large codebases or data files, because it lets the model be surgical instead of exhaustive.

Logging, auditability, and rollback

A file system MCP server should behave like a version-control-aware assistant, not a fire-and-forget script. Two practices make the difference between "the agent did something weird and we have no idea what" and "we can see exactly what happened and undo it."

Structured audit logging. Every write, delete, or move should log the tool name, arguments, timestamp, and a diff or before/after snapshot where feasible. This doesn't need to be fancy — a JSON-lines file is enough.

import json
import time

def log_action(tool: str, path: str, detail: str = "") -> None:
    entry = {
        "timestamp": time.time(),
        "tool": tool,
        "path": path,
        "detail": detail,
    }
    with open(ALLOWED_ROOT / ".mcp_audit.jsonl", "a") as f:
        f.write(json.dumps(entry) + "\n")

Call log_action at the top of every mutating tool, before the operation executes, so even a crash mid-write leaves a trace of intent.

Backup before overwrite. For write operations on existing files, copy the original to a .bak or timestamped shadow copy before overwriting. This costs almost nothing in disk space for typical text files and turns "the agent overwrote my file with garbage" from a disaster into a two-second cp .bak file recovery.

@mcp.tool()
def write_file(path: str, content: str) -> str:
    target = resolve_safe_path(path)
    if target.exists():
        backup = target.with_suffix(target.suffix + f".bak.{int(time.time())}")
        backup.write_bytes(target.read_bytes())
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(content, encoding="utf-8")
    log_action("write_file", str(target), f"{len(content)} chars")
    return f"Wrote {len(content)} characters to {path}"

If you're operating inside a git repository, an even better rollback mechanism is simply requiring the sandbox root to be a git repo and relying on git diff / git checkout for recovery — but not every use case has git available, so build the backup logic as a fallback regardless.

It helps to see the full request/response cycle rather than just isolated tool functions. Imagine an agent asked to "update the timeout value in config.json from 30 to 60 seconds." Here is what actually happens across the MCP boundary, step by step, which is worth understanding if you're debugging a server that isn't behaving the way you expect.

First, the host application (Claude Desktop, Claude Code, or a custom agent runtime) connects to your MCP server over stdio or SSE and requests its tool list. Your server responds with the JSON schema for each registered tool — name, description, and parameter types. This is the only information the model ever sees about what it can do; there's no hidden capability it can guess its way into.

Second, the model decides it needs to read the file first, so it calls read_file(path="config.json"). Your server validates the path, checks the size cap, reads the content, and returns it as plain text inside a tool result message. The model now has the actual JSON content in its context.

Third, the model constructs the new file content with the timeout changed, and calls write_file(path="config.json", content=<new JSON>). Your server runs the backup step, writes the audit log entry, performs the write, and returns a confirmation string.

Fourth — and this is the part people skip — the host's own permission layer may intercept step three before it ever reaches your server, showing the user a prompt like "Claude wants to write to config.json" with a diff preview. Your server doesn't control this UI, but it benefits from it: even if your internal validation has a bug, the human approval step is a second line of defense.

@mcp.tool()
def diff_preview(path: str, new_content: str) -> str:
    """Return a unified diff without writing anything, for human review."""
    import difflib
    target = resolve_safe_path(path)
    old_lines = target.read_text().splitlines(keepends=True) if target.exists() else []
    new_lines = new_content.splitlines(keepends=True)
    diff = difflib.unified_diff(old_lines, new_lines, fromfile=path, tofile=f"{path} (proposed)")
    return "".join(diff) or "No changes."

Adding a diff_preview tool alongside write_file gives the agent (and any human watching) a cheap way to see exactly what will change before it commits to the write — and it costs almost nothing to implement since Python's standard library already has a diff algorithm built in.

Multi-agent access, and testing your server like an adversary

If more than one agent or user shares a single MCP file system server — for example, a team's shared "docs assistant" that multiple people query concurrently — a few additional patterns matter.

  • Per-session or per-user root scoping. Instead of one global ALLOWED_ROOT, derive the root from the authenticated session, so User A's agent cannot see or write to User B's files even though both connect to the same server process.
  • Concurrent write locking. Two agents writing to the same file simultaneously can corrupt it. A simple file lock (using fcntl on Unix or a lock-file convention) around write operations prevents interleaved writes.
  • Rate limiting on expensive operations. A search_files tool that does a recursive content grep across a large tree is expensive; cap how often it can be called per session, and cache recent results if a rate limit is hit.
  • Distinct tool sets per role. If your MCP host supports it, register different tool lists for different session types — a "viewer" role gets only read tools, an "editor" role gets scoped writes, an "admin" role gets everything including deletes. This is the same tiering idea from earlier, applied at the connection level rather than per-call.

None of this is exotic engineering — it's the same access-control thinking you'd apply to any multi-tenant API, just applied to a tool surface that happens to be consumed by a language model instead of a REST client.

Once the access model is in place, don't just test the happy path — deliberately try to break your own server the way a misbehaving or confused agent might. A short adversarial checklist before you ship:

  1. Ask the agent (in a test conversation) to "read the file at ../../../../etc/hosts" and confirm it's blocked with a clean error, not a crash.
  2. Ask it to write a file with a path containing null bytes or unusual Unicode, and confirm the server rejects or safely normalizes it.
  3. Ask it to create a symlink inside the sandbox pointing outside, then read through that symlink.
  4. Ask it to write a file larger than your size cap and confirm the cap is enforced before the write starts, not after it's already consumed disk space.
  5. Kill the server process mid-write (literally kill -9 it) and check whether your backup/audit log leaves the file system in a recoverable state.

Wire these into an actual test file rather than doing them manually once — sandboxes regress silently when someone "simplifies" the path-resolution code six months later.

Closing thoughts

A file system is one of the highest-leverage tools you can hand an AI agent, and also one of the least forgiving when the boundaries are wrong. The pattern that works in production isn't "trust the model's judgment" — it's "constrain the tool surface so tightly that the model's judgment barely matters." Scope the root, resolve every path before touching it, tier your permissions from read-only up to approval-gated deletes, cap what a single read or write can consume, and log everything so a bad outcome is a five-minute rollback instead of a lost afternoon.

If you want to go deeper — wiring this server into Claude Desktop or Claude Code, adding OAuth-scoped multi-tenant roots, or combining file tools with a broader agent toolchain — that's exactly what we cover hands-on in Building & Integrating MCP Servers at teachyou.ai, where you'll build one of these servers from scratch and connect it to a real agent workflow.

MCP Server for File Systems: Safe Read/Write Access for Agents · TeachYou Academy