Guide · MCP Tool Implementation

MCP server filesystem tools

Filesystem tools are among the most-requested MCP capabilities — they let LLMs read documentation, write code files, search project trees, and manage configuration. They're also among the most dangerous if implemented carelessly. This guide covers how to build read_file, write_file, list_directory, and search_files tools with robust path-traversal prevention, allowed-directory sandboxing, atomic writes, and real-time file-change resources.

TL;DR

Resolve every incoming path with path.resolve() and verify it starts with an allowed-directory prefix before touching the filesystem. Never trust the raw path from an LLM tool argument — models can hallucinate traversal sequences, and a naive implementation will follow ../../etc/passwd exactly as given. Use write-to-temp-then-rename for atomic file writes. Expose frequently-read files as MCP resources for context injection; reserve tools for parameterized reads and all writes.

Path traversal: the core security concern

Path traversal is the primary risk in any MCP filesystem tool. A tool argument like ../../etc/passwd or an absolute path like /etc/shadow would give an LLM read access to arbitrary files on your server. The fix is simple and must be applied on every path argument, every time:

import path from 'path';
import fs from 'fs/promises';

// Define the sandbox — paths outside these roots are always rejected
const ALLOWED_ROOTS = [
  path.resolve(process.env.WORKSPACE_DIR ?? './workspace'),
];

function assertSafePath(rawPath: string): string {
  const resolved = path.resolve(rawPath);
  const isAllowed = ALLOWED_ROOTS.some(
    root => resolved === root || resolved.startsWith(root + path.sep)
  );
  if (!isAllowed) {
    throw new Error(`Path '${rawPath}' is outside the allowed workspace`);
  }
  return resolved;
}

Notice the path.sep suffix check. Without it, a root of /workspace would incorrectly allow /workspace-evil/secret because the string starts with /workspace. The exact-root check handles the case where the workspace directory itself is the argument.

Call assertSafePath at the start of every handler that receives a path argument. Return a clear isError: true response — not a thrown exception that leaks internal path structure to the LLM:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';

const server = new McpServer({ name: 'filesystem-server', version: '1.0.0' });

server.tool(
  'read_file',
  'Read the text content of a file within the workspace',
  {
    path: z.string().describe('File path to read (must be inside workspace)'),
    max_bytes: z.number().int().min(1).max(1_000_000).default(200_000),
  },
  async ({ path: rawPath, max_bytes }) => {
    let safePath: string;
    try {
      safePath = assertSafePath(rawPath);
    } catch (e) {
      return { isError: true, content: [{ type: 'text', text: `Access denied: ${(e as Error).message}` }] };
    }
    try {
      const stat = await fs.stat(safePath);
      if (stat.size > max_bytes) {
        return { isError: true, content: [{ type: 'text', text: `File too large (${stat.size} bytes). Use max_bytes or read in chunks.` }] };
      }
      const content = await fs.readFile(safePath, 'utf8');
      return { content: [{ type: 'text', text: content }] };
    } catch (e) {
      return { isError: true, content: [{ type: 'text', text: `Read failed: ${(e as Error).message}` }] };
    }
  }
);

Atomic file writes with write-then-rename

Non-atomic writes leave truncated or corrupt files if the process crashes mid-write. Use a temp-file-then-rename pattern: write to a temp file in the same directory (same filesystem, avoiding cross-device rename failures), then atomically replace the target with fs.rename. On Linux and macOS, same-filesystem rename is an atomic OS operation — readers see either the old or the new file, never a partial write.

server.tool(
  'write_file',
  'Write content to a file within the workspace (creates directories if needed)',
  {
    path: z.string().describe('File path to write'),
    content: z.string().max(1_000_000).describe('Text content to write'),
    create_dirs: z.boolean().default(true),
  },
  async ({ path: rawPath, content, create_dirs }) => {
    const safePath = (() => { try { return assertSafePath(rawPath); } catch(e) { return null; } })();
    if (!safePath) return { isError: true, content: [{ type: 'text', text: `Access denied: ${rawPath} is outside workspace` }] };

    const dir = path.dirname(safePath);
    const tmpPath = path.join(dir, `.tmp-${process.pid}-${Date.now()}`);

    try {
      if (create_dirs) await fs.mkdir(dir, { recursive: true });
      await fs.writeFile(tmpPath, content, 'utf8');
      await fs.rename(tmpPath, safePath);
      return { content: [{ type: 'text', text: `Wrote ${content.length} bytes to ${path.relative(ALLOWED_ROOTS[0], safePath)}` }] };
    } catch (e) {
      await fs.unlink(tmpPath).catch(() => {});
      return { isError: true, content: [{ type: 'text', text: `Write failed: ${(e as Error).message}` }] };
    }
  }
);

The z.string().max(1_000_000) Zod constraint on content rejects payloads over 1 MB before the handler runs, preventing accidental disk exhaustion from a runaway generation.

Directory listing with depth limits

Recursive directory listings without depth limits produce enormous responses — a full node_modules tree has tens of thousands of entries and would overflow the LLM context. Always add a depth parameter with a hard ceiling:

