How to Implement Path Validation in the MCP Filesystem Server

The MCP Filesystem server implements path validation through a whitelist-based architecture using validatePath() in src/filesystem/lib.ts, which normalizes paths, checks against allowed directories, and resolves symlinks before any file system access occurs.

The modelcontextprotocol/servers repository provides a secure filesystem server that prevents directory traversal and symlink attacks through rigorous path validation. Implementing path validation in the MCP Filesystem server requires understanding three core modules that work together to create a fail-closed security model.

Core Architecture of Path Validation

The validation system operates through a defense-in-depth strategy spread across three source files: src/filesystem/path-validation.ts for core logic, src/filesystem/path-utils.ts for normalization, and src/filesystem/lib.ts for the public API.

The Whitelist System (setAllowedDirectories)

The server maintains a global whitelist of permitted directories that is set once at startup and enforced on every request.

let allowedDirectories: string[] = [];

export function setAllowedDirectories(dirs: string[]): void {
  allowedDirectories = [...dirs];
}

export function getAllowedDirectories(): string[] {
  return [...allowedDirectories];
}

In src/filesystem/__main__.ts, you initialize the server by calling setAllowedDirectories() with your allowed roots. This creates a sandbox that the server cannot escape, even if symbolic links or relative path tricks are attempted.

Path Normalization (path-utils.ts)

Before any validation occurs, paths must be converted to a canonical form. The normalizePath() function in src/filesystem/path-utils.ts handles OS-specific edge cases:

  • Strips surrounding quotes and whitespace
  • Preserves WSL paths (/mnt/…) and normal Unix paths
  • Converts Unix-style Windows drives (/c/) only on Windows
  • Collapses duplicate separators and resolves ./.. components
  • Forces backslashes and capitalizes drive letters on Windows

This normalization ensures that mixed separators or relative components cannot bypass the whitelist through string manipulation.

The Validation Engine (path-validation.ts)

The core security logic lives in isPathWithinAllowedDirectories() inside src/filesystem/path-validation.ts:

export function isPathWithinAllowedDirectories(
  absolutePath: string,
  allowedDirectories: string[]
): boolean {
  // Type & null-byte safety checks
  // Normalize the candidate path with path.resolve
  // Verify the result is absolute
  // For each whitelist entry:
  //   - Normalize the directory
  //   - Ensure it is absolute
  //   - Return true if candidate equals directory,
  //     or starts with "<directory><sep>" (handling root and Windows-drive-root cases)
}

This function rejects null bytes to stop injection attacks, throws on relative input after normalization, and handles Windows special cases including root paths (C:\) and UNC paths.

The validatePath() Implementation

The high-level API in src/filesystem/lib.ts orchestrates the complete validation workflow through validatePath():

export async function validatePath(requestedPath: string): Promise<string> {
  const expanded = expandHome(requestedPath);               // "~" → $HOME
  const absolute = path.isAbsolute(expanded)
    ? path.resolve(expanded)
    : resolveRelativePathAgainstAllowedDirectories(expanded); // uses whitelist

  const normalizedRequested = normalizePath(absolute);

  // 1️⃣ Whitelist check (fast)
  if (!isPathWithinAllowedDirectories(normalizedRequested, allowedDirectories))
    throw new Error(`Access denied …`);

  // 2️⃣ Symlink resolution
  try {
    const realPath = await fs.realpath(absolute);
    const normalizedReal = normalizePath(realPath);
    if (!isPathWithinAllowedDirectories(normalizedReal, allowedDirectories))
      throw new Error(`Access denied – symlink target outside allowed directories`);
    return realPath;
  } catch (e) {
    // 3️⃣ ENOENT handling (new file)
    if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
      const parentDir = path.dirname(absolute);
      const realParent = await fs.realpath(parentDir);
      const normalizedParent = normalizePath(realParent);
      if (!isPathWithinAllowedDirectories(normalizedParent, allowedDirectories))
        throw new Error(`Access denied – parent dir outside allowed directories`);
      return absolute; // safe to create new file here
    }
    throw e;
  }
}

