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.
MCP servers often have broad permissions. File system access, database connections, API keys.
Agents decide which tools to call. Without guardrails, there's no validation or approval step.
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.
Configure MCP upstreams
Register your MCP servers in the Veto dashboard. Supports SSE transport for remote servers and stdio for local processes.
Point clients to the gateway
Claude, Cursor, or your custom MCP client connects to Veto's gateway endpoint instead of the upstream directly.
Policy evaluation
Every tool call is evaluated against your policies. Allow, deny, or route to human approval based on tool name, arguments, and context.
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.
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.
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.
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.
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.
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: 60Supported MCP clients
Veto's MCP gateway works with any MCP-compliant client.
Related integrations
Frequently asked questions
What is MCP security?
How does Veto's MCP gateway differ from MCP server permissions?
Can I use Veto with self-hosted MCP servers?
Does Veto add latency to MCP tool calls?
Secure your MCP integrations with authorization guardrails.