Introduction
The Model Context Protocol defines a layered security architecture built around three distinct trust boundaries: Host, Client, and Server. Understanding these boundaries is essential for building production MCP servers that resist real-world attack vectors. A misconfigured trust boundary can turn a helpful tool into an open door for command injection, data exfiltration, or privilege escalation.
Key Concepts
- Host: The application (e.g., Claude Desktop, an IDE) that enforces security policies, manages user consent, and controls which servers a client may connect to. The Host is the ultimate policy authority.
- Client: A protocol-level connector that maintains a 1:1 relationship with a single MCP server. The Client negotiates capabilities during initialization and relays requests between Host and Server.
- Server: The capability provider that exposes tools, resources, and prompts. The Server MUST validate all incoming inputs regardless of what the Client claims to have validated.
- Principle of Least Privilege: Each server should request and be granted only the minimum capabilities it needs. A file-reading server should not have network access; a database query tool should not have write permissions.
- Trust Boundary: A logical perimeter where data crosses from one trust domain to another. Every crossing requires validation.
Real World Context
Consider a production deployment where an MCP server provides database query tools to an AI assistant. A user asks the assistant to "find all orders from last week." The Host checks that the user has permission to use database tools, the Client forwards the structured request, and the Server validates that the generated SQL is a safe SELECT query. If the Server blindly executes whatever the Client sends, an attacker who compromises the prompt could inject DROP TABLE orders into the tool arguments.
Deep Dive
The MCP security model enforces defense in depth. Each layer independently validates and constrains behavior.
Host-Level Enforcement
The Host controls which servers are available and requires explicit user consent before granting tool access:
typescript// Host-level policy: only allow approved servers const allowedServers = [ { name: "db-reader", transport: "stdio", command: "node", args: ["./db-server.js"] } ]; // Host prompts user before first tool invocation async function requestConsent(toolName: string, args: unknown): Promise<boolean> { return await showDialog(`Allow tool "${toolName}" with args: ${JSON.stringify(args)}?`); }
This ensures that even if a malicious prompt tries to invoke a dangerous tool, the user must explicitly approve it.
Server-Level Input Validation
Servers must never trust input from the Client. Always validate and sanitize:
typescriptimport { z } from "zod"; // Define strict schemas for every tool const QuerySchema = z.object({ table: z.enum(["orders", "products", "users"]), limit: z.number().int().min(1).max(100).default(10), where: z.record(z.string()).optional() }); server.tool("query_table", "Query a database table", { table: z.string(), limit: z.number().optional(), where: z.record(z.string()).optional() }, async (args) => { const validated = QuerySchema.parse(args); // Use parameterized queries, never string interpolation const rows = await db.select(validated.table, validated.where, validated.limit); return { content: [{ type: "text", text: JSON.stringify(rows) }] }; } );
This code restricts table access to an explicit allowlist and enforces integer limits, preventing injection attacks.
Preventing Command Injection
Never pass user-provided arguments directly to a shell:
typescript// DANGEROUS: command injection vulnerability server.tool("run_lint", "Lint a file", { file: z.string() }, async ({ file }) => { const result = execSync(`eslint ${file}`); // attacker sends: "; rm -rf /" return { content: [{ type: "text", text: result.toString() }] }; }); // SAFE: use execFile with argument array import { execFile } from "child_process"; server.tool("run_lint", "Lint a file", { file: z.string() }, async ({ file }) => { const safePath = validatePath(file); const result = await execFileAsync("eslint", [safePath]); return { content: [{ type: "text", text: result.stdout }] }; });
Using execFile with an argument array prevents shell interpretation of metacharacters.
DNS Rebinding and SSRF
HTTP-based MCP servers are vulnerable to DNS rebinding attacks where an attacker's domain initially resolves to a public IP but later resolves to 127.0.0.1. Mitigations include binding to specific interfaces, validating the Host header, and using authentication on all endpoints.
Common Pitfalls
- Trusting Client-side validation: The Client may validate inputs, but the Server must re-validate everything. Client validation is a UX convenience, not a security guarantee.
- Overly broad tool permissions: Granting a server access to the entire filesystem or all network endpoints violates least privilege and dramatically increases blast radius.
- Logging sensitive data: Audit logs should record tool invocations but must never log secrets, tokens, or sensitive query results.
Best Practices
- Validate at every boundary: Host validates user intent, Client validates protocol compliance, Server validates all arguments against strict schemas.
- Use allowlists over denylists: Enumerate what is permitted rather than trying to block what is dangerous. Denylists are always incomplete.
- Annotate tool risk levels: Use MCP tool annotations (
destructiveHint,readOnlyHint) to help Hosts make informed consent decisions.
Summary
- MCP defines three trust boundaries: Host (policy), Client (protocol), and Server (capabilities)
- Servers must validate all inputs independently, never trusting upstream validation
- Command injection is prevented by avoiding shell execution with user-controlled strings
- DNS rebinding and SSRF are real risks for HTTP-transport servers
- Tool annotations communicate risk levels to Hosts for informed user consent
Code Examples
import { z } from "zod";
import { execFile } from "child_process";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
// Strict input schema with allowlist
const LintInputSchema = z.object({
file: z.string().regex(/^[a-zA-Z0-9_\-\/\.]+$/),
rules: z.array(z.string()).max(10).optional()
});
server.tool("lint_file", "Run ESLint on a file",
{ file: z.string(), rules: z.array(z.string()).optional() },
async (args) => {
const { file, rules } = LintInputSchema.parse(args);
const safePath = validatePath(file);
const cmdArgs = [safePath];
if (rules) cmdArgs.push("--rule", rules.join(","));
const { stdout } = await execFileAsync("eslint", cmdArgs);
return { content: [{ type: "text", text: stdout }] };
}
);