server.tool(
  'list_directory',
  'List files and subdirectories in a workspace directory',
  {
    path: z.string().default('.').describe('Directory to list (relative to workspace root)'),
    depth: z.number().int().min(1).max(5).default(2),
    include_hidden: z.boolean().default(false),
  },
  async ({ path: rawPath, depth, include_hidden }) => {
    const safePath = assertSafePath(rawPath);

    async function listDir(dir: string, remaining: number): Promise {
      if (remaining === 0) return [];
      const entries = await fs.readdir(dir, { withFileTypes: true });
      const lines: string[] = [];
      for (const ent of entries) {
        if (!include_hidden && ent.name.startsWith('.')) continue;
        const rel = path.relative(safePath, path.join(dir, ent.name));
        lines.push(ent.isDirectory() ? `${rel}/` : rel);
        if (ent.isDirectory() && remaining > 1) {
          lines.push(...await listDir(path.join(dir, ent.name), remaining - 1));
        }
      }
      return lines;
    }

    const entries = await listDir(safePath, depth);
    const text = entries.length ? entries.join('\n') : '(empty directory)';
    return { content: [{ type: 'text', text }] };
  }
);

Return relative paths rather than absolute paths in the output. Absolute paths leak your server's directory structure to the LLM, which may propagate it into generated code or documentation as hard-coded paths that fail on other machines.

File content search

LLMs frequently need to find files by content — "find the function that handles authentication" or "show usages of this deprecated API." A recursive grep-style tool covers this without requiring a full-text search index:

import { glob } from 'glob'; // npm install glob

server.tool(
  'search_files',
  'Search for a text pattern across workspace files',
  {
    pattern: z.string().describe('Search pattern (supports regex)'),
    directory: z.string().default('.').describe('Directory to search within'),
    file_glob: z.string().default('**/*').describe('Glob filter for file names'),
    max_results: z.number().int().min(1).max(100).default(30),
    case_sensitive: z.boolean().default(false),
  },
  async ({ pattern, directory, file_glob, max_results, case_sensitive }) => {
    const safeDir = assertSafePath(directory);
    const regex = new RegExp(pattern, case_sensitive ? '' : 'i');
    const files = await glob(file_glob, {
      cwd: safeDir,
      nodir: true,
      ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
    });

    const results: string[] = [];
    for (const file of files) {
      if (results.length >= max_results) break;
      try {
        const text = await fs.readFile(path.join(safeDir, file), 'utf8');
        for (const [i, line] of text.split('\n').entries()) {
          if (regex.test(line)) {
            results.push(`${file}:${i + 1}: ${line.trim()}`);
            if (results.length >= max_results) break;
          }
        }
      } catch { /* skip binary or unreadable files */ }
    }

    if (results.length === 0) return { content: [{ type: 'text', text: 'No matches found.' }] };
    const footer = results.length >= max_results ? `\n(limited to ${max_results} results)` : '';
    return { content: [{ type: 'text', text: results.join('\n') + footer }] };
  }
);

The ignore list is critical. Without excluding node_modules, a search can take minutes and return thousands of irrelevant matches from dependency source files.

File resources for context injection

For files an LLM should read for context — a README, a schema definition, a configuration snapshot — use MCP resources rather than tools. Resources appear in the client's context panel without consuming a tool call round-trip, and they support real-time push updates via subscriptions:

import { watch } from 'fs';

// Expose project README as a live resource
server.resource(
  'readme',
  'file://workspace/README.md',
  { name: 'Project README', description: 'Main project documentation', mimeType: 'text/markdown' },
  async (uri) => ({
    contents: [{
      uri: uri.href,
      mimeType: 'text/markdown',
      text: await fs.readFile(assertSafePath('./README.md'), 'utf8'),
    }],
  })
);

// Push update notification when the README changes on disk
watch(path.resolve('./README.md'), { persistent: false }, () => {
  server.sendResourceUpdated('file://workspace/README.md');
});

Filesystem tool safety matrix

ToolRiskKey safeguard
read_filePath traversal, large file DoSassertSafePath, max_bytes limit
write_fileOverwrite system files, disk exhaustionassertSafePath, content size limit, atomic rename
append_fileUnbounded log growth, overwriteassertSafePath, check current file size before append
delete_fileIrreversible data lossassertSafePath, require confirm: true argument
list_directoryContext overflow, path leakageassertSafePath, depth limit, return relative paths
search_filesCPU/memory spike on large treesassertSafePath, ignore node_modules, max_results cap
move_filePath traversal on both src and destassertSafePath on source AND destination independently

For destructive operations (delete, overwrite of existing content), add an explicit confirm: z.literal(true) argument. The Zod literal type forces the LLM to pass exactly true — it cannot accidentally pass a string or default the field — making the destructive intent unambiguous in the schema.

Monitoring filesystem MCP servers

Filesystem tools fail in ways that are invisible at the transport level. A full disk causes write_file to fail silently — the server still responds to initialize and tools/list as normal, but every write returns isError: true. A misconfigured WORKSPACE_DIR environment variable after a deployment causes every path to fail the sandbox check. An NFS mount timing out makes read_file hang indefinitely, holding the SSE connection open until the client times out.

These failures affect tool calls but not the transport handshake — a naive uptime check that only pings the HTTP endpoint will report the server as healthy while every file operation fails. AliveMCP probes your HTTP MCP endpoint every 60 seconds using the full protocol handshake (initialize → tools/list → tools/call on a canary tool), catching handler-level failures before users report broken file operations.

Further reading