Security Guarantees

Each validation step protects against specific attack vectors:

  • Whitelist test: Blocks direct traversal (../etc/passwd) or absolute paths pointing outside allowed roots
  • fs.realpath resolution: Prevents symlink attacks where a malicious link inside an allowed directory points to sensitive system files
  • Parent-directory check on ENOENT: Stops creation of new files in unauthorized locations (e.g., ../outside/file.txt)
  • resolveRelativePathAgainstAllowedDirectories: Guarantees relative requests anchor to a whitelist entry before any I/O occurs

Practical Implementation Example

Configure the whitelist at startup, then validate every user-supplied path before operations:

import { setAllowedDirectories, validatePath } from './lib.js';
import * as fs from 'fs/promises';

// 1️⃣ Initialize allowed directories (call once at startup)
setAllowedDirectories([
  '/srv/mcp/data',                // Linux/Unix
  'C:\\MCP\\Data'                // Windows
]);

// 2️⃣ Validate before any file operation
async function readUserFile(userPath: string) {
  try {
    const safePath = await validatePath(userPath);   // throws if unsafe
    const content = await fs.readFile(safePath, 'utf-8');
    return content;
  } catch (err) {
    // Convert to HTTP 403 or similar
    console.error(err);
    throw new Error('Forbidden: requested path is not allowed');
  }
}

// Example calls
await readUserFile('project/readme.md');          // relative → resolved against whitelist
await readUserFile('/srv/mcp/data/notes.txt');   // absolute, allowed
await readUserFile('../../etc/passwd');           // ❌ throws – outside allowed dirs

Key implementation details:

  • Call setAllowedDirectories() once during server initialization
  • validatePath() returns the real, absolute path after symlink resolution
  • All I/O occurs after validation, ensuring fail-closed behavior
  • Relative paths are automatically resolved against the whitelist, not the process working directory

Summary

  • Use setAllowedDirectories() at startup to define the sandbox roots in src/filesystem/lib.ts
  • Call await validatePath() from src/filesystem/lib.ts before every filesystem operation to enforce the whitelist
  • The system validates path membership before resolving symlinks, then re-validates the symlink target
  • For new file creation, the parent directory is validated against the whitelist when the target does not exist
  • All paths are normalized using OS-aware logic in src/filesystem/path-utils.ts to prevent bypasses through separator tricks

Frequently Asked Questions

The server uses fs.realpath() in validatePath() to resolve symbolic links to their actual targets, then normalizes and validates that resolved path against the whitelist. If a symlink inside an allowed directory points outside the whitelist (e.g., to /etc/passwd), the second validation check throws an access denied error before any file content is read.

Can users access files using relative paths like ../?

No. Relative paths are resolved using resolveRelativePathAgainstAllowedDirectories() in src/filesystem/lib.ts, which anchors them to an allowed directory before validation. Even if a user requests ../../etc/passwd, the system attempts to resolve it within the whitelist confines. The subsequent isPathWithinAllowedDirectories() check in src/filesystem/path-validation.ts detects that the resolved absolute path falls outside the allowed roots and throws an error.

What happens when validating a path for a new file that doesn't exist yet?

When fs.realpath() throws an ENOENT error (file not found), the server enters a special handling branch in validatePath(). It extracts the parent directory using path.dirname(), resolves and validates that parent against the whitelist, and only permits the operation if the parent lies within allowed directories. This prevents attackers from creating files in unauthorized locations while allowing legitimate new file creation inside the sandbox.

How are Windows and Unix path formats handled differently?

The normalizePath() function in src/filesystem/path-utils.ts detects the operating system and applies platform-specific rules. On Windows, it forces backslash separators, capitalizes drive letters (ensuring c:\ and C:\ match), and handles UNC paths. On Unix systems, it preserves WSL mount points and uses forward slashes. This ensures path validation works correctly across platforms without false negatives due to case sensitivity or separator mismatches.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →