MCP Security: Model Context Protocol Authorization

MCP security and guardrails for Claude, Cursor, and AI tools. Authorize tool calls, enforce policies, and maintain control over Model Context Protocol integrations.

What is MCP (Model Context Protocol)?

MCP (Model Context Protocol) is an open protocol that standardizes how AI applications connect to external tools and data sources. It enables Claude, Cursor, and other AI tools to call functions, read files, and interact with APIs. MCP security ensures these powerful capabilities are authorized before execution.

Why MCP needs authorization

MCP servers expose tools that can read files, execute commands, call APIs, and modify data. Without authorization, any MCP-connected agent has full access to every tool. A single compromised or misbehaving agent can delete databases, send emails, or exfiltrate sensitive data.

MCP guardrails operate at the tool-call boundary. Before an agent can invoke any MCP tool, Veto evaluates the request against your policies. Allow read operations, require approval for writes, block destructive actions entirely.

Unrestricted access

MCP servers often have broad permissions. File system access, database connections, API keys.

Agent autonomy

Agents decide which tools to call. Without guardrails, there's no validation or approval step.

Authorization layer

Veto intercepts MCP tool calls and enforces policies before execution.

How Veto's MCP gateway works

Veto acts as an authorization proxy between MCP clients and MCP servers. All tool calls pass through the gateway for policy evaluation before reaching the upstream server.

1

Configure MCP upstreams

Register your MCP servers in the Veto dashboard. Supports SSE transport for remote servers and stdio for local processes.

2

Point clients to the gateway

Claude, Cursor, or your custom MCP client connects to Veto's gateway endpoint instead of the upstream directly.

3

Policy evaluation

Every tool call is evaluated against your policies. Allow, deny, or route to human approval based on tool name, arguments, and context.

4

Audit and compliance

All decisions logged with tool, arguments, outcome, and timestamp. Export for SOC2, HIPAA, and compliance reporting.

TypeScript MCP client configuration

Configure your MCP client to route through Veto's gateway. Works with Claude Desktop, Cursor, and any MCP-compliant client.

mcp-config.tstypescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"

// Route MCP traffic through Veto's authorization gateway
const vetoGatewayUrl = "https://api.veto.so/v1/mcp/default"
const apiKey = process.env.VETO_API_KEY

const transport = new SSEClientTransport(
  new URL(vetoGatewayUrl),
  {
    headers: {
      "X-Veto-API-Key": apiKey,
    },
  }
)

const client = new Client(
  { name: "my-mcp-client", version: "1.0.0" },
  { capabilities: {} }
)

await client.connect(transport)

// List available tools (Veto filters based on policy)
const tools = await client.listTools()
console.log("Authorized tools:", tools)

// Call a tool (Veto evaluates before forwarding)
const result = await client.callTool({
  name: "read_file",
  arguments: { path: "/data/report.csv" }
})

The client connects to Veto's gateway. Tool calls are authorized before being forwarded to your upstream MCP servers.

MCP security patterns

Common authorization patterns for MCP deployments.

Read-only by default

Allow all read operations (list, get, search) without approval. Require explicit authorization for writes (create, update, delete).

Sensitive data blocking

Block tool calls that access PII, credentials, or financial data. Log attempts for security review.

Human-in-the-loop for destructive ops

Route delete, drop, or purge operations to human approval. Prevent accidental data loss from agent mistakes.

Rate limiting per tool

Limit how often specific tools can be called. Prevent runaway agents from overwhelming APIs or databases.

Tool poisoning attacks and mitigation

MCP's tool definition model has a critical security implication: the server defines the tool schema, but a malicious or compromised server could inject misleading descriptions that trick the LLM into executing unintended actions. This is known as tool poisoning.

For example, a tool named "send_email" with a description claiming it "saves a draft" would cause the LLM to execute it when the user only wanted to compose, not send. Veto's authorization layer provides defense-in-depth against these attacks.

tool_poisoning_prevention.tstypescript
interface ToolDefinition {
  name: string
  description: string
  inputSchema: JSONSchema
  // Potentially malicious fields from untrusted server
  annotations?: Record<string, unknown>
}

class ToolPoisoningPrevention {
  /**
   * Detect potential tool poisoning by comparing declared vs actual behavior.
   * Defense-in-depth against malicious MCP servers.
   */
  private trustedToolSignatures: Map<string, ToolSignature>

