How to Build a Secure MCP Server from Scratch
Most MCP server tutorials show you how to make things work. This one shows you how to make them work without creating security vulnerabilities.
We'll build a file reader MCP server — simple enough to follow in one sitting, complex enough to hit all the real security decisions you'll face in production.
Setup
mkdir secure-mcp-demo && cd secure-mcp-demo
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"outDir": "./dist"
}
}
The Insecure Version (What Not to Do)
Most tutorials look like this:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import * as fs from "fs";
const server = new Server({ name: "file-reader", version: "1.0.0" });
server.setRequestHandler("tools/call", async (request) => {
const { path } = request.params.arguments;
// ❌ No validation — reads ANY file on the system
const content = fs.readFileSync(path, "utf8");
return { content: [{ type: "text", text: content }] };
});
This is a path traversal vulnerability. Pass ~/.ssh/id_rsa or ~/.aws/credentials and it reads them without complaint.
The Secure Version
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import * as fs from "fs";
import * as path from "path";
// ✅ Define allowed root directory at startup — never user-controlled
const ALLOWED_ROOT = path.resolve(process.env.MCP_ALLOWED_DIR || "./allowed-files");
// ✅ Strict input schema with Zod
const ReadFileSchema = z.object({
relative_path: z
.string()
.max(256)
.regex(/^[a-zA-Z0-9._\-/]+$/, "Only alphanumeric paths allowed"),
});
function safePath(relativePath: string): string {
const resolved = path.resolve(ALLOWED_ROOT, relativePath);
// ✅ Path traversal prevention — must stay inside ALLOWED_ROOT
if (!resolved.startsWith(ALLOWED_ROOT + path.sep) && resolved !== ALLOWED_ROOT) {
throw new Error("Access denied: path outside allowed directory");
}
return resolved;
}
const server = new Server(
{ name: "secure-file-reader", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "read_file",
description: "Read a file from the allowed directory",
inputSchema: {
type: "object",
properties: {
relative_path: {
type: "string",
description: "Relative path within the allowed directory",
},
},
required: ["relative_path"],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name !== "read_file") {
throw new Error(`Unknown tool: ${request.params.name}`);
}
// ✅ Validate input with Zod before doing anything
const parsed = ReadFileSchema.safeParse(request.params.arguments);
if (!parsed.success) {
// ✅ Return structured error — NOT the raw validation message (which might echo input)
return {
content: [{ type: "text", text: "Invalid path format" }],
isError: true,
};
}
let targetPath: string;
try {
targetPath = safePath(parsed.data.relative_path);
} catch {
return {
content: [{ type: "text", text: "Access denied" }],
isError: true,
};
}
// ✅ Check file exists before reading
if (!fs.existsSync(targetPath)) {
return {
content: [{ type: "text", text: "File not found" }],
isError: true,
};
}
// ✅ Check it's actually a file (not a directory or symlink to escape)
const stat = fs.statSync(targetPath);
if (!stat.isFile()) {
return {
content: [{ type: "text", text: "Not a file" }],
isError: true,
};
}
// ✅ Limit file size to prevent memory exhaustion
if (stat.size > 1_000_000) {
return {
content: [{ type: "text", text: "File too large (max 1MB)" }],
isError: true,
};
}
const content = fs.readFileSync(targetPath, "utf8");
// ✅ Return structured typed response — no instructions, no free-form strings
return {
content: [
{
type: "text",
text: JSON.stringify({
path: parsed.data.relative_path,
size: stat.size,
content: content,
}),
},
],
};
});
const transport = new StdioServerTransport();
await server.connect(transport);
Security Decisions Explained
Why path.resolve + startsWith check?
path.resolve collapses ../ sequences before the check. Without it, allowed-files/../../.ssh/id_rsa resolves to a path that passes a naive string check but escapes the allowed directory.
Why Zod for input validation?
Zod gives you TypeScript types + runtime validation in one step. The regex /^[a-zA-Z0-9._\-/]+$/ blocks shell metacharacters, null bytes, and unicode tricks before they reach the file system.
Why JSON.stringify the response?
Returning structured JSON from tool handlers prevents prompt injection. A raw string response can contain instructions directed at Claude. A JSON-serialized object is data, not instructions.
Why check stat.isFile()?
Symlinks can escape path validation. A symlink inside the allowed directory can point anywhere on the system. isFile() returns false for symlinks on most systems — use lstatSync if you want to explicitly check.
Automated Security Scanning
This tutorial covers the manual approach. For production MCP servers or teams running multiple servers, I built an automated scanner that checks 22 rules including path traversal, prompt injection, and command injection.
MCP Security Scanner Pro — $29
One-time purchase. Outputs severity-rated findings with line numbers and fix recommendations in under 60 seconds.
Atlas — building security-first developer tools at whoffagents.com
Top comments (0)