File System Tools
- Build a file server with read, write, list, and delete tools
- Understand and implement path traversal prevention
- Know why
safe_path()is the most important function in any file server
Why you need safety controls first
Without them, a poorly designed file server could allow Claude — or a prompt injection attack — to read your passwords, SSH keys, or system files. The fix is simple: confine all file operations to a specific allowed directory and verify every path before touching it.
import os
from pathlib import Path
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("File Server")
WORKSPACE = Path.home() / "ai-workspace"
WORKSPACE.mkdir(exist_ok=True)
def safe_path(relative: str) -> Path:
"""
Resolve a path and verify it is inside WORKSPACE.
Raises ValueError if the path tries to escape (e.g. using ../).
"""
resolved = (WORKSPACE / relative).resolve()
if not str(resolved).startswith(str(WORKSPACE.resolve())):
raise ValueError("Access denied: path is outside the workspace")
return resolved
@mcp.tool()
def read_file(path: str) -> str:
"""
Read a file from the workspace. Path is relative to the workspace root.
Example: read_file("notes/todo.txt")
"""
try:
return safe_path(path).read_text(encoding="utf-8")
except ValueError as e:
return f"Error: {e}"
except FileNotFoundError:
return f"Error: File not found — {path}"
@mcp.tool()
def write_file(path: str, content: str) -> str:
"""
Write content to a file, creating the file and any missing directories.
Path is relative to the workspace root.
"""
try:
p = safe_path(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
return f"Written {len(content)} characters to {path}"
except ValueError as e:
return f"Error: {e}"
@mcp.tool()
def list_files(directory: str = "") -> list[str]:
"""
List files in a workspace directory. Leave empty for the workspace root.
Returns relative paths from the workspace root.
"""
try:
target = safe_path(directory) if directory else WORKSPACE
if not target.is_dir():
return [f"Error: {directory} is not a directory"]
return sorted(
str(p.relative_to(WORKSPACE))
for p in target.rglob("*")
if p.is_file()
)
except ValueError as e:
return [f"Error: {e}"]
@mcp.tool()
def delete_file(path: str) -> str:
"""Delete a file from the workspace. Cannot delete directories."""
try:
p = safe_path(path)
if not p.exists():
return f"Error: Not found — {path}"
if p.is_dir():
return "Error: Cannot delete a directory with this tool"
p.unlink()
return f"Deleted: {path}"
except ValueError as e:
return f"Error: {e}"
if __name__ == "__main__":
mcp.run()
Why safe_path works — and why you cannot skip it
User (or prompt injection) sends: path = "../../etc/passwd"
|
v
Without safe_path:
WORKSPACE / "../../etc/passwd"
resolves to: /etc/passwd <- system file! reads passwords
With safe_path:
resolved = /etc/passwd
starts_with(WORKSPACE)? NO
-> raises ValueError("Access denied") OK protected
The .resolve() call expands all ../ segments before we check. That is why it works. Without .resolve(), a string comparison would miss encoded or relative path tricks.
Good practices for file tools
Return error strings, not exceptions. When read_file catches a ValueError, it returns "Error: ..." instead of raising. Claude can read that error and decide what to do next. A raised exception stops the agent cold.
Create missing directories. Use p.parent.mkdir(parents=True, exist_ok=True) before writing. This lets Claude create nested paths like projects/mcp/notes.md without extra tool calls.
Set a file size limit. For read operations, consider adding content[:100_000] to prevent accidentally loading huge files into the context window.
- Always confine file operations to a dedicated
WORKSPACEdirectory safe_path()resolves and checks every path before use — never skip this- Return
"Error: ..."strings from tools instead of raising exceptions - Create missing directories automatically with
p.parent.mkdir(parents=True, exist_ok=True)
- What is a path traversal attack, and give an example of what it looks like?
- Why does
safe_pathuse.resolve()before comparing paths? - Why should tools return error strings rather than raise exceptions?