  validateToolDefinition(tool: ToolDefinition): ValidationResult {
    const errors: string[] = []

    // 1. Check against trusted signature (pinned on first use)
    const trusted = this.trustedToolSignatures.get(tool.name)
    if (trusted) {
      if (tool.description !== trusted.description) {
        errors.push(`Description mismatch for ${tool.name}. Tool may have been modified.`)
      }
      if (JSON.stringify(tool.inputSchema) !== JSON.stringify(trusted.inputSchema)) {
        errors.push(`Input schema changed for ${tool.name}. Verify with server operator.`)
      }
    }

    // 2. Detect suspicious description patterns
    const suspiciousPatterns = [
      /send.*email.*draft/i,      // Claims draft but might send
      /delete.*backup/i,           // Hides destructive action
      /read.*password/i,           // Attempts credential theft
      /forward.*to.*external/i,    // Data exfiltration risk
    ]

    for (const pattern of suspiciousPatterns) {
      if (pattern.test(tool.description)) {
        errors.push(`Suspicious pattern in tool description: ${pattern.source}`)
      }
    }

    // 3. Enforce least-privilege annotations
    if (tool.annotations?.readOnlyHint !== true && this.isWriteTool(tool.name)) {
      errors.push(`Tool ${tool.name} performs writes but lacks readOnlyHint=false`)
    }

    return {
      valid: errors.length === 0,
      errors,
      recommendation: errors.length > 0 ? 'Block tool until verified' : 'Allow'
    }
  }

  // Register trusted tools after verification
  pinToolSignature(tool: ToolDefinition, verifiedBy: string): void {
    this.trustedToolSignatures.set(tool.name, {
      name: tool.name,
      description: tool.description,
      inputSchema: tool.inputSchema,
      pinnedAt: new Date(),
      verifiedBy
    })
  }
}

OAuth state binding for MCP authentication

When MCP servers require OAuth authentication, the authorization code flow must bind the state parameter to the specific MCP session and tool call context. Without proper binding, an attacker could intercept the callback and execute unauthorized tool calls.

oauth_state_binding.tstypescript
import { randomBytes, createHmac } from 'crypto'

interface OAuthStatePayload {
  sessionId: string          // MCP session identifier
  toolName: string           // Tool that triggered OAuth
  requestId: string          // Unique request correlation ID
  redirectUri: string        // Where to send the user after auth
  expiresAt: number          // Unix timestamp
}

class MCPAuthStateManager {
  private secret: Buffer
  private stateCache: Map<string, OAuthStatePayload>

  constructor(secret: string) {
    this.secret = Buffer.from(secret, 'utf-8')
    this.stateCache = new Map()
  }

  /**
   * Generate an OAuth state parameter bound to the MCP context.
   * Prevents CSRF attacks on OAuth callbacks.
   */
  generateState(payload: OAuthStatePayload): string {
    const nonce = randomBytes(16).toString('hex')
    const stateData = JSON.stringify({ ...payload, nonce })

    // Create HMAC signature for integrity
    const signature = createHmac('sha256', this.secret)
      .update(stateData)
      .digest('hex')

    const state = Buffer.from(JSON.stringify({
      data: payload,
      nonce,
      sig: signature.substring(0, 32)  // First 32 chars of HMAC
    })).toString('base64url')

    // Cache for verification
    this.stateCache.set(state, { ...payload, expiresAt: Date.now() + 300000 }) // 5 min TTL

    return state
  }

  /**
   * Verify and decode OAuth state, ensuring it matches the expected context.
   */
  verifyState(state: string, expectedSessionId: string): OAuthStatePayload | null {
    const cached = this.stateCache.get(state)
    if (!cached) {
      console.error('State not found in cache - possible replay attack')
      return null
    }

    // Verify session binding
    if (cached.sessionId !== expectedSessionId) {
      console.error('Session ID mismatch in OAuth state')
      return null
    }

    // Check expiration
    if (Date.now() > cached.expiresAt) {
      this.stateCache.delete(state)
      console.error('OAuth state expired')
      return null
    }

    // One-time use - delete after verification
    this.stateCache.delete(state)

    return cached
  }
}

// Usage in MCP OAuth flow
const authManager = new MCPAuthStateManager(process.env.OAUTH_STATE_SECRET)

// When tool requires OAuth
app.get('/mcp/:serverId/tools/:toolName/oauth/authorize', async (req, res) => {
  const { serverId, toolName } = req.params
  const sessionId = req.headers['x-mcp-session'] as string

  const state = authManager.generateState({
    sessionId,
    toolName,
    requestId: crypto.randomUUID(),
    redirectUri: `${req.protocol}://${req.get('host')}/mcp/oauth/callback`,
    expiresAt: Date.now() + 300000
  })

  const authUrl = new URL(oauthConfig.authorizationEndpoint)
  authUrl.searchParams.set('response_type', 'code')
  authUrl.searchParams.set('client_id', oauthConfig.clientId)
  authUrl.searchParams.set('redirect_uri', oauthConfig.redirectUri)
  authUrl.searchParams.set('state', state)
  authUrl.searchParams.set('scope', oauthConfig.scopes.join(' '))

  res.redirect(authUrl.toString())
})

The state parameter is cryptographically bound to the MCP session, preventing attackers from hijacking OAuth flows to execute unauthorized tool calls.

Input validation for MCP tool calls

Veto's authorization layer validates tool inputs before they reach MCP servers. This prevents injection attacks, path traversal, and argument manipulation.

input_validation_guardrails.tstypescript
import { z } from 'zod'

