DEV Community

Cover image for I Built a File System MCP Server — Here's Why Permissions Matter More Than Features
PADMANABHA DAS
PADMANABHA DAS

Posted on

I Built a File System MCP Server — Here's Why Permissions Matter More Than Features

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"
  }
]
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Three crucial design decisions here:

  1. Most specific path wins. If /projects has read access but /projects/temp has write, operations in temp/ get write. This wasn't obvious initially — I had a bug where the first match won, making granular permissions impossible.
  2. Write implies read. Obviously. But you'd be surprised how many systems make you specify both.
  3. 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
}
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. read-file — Read any file with optional line ranges
  2. modify-file — Surgical edits (insert/replace/delete specific lines)
  3. directory-tree — Visualize project structure
  4. search-file-directory — Find files by name
  5. 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:

  1. Grab the executable from GitHub releases (macOS and Windows available)
  2. Run it once to register with Claude Desktop:

    chmod +x file-system  # macOS only
    ./file-system
    
  3. Open http://localhost:20252 and add some paths

  4. Launch 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)