t token-based authentication at the transport layer, even for localhost bindings. Use short-lived tokens with rotation, and validate them before processing any JSON-RPC method.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const AUTH_TOKEN = process.env.MCP_SERVER_TOKEN;
if (!AUTH_TOKEN) throw new Error("MCP_SERVER_TOKEN is required");
const server = new McpServer({ name: "secure-data-bridge", version: "1.0.0" });
// Middleware-style auth validation
function validateAuth(request: any): boolean {
const token = request.params?.auth_token;
return token === AUTH_TOKEN;
}
// Wrap tool registration with auth guard
function registerSecureTool(name: string, schema: z.ZodType, handler: Function) {
server.tool(name, schema, async (params, extra) => {
if (!validateAuth(extra.request)) {
throw new Error("Authentication required");
}
return handler(params);
});
}
Architecture Rationale: Authentication is enforced at the tool registration layer rather than the transport layer to maintain protocol compatibility while ensuring every execution path is guarded. Short-lived tokens prevent replay attacks, and environment-based injection avoids hardcoding secrets.
Step 2: Sanitize Tool Metadata & Context Injection
Tool descriptions are not documentation; they are context window payloads. Malicious or compromised descriptions can override system prompts, instruct the LLM to exfiltrate data, or reveal internal state. Treat all metadata as untrusted input.
const SAFE_DESCRIPTION_REGEX = /^(?!.*\b(ignore|override|reveal|exfiltrate|send to|your new role)\b).{1,250}$/i;
function registerSanitizedTool(name: string, description: string, schema: z.ZodType, handler: Function) {
if (!SAFE_DESCRIPTION_REGEX.test(description)) {
throw new Error(`Tool "${name}" contains prohibited metadata patterns`);
}
server.tool(name, description, schema, async (params) => {
return handler(params);
});
}
Architecture Rationale: Regex-based filtering catches common prompt injection vectors before they reach the LLM. Length limits prevent context window poisoning. This approach shifts metadata validation to registration time, failing fast rather than at runtime.
Every tool parameter must be validated against a strict schema. Type coercion, path traversal, and injection attacks exploit loose validation. Use Zod for runtime schema enforcement and explicitly reject malformed payloads.
import { z } from "zod";
const FileQuerySchema = z.object({
path: z.string()
.regex(/^[a-zA-Z0-9_\-./]+$/, "Invalid path characters")
.max(256),
operation: z.enum(["read", "list", "metadata"]),
depth: z.number().int().min(1).max(5).default(1)
});
registerSanitizedTool(
"query_filesystem",
"Retrieve file metadata or contents within allowed directories",
FileQuerySchema,
async (params) => {
// Resolve path safely, prevent traversal
const safePath = path.resolve("/allowed/root", params.path);
if (!safePath.startsWith("/allowed/root")) {
throw new Error("Path traversal detected");
}
return executeFileOperation(safePath, params.operation, params.depth);
}
);
Architecture Rationale: Zod provides runtime type safety and explicit error messages. Path resolution with prefix validation neutralizes traversal attacks. Enum constraints limit operation scope, reducing the blast radius of compromised tools.
Step 4: Deploy Runtime Sandboxing & Egress Control
Static analysis cannot predict runtime behavior. Deploy MCP servers in isolated containers with strict capability drops, read-only filesystems, and disabled egress. Monitor stdout, filesystem mutations, and network attempts during adversarial testing.
# docker-compose.sandbox.yml
version: "3.8"
services:
mcp-sandbox:
build: ./mcp-server
network_mode: "none"
read_only: true
cap_drop: ["ALL"]
tmpfs:
- /tmp:size=64M
volumes:
- ./allowed-data:/data:ro
environment:
- MCP_SERVER_TOKEN=${MCP_TOKEN}
- LOG_LEVEL=warn
command: ["node", "dist/server.js"]
Architecture Rationale: network_mode: "none" is the most effective control against data exfiltration. Read-only filesystems prevent persistent compromise. Capability drops eliminate privilege escalation vectors. Temporary filesystems allow necessary runtime operations without persistence.
Step 5: Implement Structured Error Handling & Rate Limiting
Verbose errors leak stack traces, environment variables, and internal architecture. Implement a uniform error formatter that strips sensitive data. Apply rate limiting to prevent abuse and enumeration attacks.
import rateLimit from "express-rate-limit";
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { jsonrpc: "2.0", error: { code: -32001, message: "Rate limit exceeded" }, id: null }
});
function formatErrorResponse(error: Error, id: string | number) {
const safeMessage = error.message.length > 120
? error.message.slice(0, 120) + "..."
: error.message;
return {
jsonrpc: "2.0",
error: { code: -32603, message: "Internal error", data: { detail: safeMessage } },
id
};
}
Architecture Rationale: Rate limiting prevents brute-force enumeration and DoS. Structured error formatting ensures consistent JSON-RPC responses while stripping stack traces and environment data. Legacy header removal prevents information leakage via HTTP metadata.
Pitfall Guide
1. Localhost Complacency
Explanation: Developers assume binding to 127.0.0.1 or localhost provides security. Shared development machines, container networks, and compromised processes can bypass this boundary.
Fix: Enforce token authentication regardless of bind address. Use Unix domain sockets for local IPC when possible, and validate credentials at the protocol layer.
Explanation: Tool descriptions are treated as static documentation. In reality, they are injected into the LLM's context window and can contain executable instructions.
Fix: Implement regex-based sanitization at registration time. Reject descriptions containing imperative verbs, role overrides, or data exfiltration patterns. Treat metadata as untrusted input.
3. Schema Bypass via Type Coercion
Explanation: JSON-RPC allows flexible typing. Without strict validation, numeric strings, nested objects, or malformed payloads can bypass business logic.
Fix: Use runtime schema validation (Zod, Joi, or custom validators) that rejects type coercion. Define explicit enums for operations and enforce strict type boundaries.
4. Over-Provisioned OAuth Tokens
Explanation: MCP servers acting as proxies often request broad OAuth scopes ("god mode") instead of minimal permissions. Compromised tokens grant excessive access.
Fix: Implement scope minimization at the OAuth flow level. Request only the permissions required for specific tools. Rotate tokens frequently and implement token binding to tool execution contexts.
5. Verbose Error Propagation
Explanation: Unhandled exceptions bubble up as JSON-RPC errors containing stack traces, file paths, and environment variables. Attackers use this for reconnaissance.
Fix: Implement a global error handler that formats responses uniformly. Strip stack traces, limit message length, and log detailed errors internally while returning generic client-facing messages.
6. Static-Only Security Scanning
Explanation: Relying solely on Semgrep, ESLint, or SAST tools misses runtime behavior. Malicious code can appear benign in source control but execute network calls or filesystem reads at runtime.
Fix: Combine static analysis with runtime sandboxing. Deploy adversarial testing pipelines that monitor stdout, filesystem mutations, and network attempts. Treat runtime behavior as the source of truth.
7. Unbounded Context Window Usage
Explanation: Tools that return large payloads or unfiltered data can exhaust the LLM's context window, causing degradation or triggering fallback behaviors that bypass security controls.
Fix: Implement payload size limits, pagination, and data truncation at the tool layer. Return metadata summaries instead of raw content when possible. Enforce strict output schemas.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Internal development MCP server | Token auth + localhost binding + verbose logging | Faster iteration, acceptable risk in isolated envs | Low |
| Production-facing MCP proxy | OAuth scopes + rate limiting + structured errors + sandbox | Minimizes blast radius, prevents exfiltration | Medium |
| Community-sourced MCP server | Full sandbox + network isolation + runtime monitoring | Supply chain risk requires zero-trust execution | High |
| High-throughput data pipeline | Schema validation + payload limits + async processing | Prevents context window exhaustion and DoS | Medium |
| Multi-tenant MCP gateway | Per-tenant auth + scope isolation + audit logging | Prevents cross-tenant data leakage | High |
Configuration Template
// mcp-hardened-server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import rateLimit from "express-rate-limit";
const server = new McpServer({ name: "hardened-mcp-gateway", version: "2.1.0" });
const AUTH_TOKEN = process.env.MCP_AUTH_TOKEN;
if (!AUTH_TOKEN) throw new Error("MCP_AUTH_TOKEN must be set");
const limiter = rateLimit({
windowMs: 60_000,
max: 25,
standardHeaders: true,
legacyHeaders: false,
message: { jsonrpc: "2.0", error: { code: -32001, message: "Rate limit exceeded" }, id: null }
});
function validateAuth(req: any): boolean {
return req.params?.auth_token === AUTH_TOKEN;
}
const SAFE_META = /^(?!.*\b(ignore|override|reveal|exfiltrate|send to|your new role)\b).{1,250}$/i;
function registerTool(name: string, desc: string, schema: z.ZodType, handler: (p: any) => Promise<any>) {
if (!SAFE_META.test(desc)) throw new Error(`Invalid metadata for ${name}`);
server.tool(name, desc, schema, async (params, extra) => {
if (!validateAuth(extra.request)) throw new Error("Authentication required");
try {
return await handler(params);
} catch (err) {
const msg = err instanceof Error ? err.message.slice(0, 120) : "Unknown error";
throw new Error(msg);
}
});
}
const QuerySchema = z.object({
target: z.string().regex(/^[a-zA-Z0-9_\-./]+$/).max(200),
mode: z.enum(["read", "list", "stats"]),
limit: z.number().int().min(1).max(50).default(10)
});
registerTool("query_resource", "Access structured resource data with strict boundaries", QuerySchema, async (p) => {
const resolved = path.resolve("/secure/root", p.target);
if (!resolved.startsWith("/secure/root")) throw new Error("Path traversal blocked");
return await executeQuery(resolved, p.mode, p.limit);
});
const transport = new StdioServerTransport();
await server.connect(transport);
Quick Start Guide
- Initialize the project:
npm init -y && npm install @modelcontextprotocol/sdk zod express-rate-limit
- Set environment variables:
export MCP_AUTH_TOKEN=$(openssl rand -hex 16)
- Deploy sandbox:
docker compose -f docker-compose.sandbox.yml up -d
- Run adversarial test: Send malformed JSON-RPC payloads via
curl or a custom test script to verify error formatting and rate limiting
- Monitor runtime: Check container logs for stdout anomalies, filesystem mutations, and network attempts. Adjust schemas and limits based on observed behavior.
MCP servers are not internal utilities; they are execution boundaries that bridge untrusted models to critical systems. Treat them with the same rigor as public APIs, enforce zero-trust principles at every layer, and validate runtime behavior over static assumptions. The protocol's simplicity is its strength, but it demands explicit security controls from the implementer.