advanced
ai-engineering

Security & Sandboxing

Permission scopes, input validation, and secure tool execution boundaries

mcp fundamentals
28 min read

Why Security Is the Hard Part of MCP

An MCP server turns a language model into an actor: it can read files, hit databases, and call external APIs. That is the whole point — and it is also the whole risk. The arguments your tools receive are ultimately shaped by model output, and model output can be steered by untrusted content the model just read (a web page, an email, a PR description). This is indirect prompt injection, and it means you must treat every tool call as if it originated from a hostile client.

The good news: MCP servers are ordinary programs, so the defenses are the ones you already know — strict input validation, least privilege, sandboxing, and auditing — applied with discipline at the tool boundary. This lesson wires those defenses into a real TypeScript server.

Learning Objectives
  • Validate every tool input with a strict Zod schema
  • Prevent path traversal and enforce filesystem boundaries
  • Apply least privilege using scopes and tool annotations
  • Sandbox the runtime with a hardened container
  • Audit tool calls and gate destructive actions behind confirmation

The MCP Threat Model

Before writing code, name what you are defending against. In a typical MCP deployment the server trusts neither the tool arguments nor the data those tools return.

Where attacks enter
  • Injected arguments — the model, influenced by malicious content, calls delete_record or reads /etc/passwd.
  • Path & command traversal — ../../ sequences or shell metacharacters escape their intended sandbox.
  • Confused deputy — the server holds broad credentials and executes actions the end user never authorized.
  • Data exfiltration — a tool returns secrets that get echoed back into the model context.

Validate Every Input with Zod

The single most effective control is a schema that rejects anything unexpected before your handler runs. Constrain type, length, format, and range — an allowlist beats a denylist every time. The official SDK integrates Zod directly, so a rejected input never reaches your logic.

typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { z } from "zod" const server = new McpServer({ name: "files", version: "1.0.0" }) server.registerTool( "read_file", { title: "Read File", description: "Read a UTF-8 text file inside the workspace.", inputSchema: { // Allowlist the exact shape: relative path, bounded length, safe charset. path: z .string() .max(256) .regex(/^[a-zA-Z0-9_./-]+$/, "Illegal characters in path"), // Cap how much can be exfiltrated in one call. maxBytes: z.number().int().min(1).max(1_000_000).default(65_536), }, annotations: { readOnlyHint: true, destructiveHint: false }, }, async ({ path, maxBytes }) => { const abs = safeResolve(path) // see next section const data = await fs.readFile(abs, "utf8") return { content: [{ type: "text", text: data.slice(0, maxBytes) }] } }, )

Enforce Filesystem Boundaries

Prefix checks like path.startsWith(ROOT) are a classic footgun: ../ sequences and sibling directories that share a prefix (/srv/data-evil starts with /srv/data) both slip through. Always resolve to an absolute path first, then confirm it sits under the root plus a separator.

typescript
import path from "node:path" import fs from "node:fs/promises" const ALLOWED_ROOT = path.resolve("/srv/workspace") export function safeResolve(userPath: string): string { const resolved = path.resolve(ALLOWED_ROOT, userPath) const isInside = resolved === ALLOWED_ROOT || resolved.startsWith(ALLOWED_ROOT + path.sep) if (!isInside) throw new Error("Path escapes the allowed root") return resolved }

The same principle applies to any tool that shells out. Never interpolate model input into a command string — pass arguments as an array and keep an explicit allowlist of executables.

typescript
import { execFile } from "node:child_process" import { promisify } from "node:util" const run = promisify(execFile) const ALLOWED = new Set(["git", "ls", "cat"]) async function safeExec(cmd: string, args: string[]) { if (!ALLOWED.has(cmd)) throw new Error(`Command not permitted: ${cmd}`) // execFile does NOT spawn a shell, so metacharacters can't chain commands. return run(cmd, args, { timeout: 5_000, cwd: ALLOWED_ROOT }) }

Least Privilege and Authentication

A tool should hold the narrowest permission that lets it do its job. Scope credentials per tool, prefer read-only database roles, and separate authentication (who is calling) from authorization (what they may do). How you authenticate depends on the transport.

For local servers launched by a client such as Claude Desktop, secrets travel through the process environment, never in the code or arguments. The client config injects them at spawn time:

json
{ "mcpServers": { "files": { "command": "node", "args": ["dist/index.js"], "env": { "DB_URL": "postgres://readonly@localhost/app" } } } }

Sandbox the Runtime

Validation stops bad inputs; sandboxing contains the blast radius when something still goes wrong. Run the server as a non-root user in a container with a read-only root filesystem, all Linux capabilities dropped, and no ability to gain new privileges.

yaml
# docker-compose.yml — a hardened MCP service services: mcp: image: my-mcp-server:1.0.0 user: "10001:10001" # never root read_only: true # immutable root filesystem cap_drop: ["ALL"] # drop every Linux capability security_opt: - no-new-privileges:true # block setuid escalation pids_limit: 128 mem_limit: 256m tmpfs: - /tmp:size=16m # writable scratch, capped networks: - restricted # egress allowlist, not open internet

Audit Logging and Human Confirmation

You cannot investigate what you did not record. Log every tool invocation with enough context to reconstruct it, and gate irreversible actions behind an explicit confirmation step rather than executing them silently.

typescript
server.registerTool( "delete_record", { title: "Delete Record", description: "Permanently delete a record by id.", inputSchema: { id: z.string().uuid(), confirm: z.literal(true) }, annotations: { destructiveHint: true, idempotentHint: false }, }, async ({ id }, { requestId }) => { logger.warn("tool.invoke", { tool: "delete_record", id, requestId, ts: Date.now() }) await db.records.delete(id) return { content: [{ type: "text", text: `Deleted ${id}` }] } }, )

By requiring confirm: z.literal(true), the model must deliberately pass confirm: true — a stray or injected call missing that field is rejected by the schema before any row is touched. Pair this with client-side approval prompts for anything destructive.

Common Pitfalls


Next Steps

Continue Learning
stdio, SSE, and WebSocket — choosing and building the right transport for your MCP server.

Next Lesson

Custom Transports