A few months ago, I got excited about MCP (Model Context Protocol). The idea of giving Claude direct access to my file system seemed powerful — no more copy-pasting code snippets, no more describing folder structures. Just "Hey Claude, read my project and help me refactor it."
So I did what any developer does. I searched for existing implementations.
What I found scared me.
Most file system MCP tutorials hand Claude unrestricted access to your entire machine. Some have a hardcoded ALLOWED_DIRECTORIES array that you set once and forget. Others just... don't bother with permissions at all.
That's when it hit me: I was about to give an AI full read/write access to my computer. An AI that might misunderstand "clean up my Downloads" as "delete everything that looks messy".
The Trust Problem Nobody Talks About
Here's the thing about AI assistants — they're incredibly capable, but they make mistakes. Claude might reorganize your entire project to follow "best practices" when you're intentionally breaking them. It might overwrite a file while trying to help. It might decide that empty folders are useless and delete them (spoiler: sometimes they're not).
When I started building my own File System MCP server, I kept asking myself: would I actually use this on my real projects?
The honest answer was no. Not without proper guardrails.
Three Permission States: (Not Two, Why Binary Not Enough?)
Most permission systems think in binary: allowed or denied. But real-world file access isn't that simple.
Consider this scenario: I'm debugging a Next.js project. I want Claude to:
- Read my source code to understand the bug
- NOT write to src/ because I'll review changes first
- Full access to my temp/ folder for testing fixes
This led me to three permission states:
[
{
"path": "/Users/padmanabhadas/next-js-projects/signalist/src",
"operation": "read"
},
{
"path": "/Users/padmanabhadas/next-js-projects/signalist/temp",
"operation": "write"
}
]
Notice how I can mix permissions within the same project? That granularity is crucial.
The Heart of the System: Path Resolution
Every file operation goes through one function: resolvePath(). This is where the magic (and paranoia) lives.
const resolvePath = async (relativePath: string, operation: 'read' | 'write'): Promise<string> => {
const resolved = path.resolve(relativePath);
const ALLOWED_ROOTS = await getAllowedRoots();
// Find ALL matching roots, not just the first
const matchingRoots = ALLOWED_ROOTS.filter((root: any) => {
const resolvedBasePath = path.resolve(root.path);
return resolved.startsWith(resolvedBasePath);
});
if (matchingRoots.length === 0) {
throw new Error(`Access denied: ${relativePath} is outside allowed directories`);
}
// Most specific path wins - this took me weeks to debug
const mostSpecificRoot = matchingRoots.sort((a: any, b: any) => b.path.length - a.path.length)[0];
// Write implies read
if (mostSpecificRoot.operation !== operation && mostSpecificRoot.operation !== 'write') {
throw new Error(`Insufficient permissions for ${operation} operation`);
}
return resolved;
}
Three crucial design decisions here:
-
Most specific path wins. If
/projectshas read access but/projects/temphas write, operations in temp/ get write. This wasn't obvious initially — I had a bug where the first match won, making granular permissions impossible. - Write implies read. Obviously. But you'd be surprised how many systems make you specify both.
-
Absolute paths only. No
../tricks. Everything gets resolved immediately.
Every Tool Is Paranoid (And That's Good)
Here's how tools actually use the permission system:
// read-file tool
export const getFileLines = async (filePath: string, startLine?: number, endLine?: number) => {
// Permission check happens FIRST
const fullPath = await resolvePath(filePath, 'read');
// Only then we proceed
const fileContent = await fs.readFile(fullPath, 'utf8');
const lines = fileContent.split('\n');
// ... rest of logic
}
// modify-file tool
export const modifyFile = async (filePath: string, operation: string, lineNumber: number, content?: string) => {
// Different permission requirement
const fullPath = await resolvePath(filePath, 'write');
const fileContent = await fs.readFile(fullPath, 'utf8');
// ... modification logic
}
I was tempted to add permission checks inside each tool, but centralizing everything in resolvePath() means I literally can't forget to check. One function, one responsibility, zero exceptions.
Shell Commands: Where Things Get Scary
Reading files? Safe. Writing files? Manageable. Shell commands? That's where my paranoia really kicked in.
When someone runs git status, that's clearly a read operation. But git push? That modifies a remote repository. What about ls > output.txt? That's reading a directory but writing a file.
const getCommandPermission = (command: string): 'read' | 'write' | 'none' => {
const cmd = command.trim().toLowerCase();
// No filesystem access needed
const noAccessPatterns = [
/^cd\s+/,
/^pwd$/,
/^echo\s+[^>]/,
/^date$/,
/^whoami$/,
];
// Read-only commands
const readOnlyPatterns = [
/^git\s+(status|log|branch|diff|show)/,
/^(ls|ll|la)\s*/,
/^cat\s+[^>]/,
/^grep\s+.*[^>]*$/,
/^find\s+.*(?<!-delete)[^>]*$/,
];
// Write operations
const writePatterns = [
/^git\s+(add|commit|push|pull|clone)/,
/^(mkdir|rmdir|rm|cp|mv|touch)\s+/,
/.*>\s*[^&]/, // Output redirection
/^npm\s+(install|run|build)/,
];
for (const pattern of noAccessPatterns) {
if (pattern.test(cmd)) return 'none';
}
for (const pattern of readOnlyPatterns) {
if (pattern.test(cmd)) return 'read';
}
for (const pattern of writePatterns) {
if (pattern.test(cmd)) return 'write';
}
// Default to write for safety
return 'write';
}
Is this perfect? No. Someone could probably craft a command that slips through. But the default-to-write approach means unknown commands require write permission. Fail secure, not fail open.
The actual execution function ties it all together:
const runShellCommand = async (command: string, cwd: string) => {
const permission = getCommandPermission(command);
if (permission !== 'none') {
// Command needs filesystem access — validate the working directory
// against our permission system before running anything
const validatedPath = await resolvePath(cwd, permission);
const { stdout, stderr } = await execAsync(command, { cwd: validatedPath });
return { stdout, stderr };
} else {
// Safe commands like 'pwd' or 'whoami' that don't touch files
// Skip the permission check since there's nothing to protect
const { stdout, stderr } = await execAsync(command, { cwd });
return { stdout, stderr };
}
}
The key insight: I’m not just checking if a command is "allowed" — I’m checking if the working directory has the right permissions for what that command wants to do. Running ls in a read-only directory? Fine. Running rm -rf * there? Blocked.
The Web UI: Because 2 AM Debugging Needs to Be Easy
Initially, I used a JSON config file you'd edit manually. That lasted exactly one late-night debugging session before I built a web UI.
app.get('/manage-permissions', (req, res) => {
// Serve a simple HTML page
// No React, no build process, just vanilla JS
res.sendFile(path.join(__dirname, 'permission-manager.html'));
});
app.post('/api/permissions', async (req, res) => {
// Update the config file
const newPermissions = req.body;
await fs.writeFile(configPath, JSON.stringify(newPermissions, null, 2));
res.json({ success: true });
});
Visit http://localhost:20252, see your permissions, add new ones, remove old ones. No terminal required. Yes, you have to type paths manually (browser security prevents file pickers), but it beats editing JSON.
Default-Deny: The Only Sane Default
When the server starts for the first time:
export const getAllowedRoots = async () => {
const filePath = path.join(getClaudeConfigDir(), 'file_system_config.json');
try {
const content = await fs.readFile(filePath, 'utf8');
return JSON.parse(content);
} catch (error: any) {
if (error.code === 'ENOENT') {
// No config? Start with ZERO permissions
const defaultRoots: object[] = [];
await fs.writeFile(filePath, JSON.stringify(defaultRoots, null, 2));
return defaultRoots;
}
throw error;
}
}
Empty array. No access. Nothing. You have to explicitly grant permissions before Claude can do anything.
This was deliberate. Most security problems come from starting open and asking users to lock down. I went the opposite direction — start locked, make users open up consciously.
Real-World Usage (With Actual Sessions)
Here's what actually happens when I use this:
Me: "Show me the structure of my signalist project"
Claude: (calls list-allowed-directories first, then directory-tree)
❌ Access denied: /Users/padmanabhadas/next-js-projects/signalist is outside allowed directories
Configure permissions at: http://localhost:20252
Right. I forgot to add permissions. Quick visit to localhost:20252, add the path with read access.
Me: "Try again"
Claude:
signalist/
├── src/
│ ├── app/
│ ├── components/
│ └── utils/
├── public/
├── package.json
└── README.md
Files: 47, Directories: 12
Me: "Update the README to include the logo from public folder"
Claude:
❌ Access denied: Insufficient permissions for write operation
Configure permissions at: http://localhost:20252
This is the system working as intended. I gave read-only access, so writes fail. If I trust Claude enough, I'll upgrade to write. If not, I'll ask for the changes and apply them myself.
The 20 Tools (But Only 5 Matter)
I built 20 tools total, but honestly, 80% of my usage is just these five:
-
read-file— Read any file with optional line ranges -
modify-file— Surgical edits (insert/replace/delete specific lines) -
directory-tree— Visualize project structure -
search-file-directory— Find files by name -
run-shell-command— When you really need it
The Office document tools (Excel, PowerPoint, Word) seemed cool when I built them. I've used them maybe twice. The core file operations are what matter.
Things That Went Wrong (And What I Learned)
The Path Precedence Bug: For three weeks, I couldn't figure out why granular permissions didn't work. Turns out I was taking the first matching path, not the most specific. One sort() call fixed it.
The Symlink Escape: A friend tried to break out using symlinks. Worked perfectly until I added fs.realpath() checks everywhere. Now symlinks are resolved before permission checking.
The "Clean Downloads" Incident: I asked Claude to "organize my Downloads folder" without being specific. It created 47 subfolders based on file extensions. Technically correct, absolutely not what I wanted. Now I'm very specific with instructions.
Shell Command Parsing: My regex-based command parser thought echo "rm -rf /" was trying to delete everything. It's not. Command parsing is hard. I still don't trust it 100%.
If I Started Over
Time-based permissions: "Give write access for 30 minutes" would be useful for quick tasks.
Operation logs: Not just permission checks, but what Claude actually did. An audit trail.
Workspace templates: Preconfigured permission sets for common scenarios (read-only code review, full access to test directory, etc.)
But honestly? The current system works. It's been three months, and I haven't lost any data. That's a win.
Getting Started
If you want to try this yourself:
- Grab the executable from GitHub releases (macOS and Windows available)
-
Run it once to register with Claude Desktop:
chmod +x file-system # macOS only ./file-system Open
http://localhost:20252and add some pathsLaunch Claude Desktop and try it: "use file system tools to show my allowed directories"
The server stays running as long as Claude Desktop is open. You don't need to start it manually each time.
The Bottom Line
Most MCP servers are built by developers, for developers, with developer assumptions about security ("I know what I'm doing"). But when you're giving an AI file system access, you need to design for the worst case — a misunderstood command at 3 AM when you're half asleep.
The permission system isn't sexy. It doesn't demo well. But it's the difference between a toy you play with and a tool you actually trust with your real work.
If you're building anything that touches the file system — MCP server or not — start with permissions. Make them granular. Make them explicit. Make them annoying if you have to. Your future self will thank you when Claude doesn't accidentally reorganize your entire home directory.
Want to try it yourself? The code's on GitHub (FS-MCP). Just remember – when you're giving an AI file system access, paranoia isn't a bug, it's a feature.
Executables:
Questions? Find me on GitHub or LinkedIn. I'm always curious how others approach the permission problem.
Top comments (0)