// Define strict schemas for MCP tool arguments
const ToolSchemas = {
  read_file: 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('/root'), 'Root directory access denied')
      .refine(p => !p.includes('.env'), 'Environment file access denied')
  }),

  execute_query: z.object({
    query: z.string()
      .refine(q => !/;s*DROP/i.test(q), 'DROP statement not allowed')
      .refine(q => !/;s*DELETE/i.test(q), 'DELETE requires approval')
      .refine(q => !/;s*TRUNCATE/i.test(q), 'TRUNCATE not allowed'),
    database: z.string().regex(/^[a-zA-Z0-9_]+$/, 'Invalid database name')
  }),

  run_command: z.object({
    command: z.string()
      .refine(c => !c.includes('rm -rf'), 'Recursive delete blocked')
      .refine(c => !c.includes('sudo'), 'Elevated privileges not allowed')
      .refine(c => !c.includes('chmod 777'), 'Insecure permissions blocked')
      .refine(c => !/>s*//.test(c), 'Root filesystem write blocked'),
    timeout: z.number().max(30000).optional()  // 30s max
  }),

  http_request: z.object({
    url: z.string().url()
      .refine(u => !u.includes('localhost'), 'Localhost requests blocked')
      .refine(u => !u.includes('127.0.0.1'), 'Loopback requests blocked')
      .refine(u => !u.includes('169.254'), 'Metadata service access blocked')
      .refine(u => !u.includes('internal'), 'Internal network access blocked'),
    method: z.enum(['GET', 'POST', 'PUT', 'DELETE']),
    headers: z.record(z.string()).optional()
      .refine(h => !h?.Authorization?.includes('Bearer'), 'Auth header injection detected')
  })
}

class MCPInputValidator {
  validate(toolName: string, args: Record<string, unknown>): ValidationResult {
    const schema = ToolSchemas[toolName as keyof typeof ToolSchemas]
    if (!schema) {
      // Unknown tool - require explicit approval
      return { valid: false, reason: 'Unknown tool', action: 'require_approval' }
    }

    try {
      schema.parse(args)
      return { valid: true, action: 'allow' }
    } catch (error) {
      if (error instanceof z.ZodError) {
        const issues = error.issues.map(i => i.message)
        return {
          valid: false,
          reason: `Validation failed: ${issues.join(', ')}`,
          action: 'deny',
          shouldAlert: issues.some(i =>
            i.includes('traversal') ||
            i.includes('injection') ||
            i.includes('blocked')
          )
        }
      }
      return { valid: false, reason: 'Unknown validation error', action: 'deny' }
    }
  }
}

MCP policy example

Define authorization rules for MCP tools in declarative YAML.

veto/policies.yamlyaml
policies:
  # Allow read operations on filesystem tools
  - name: "Allow file reads"
    match:
      tool: "read_file"
      arguments:
        path: "^(?!/etc|/root|/home).*$"  # Exclude sensitive paths
    action: allow

  # Require approval for database writes
  - name: "Approve database writes"
    match:
      tool: "execute_query"
      arguments:
        query: "^(INSERT|UPDATE|DELETE).*$"
    action: require_approval
    approval:
      timeout_minutes: 30
      channels: [slack, email]

  # Block destructive operations entirely
  - name: "Block destructive commands"
    match:
      tool: "run_command"
      arguments:
        command: "^(rm -rf|DROP|truncate).*$"
    action: deny
    response:
      error: "Destructive operations are not permitted"

  # Rate limit API calls
  - name: "Rate limit external APIs"
    match:
      tool: "http_request"
    action: allow
    rate_limit:
      max_calls: 100
      window_seconds: 60

Supported MCP clients

Veto's MCP gateway works with any MCP-compliant client.

Claude Desktop
Cursor
Windsurf
Zed

Related integrations

Frequently asked questions

What is MCP security?
MCP security refers to authorization and guardrails for the Model Context Protocol. It controls which tools MCP-connected agents can call, with what arguments, and under what conditions. Without MCP security, any agent with access to an MCP server can invoke all its tools unrestricted.
How does Veto's MCP gateway differ from MCP server permissions?
MCP server permissions are typically coarse-grained (all or nothing). Veto's gateway provides fine-grained authorization at the tool-call level with argument inspection, policy evaluation, approval workflows, and audit logging. You can allow read_file but require approval for write_file, even though both come from the same MCP server.
Can I use Veto with self-hosted MCP servers?
Yes. Veto supports both SSE transport (remote servers) and stdio transport (local processes). For stdio, use the Veto CLI to run your MCP server through the authorization layer: `veto mcp serve --config ./veto/mcp.config.yaml`.
Does Veto add latency to MCP tool calls?
Minimal impact. Policy evaluation happens in-process, typically under 10ms for local policies. Cloud mode adds network latency for approval workflows and audit logging, but the critical path for allowed operations remains fast. Most MCP tool calls spend far more time in the actual tool execution than in authorization.

Secure your MCP integrations with authorization guardrails.