Security

MCP Security: A Complete Guide

The Model Context Protocol (MCP) enables powerful AI integrations. Learn how to secure MCP servers and protect against tool-based attacks.

Veto TeamMarch 5, 202612 min

The Model Context Protocol (MCP) is becoming the standard for AI agent integrations. But with great power comes great responsibility—and new security considerations.

What is MCP?

MCP is an open protocol that standardizes how AI models connect to external tools and data sources. Instead of building custom integrations for each tool, MCP provides a universal interface. An MCP server exposes tools, resources, and prompts that any MCP-compatible client can use.

Popular MCP servers include file system access, database connections, web browsing, and more. The ecosystem is growing rapidly.

Security Risks of MCP

MCP servers often have significant capabilities. A file server can read and write files. A database server can execute queries. A browser server can navigate the web and fill forms.

Key risks include:

  • Unrestricted tool access — MCP servers don't have built-in authorization; they execute what's requested
  • Sensitive data exposure — Tools can access files, databases, and APIs that contain sensitive information
  • External communication — Browser and HTTP tools can exfiltrate data to external servers
  • Privilege escalation — A compromised agent could use MCP tools to gain broader access

The MCP Architecture Gap

MCP's architecture assumes a trusted client. The protocol has no built-in concept of authorization policies, rate limits, or approval workflows. This means:

mcp_client_gap.tstypescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js"

// MCP client has full access to all tools
const client = new Client({ name: "my-agent", version: "1.0.0" }, {
  capabilities: {}
})

await client.connect(transport)

// No authorization checks - agent can call any tool
const result = await client.callTool({
  name: "filesystem_write",
  arguments: {
    path: "/etc/passwd",  // Oops - sensitive file!
    content: "..."
  }
})

// MCP servers trust the client completely
// There's no way to say "only allow writes to /tmp"

Securing MCP with Veto

Veto provides guardrails for MCP tool calls. By wrapping MCP tool execution, you can enforce policies without modifying the MCP server:

mcp_veto_wrapper.tstypescript
import { Veto, Policy, Decision } from "veto-sdk"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"

const veto = new Veto({ apiKey: process.env.VETO_API_KEY })
const mcpClient = new Client({ name: "secure-agent", version: "1.0.0" }, {})

// Wrap MCP tool calls with Veto authorization
async function secureCallTool(
  toolName: string,
  args: Record<string, unknown>,
  context: { userId: string; role: string }
) {
  // 1. Check authorization with Veto
  const decision = await veto.validate({
    tool: toolName,
    arguments: args,
    context: {
      user_id: context.userId,
      role: context.role,
      timestamp: new Date().toISOString()
    }
  })

  // 2. Handle decision
  switch (decision.action) {
    case "deny":
      throw new Error(`Tool ${toolName} denied: ${decision.reason}`)

    case "require_approval":
      // Route to human approval queue
      const approval = await veto.requestApproval({
        tool: toolName,
        arguments: args,
        requestedBy: context.userId,
        reason: decision.approvalReason
      })
      if (!approval.granted) {
        throw new Error(`Approval denied: ${approval.reason}`)
      }
      break

    case "allow":
      // Continue to execution
      break
  }

  // 3. Execute the tool call
  const result = await mcpClient.callTool({ name: toolName, arguments: args })

  // 4. Log execution for audit
  await veto.logExecution({
    tool: toolName,
    arguments: args,
    result: result.content,
    decision: decision.action
  })

  return result
}

Policy Examples for MCP Servers

Different MCP servers need different security policies. Here are examples for common server types:

mcp_policies.tstypescript
// Filesystem MCP Server policies
const filesystemPolicies = [
  Policy.denyPath("/etc/*"),
  Policy.denyPath("*.env"),
  Policy.denyPath("*.{key,pem,p12}"),
  Policy.requireApprovalForPath("*/secrets/*"),
  Policy.readOnlyPath("/var/log/*"),
  Policy.maxFileSize(10 * 1024 * 1024), // 10MB limit
]

// Database MCP Server policies
const databasePolicies = [
  Policy.denyQuery(/DROPs+/i),
  Policy.denyQuery(/TRUNCATEs+/i),
  Policy.denyQuery(/DELETEs+FROMs+users/i),
  Policy.requireApprovalForQuery(/UPDATEs+/i),
  Policy.maxRows(1000), // Limit result sets
  Policy.readOnlyTables(["audit_logs", "billing_history"]),
]

// Web/Browser MCP Server policies
const browserPolicies = [
  Policy.denyDomain("internal.company.com"),
  Policy.denyDomain("*.private.cloud"),
  Policy.requireApprovalForDomain("api.stripe.com"),
  Policy.blockFormField(/password|secret|token|api.?key/i),
  Policy.maxRequests(100, { per: "hour" }),
  Policy.logAllRequests(),
]

