Build a File System MCP Server

Create an MCP server that provides file read/write capabilities as tools and exposes directory listings as resources. Learn sandboxing, path validation, and secure file access patterns.


title: "Build a File System MCP Server" description: "Create an MCP server that provides file read/write capabilities as tools and exposes directory listings as resources. Learn sandboxing, path validation, and secure file access patterns." order: 4 keywords:

  • mcp file system server
  • file access mcp tools
  • mcp resources tutorial
  • file read write mcp
  • secure file server mcp date: "2026-04-01" level: "intermediate" duration: "25 min"

Quick Summary

Build an MCP server that lets AI assistants read, write, and browse files within a sandboxed directory. You will create tools for file operations and resources for directory listings, with strict path validation to prevent directory traversal attacks.

What You Will Build

A file system MCP server with:

  • read_file tool -- Read file contents with encoding support
  • write_file tool -- Write or update files safely
  • list_directory tool -- Browse directory contents
  • file:// resources -- Expose directory trees as MCP resources
MCP Resources

Resources are read-only data that an MCP server exposes for AI assistants to access. Unlike tools (which take inputs and perform actions), resources are identified by URIs and provide contextual information like file contents, database schemas, or configuration data.

Project Setup

1

Create the project

npx mcp-framework create file-system-server
cd file-system-server
2

Create a sandbox directory for testing

mkdir -p sandbox/docs sandbox/src
echo "# Project README" > sandbox/README.md
echo "console.log('hello');" > sandbox/src/index.js
echo "User guide content here." > sandbox/docs/guide.txt

Security: Path Validation

Before building any file tools, you need a robust path validator. Create src/utils/pathValidator.ts:

import path from "path";
import fs from "fs";

export class PathValidator {
  private rootDir: string;

  constructor(rootDir: string) {
    this.rootDir = path.resolve(rootDir);
    if (!fs.existsSync(this.rootDir)) {
      throw new Error(`Root directory does not exist: ${this.rootDir}`);
    }
  }

  validate(filePath: string): string {
    const resolved = path.resolve(this.rootDir, filePath);

    // Prevent directory traversal
    if (!resolved.startsWith(this.rootDir)) {
      throw new Error(
        `Access denied: path "${filePath}" escapes the sandbox directory`
      );
    }

    return resolved;
  }

  validateExists(filePath: string): string {
    const resolved = this.validate(filePath);
    if (!fs.existsSync(resolved)) {
      throw new Error(`File not found: ${filePath}`);
    }
    return resolved;
  }

  get root(): string {
    return this.rootDir;
  }
}
Never Skip Path Validation

Directory traversal attacks (e.g., ../../etc/passwd) are the number one risk with file system MCP servers. Always resolve paths and verify they remain within your sandbox directory. An AI model could be tricked via prompt injection into requesting files outside the intended scope.

Building the Tools

ReadFileTool

Create src/tools/ReadFileTool.ts:

import { MCPTool } from "mcp-framework";
import { z } from "zod";
import fs from "fs/promises";
import { PathValidator } from "../utils/pathValidator.js";

const validator = new PathValidator(process.env.SANDBOX_DIR || "./sandbox");

class ReadFileTool extends MCPTool<typeof inputSchema> {
  name = "read_file";
  description = "Read the contents of a file within the sandbox directory";

  schema = {
    path: {
      type: z.string().min(1),
      description: "Relative path to the file within the sandbox",
    },
    encoding: {
      type: z.enum(["utf-8", "base64"]).optional(),
      description: "File encoding (default: utf-8)",
    },
  };

  async execute(input: z.infer<typeof inputSchema>): Promise<string> {
    try {
      const resolvedPath = validator.validateExists(input.path);
      const encoding = input.encoding || "utf-8";
      const content = await fs.readFile(resolvedPath, encoding);

      const stats = await fs.stat(resolvedPath);

      return JSON.stringify({
        path: input.path,
        size: stats.size,
        modified: stats.mtime.toISOString(),
        content: content,
      }, null, 2);
    } catch (error) {
      const message = error instanceof Error ? error.message : "Unknown error";
      return JSON.stringify({ error: message });
    }
  }
}

const inputSchema = z.object({
  path: z.string().min(1),
  encoding: z.enum(["utf-8", "base64"]).optional(),
});

export default ReadFileTool;

WriteFileTool

Create src/tools/WriteFileTool.ts:

