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"
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
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
Create the project
npx mcp-framework create file-system-server
cd file-system-server
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;
}
}
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;
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:
list_directorywith no arguments to see the sandbox rootread_filewithpath: "README.md"write_filewithpath: "notes.txt"andcontent: "Created by AI"read_filewithpath: "../../etc/passwd"to confirm the security check works