// Register policies with Veto
await veto.registerMcpServer("filesystem", {
  policies: filesystemPolicies,
  audit: { enabled: true, retention: "90d" }
})

Argument Validation

MCP tool arguments can contain malicious inputs or unexpected values. Validate before execution:

mcp_validation.tstypescript
import { z } from "zod"

// Define schemas for MCP tool arguments
const WriteFileSchema = z.object({
  path: z.string()
    .refine(p => !p.includes(".."), "Path traversal detected")
    .refine(p => !p.startsWith("/etc"), "System directory access denied")
    .refine(p => !p.startsWith("~/.ssh"), "SSH directory access denied"),
  content: z.string()
    .max(10_000_000, "File too large (max 10MB)")
})

const ExecuteQuerySchema = z.object({
  query: z.string()
    .refine(q => !/DROP|TRUNCATE/i.test(q), "Destructive queries not allowed")
    .refine(q => !/;\s*DROP/i.test(q), "SQL injection detected"),
  database: z.enum(["production", "staging", "analytics"])
})

// Validate before calling MCP tools
async function validateAndCall(
  toolName: string,
  args: unknown,
  schema: z.ZodType
) {
  const validated = schema.parse(args)

  // Log validation for audit
  await veto.logValidation({
    tool: toolName,
    originalArgs: args,
    validatedArgs: validated
  })

  return mcpClient.callTool({ name: toolName, arguments: validated })
}

Real-World MCP Security Setup

Here's a complete production setup for securing multiple MCP servers:

production_mcp_security.tstypescript
import { Veto, Policy, createMcpMiddleware } from "veto-sdk"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"

// Initialize Veto with production config
const veto = new Veto({
  apiKey: process.env.VETO_API_KEY!,
  environment: "production",
  project: "mcp-agent-platform"
})

// Create middleware for MCP clients
const mcpMiddleware = createMcpMiddleware(veto, {
  defaultAction: "deny", // Fail closed
  auditEnabled: true,
  auditRetention: "365d",

  // Global policies applied to all tools
  globalPolicies: [
    Policy.rateLimit(1000, { per: "hour" }),
    Policy.logAll(),
  ],

  // Per-server policies
  serverPolicies: {
    filesystem: [
      Policy.denyPath("/etc/*"),
      Policy.denyPath("*.env"),
      Policy.requireApprovalForPath("*/sensitive/*"),
    ],
    postgres: [
      Policy.denyQuery(/DROP|TRUNCATE/i),
      Policy.readOnly(),
      Policy.maxRows(5000),
    ],
    puppeteer: [
      Policy.denyDomain(/\.internal\./),
      Policy.requireApprovalForDomain(/api\..*/),
      Policy.blockFileUploads(),
    ]
  }
})

// Wrap MCP clients with security middleware
async function createSecureMcpClient(serverName: string, transport: Transport) {
  const client = new Client({ name: serverName, version: "1.0.0" }, {})
  await client.connect(transport)

  // Wrap the callTool method with security
  const originalCallTool = client.callTool.bind(client)
  client.callTool = async (request) => {
    return mcpMiddleware.intercept(
      () => originalCallTool(request),
      {
        serverName,
        toolName: request.name,
        arguments: request.arguments as Record<string, unknown>,
      }
    )
  }

  return client
}

MCP Security Best Practices

  1. Principle of least privilege — Only connect to MCP servers that your agent actually needs
  2. Validate tool arguments — Check file paths, URLs, and query parameters before execution
  3. Log all tool calls — Maintain audit trails for compliance and debugging
  4. Require approval for sensitive operations — Human-in-the-loop for anything that modifies state or accesses sensitive data
  5. Monitor for anomalies — Track tool call patterns and alert on unusual behavior

Monitoring MCP Activity

mcp_monitoring.tstypescript
// Set up real-time monitoring for MCP tool calls
veto.onToolCall((event) => {
  // Detect anomalies
  if (event.isAnomalous) {
    console.warn(`Anomaly detected: ${event.tool} - ${event.anomalyReason}`)

    // Alert security team
    sendAlert({
      severity: "high",
      message: `Suspicious MCP activity: ${event.tool}`,
      details: event
    })
  }

  // Track metrics
  metrics.increment(`mcp.${event.server}.${event.tool}`)
  metrics.histogram(`mcp.latency`, event.duration)
})

// Set up alerts for security events
veto.onSecurityEvent((event) => {
  if (event.type === "policy_violation") {
    // Immediate notification for violations
    pagerduty.trigger({
      title: "MCP Policy Violation",
      description: event.details,
      severity: "critical"
    })
  }
})

Conclusion

MCP is powerful, but it needs guardrails. With Veto, you can use MCP tools safely without sacrificing their capabilities.Read our MCP integration guide.

Related posts

Ready to secure your agents?