import { MCPTool } from "mcp-framework";
import { z } from "zod";
import fs from "fs/promises";
import path from "path";
import { PathValidator } from "../utils/pathValidator.js";

const validator = new PathValidator(process.env.SANDBOX_DIR || "./sandbox");

class WriteFileTool extends MCPTool<typeof inputSchema> {
  name = "write_file";
  description = "Write content to a file within the sandbox directory. Creates parent directories if needed.";

  schema = {
    path: {
      type: z.string().min(1),
      description: "Relative path for the file within the sandbox",
    },
    content: {
      type: z.string(),
      description: "The content to write to the file",
    },
    append: {
      type: z.boolean().optional(),
      description: "If true, append to existing file instead of overwriting",
    },
  };

  async execute(input: z.infer<typeof inputSchema>): Promise<string> {
    try {
      const resolvedPath = validator.validate(input.path);

      // Ensure parent directory exists
      await fs.mkdir(path.dirname(resolvedPath), { recursive: true });

      if (input.append) {
        await fs.appendFile(resolvedPath, input.content, "utf-8");
      } else {
        await fs.writeFile(resolvedPath, input.content, "utf-8");
      }

      const stats = await fs.stat(resolvedPath);

      return JSON.stringify({
        success: true,
        path: input.path,
        size: stats.size,
        action: input.append ? "appended" : "written",
      }, null, 2);
    } catch (error) {
      const message = error instanceof Error ? error.message : "Unknown error";
      return JSON.stringify({ error: message });
    }
  }
}

const inputSchema = z.object({
  path: z.string().min(1),
  content: z.string(),
  append: z.boolean().optional(),
});

export default WriteFileTool;

ListDirectoryTool

Create src/tools/ListDirectoryTool.ts:

import { MCPTool } from "mcp-framework";
import { z } from "zod";
import fs from "fs/promises";
import path from "path";
import { PathValidator } from "../utils/pathValidator.js";

const validator = new PathValidator(process.env.SANDBOX_DIR || "./sandbox");

class ListDirectoryTool extends MCPTool<typeof inputSchema> {
  name = "list_directory";
  description = "List files and subdirectories in a directory within the sandbox";

  schema = {
    path: {
      type: z.string().optional(),
      description: "Relative directory path (default: root of sandbox)",
    },
    recursive: {
      type: z.boolean().optional(),
      description: "If true, list files recursively",
    },
  };

  async execute(input: z.infer<typeof inputSchema>): Promise<string> {
    try {
      const dirPath = validator.validateExists(input.path || ".");
      const entries = await fs.readdir(dirPath, { withFileTypes: true });

      const items = await Promise.all(
        entries.map(async (entry) => {
          const fullPath = path.join(dirPath, entry.name);
          const stats = await fs.stat(fullPath);
          return {
            name: entry.name,
            type: entry.isDirectory() ? "directory" : "file",
            size: entry.isFile() ? stats.size : undefined,
            modified: stats.mtime.toISOString(),
          };
        })
      );

      return JSON.stringify({
        path: input.path || ".",
        entries: items,
      }, null, 2);
    } catch (error) {
      const message = error instanceof Error ? error.message : "Unknown error";
      return JSON.stringify({ error: message });
    }
  }
}

const inputSchema = z.object({
  path: z.string().optional(),
  recursive: z.boolean().optional(),
});

export default ListDirectoryTool;
Sandbox Your File Access

Always constrain file system access to a specific directory. Use environment variables (SANDBOX_DIR) to make the root configurable, and validate every path before any file operation.

Official SDK Version

Here is how you would register the read_file tool using the official SDK:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fs from "fs/promises";

const server = new McpServer({ name: "file-system-server", version: "1.0.0" });

server.tool(
  "read_file",
  "Read file contents within the sandbox",
  { path: z.string().min(1), encoding: z.enum(["utf-8", "base64"]).optional() },
  async ({ path: filePath, encoding }) => {
    // ... path validation and file reading logic ...
    const content = await fs.readFile(resolvedPath, encoding || "utf-8");
    return {
      content: [{ type: "text" as const, text: JSON.stringify({ path: filePath, content }) }],
    };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);

Testing

npm run build
SANDBOX_DIR=./sandbox npx @modelcontextprotocol/inspector node dist/index.js

Try these operations:

  1. list_directory with no arguments to see the sandbox root
  2. read_file with path: "README.md"
  3. write_file with path: "notes.txt" and content: "Created by AI"
  4. read_file with path: "../../etc/passwd" to confirm the security check works

Frequently Asked Questions