MCP Security Best Practices

Essential security guidelines for building and configuring MCP servers, including API key management, input validation, and least-privilege access.

Security is critical when connecting AI assistants to your tools and data. MCP servers have access to whatever resources you grant them — a misconfigured server can expose sensitive data or allow unintended operations. This guide covers the essential security practices for MCP.


1. Environment Variables for Secrets

Never hardcode API keys, tokens, or passwords in your MCP server source code or configuration files.

Bad

{
  "mcpServers": {
    "my-server": {
      "command": "node",
      "args": ["server.js"],
      "env": {
        "API_KEY": "sk-1234567890abcdef"
      }
    }
  }
}

This embeds the key in claude_desktop_config.json, which might be committed to version control or exposed on screen shares.

Better

Use a .env file:

# .env (never commit this file)
API_KEY=sk-1234567890abcdef

Reference it in your server:

const apiKey = process.env.API_KEY;

And configure your MCP server to load it. The config file references the env var name only:

{
  "mcpServers": {
    "my-server": {
      "command": "node",
      "args": ["-r", "dotenv/config", "server.js"],
      "env": {
        "API_KEY": "${API_KEY}"
      }
    }
  }
}

Best

Use a secrets manager or OS keychain. On macOS:

security add-generic-password -s "mcp-my-server" -a "API_KEY" -w "sk-1234567890abcdef"

Then in your server:

import { execSync } from 'child_process';
const apiKey = execSync(
  `security find-generic-password -s "mcp-my-server" -a "API_KEY" -w`
).toString().trim();

2. Least-Privilege IAM Policies

When using AWS MCP servers, grant only the permissions your server needs.

Bad

{
  "Effect": "Allow",
  "Action": ["s3:*"],
  "Resource": ["*"]
}

Good

{
  "Effect": "Allow",
  "Action": ["s3:GetObject", "s3:ListBucket"],
  "Resource": [
    "arn:aws:s3:::my-app-data",
    "arn:aws:s3:::my-app-data/*"
  ]
}

For DynamoDB, restrict to specific tables and operations:

{
  "Effect": "Allow",
  "Action": ["dynamodb:GetItem", "dynamodb:Query"],
  "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/Users"
}

3. Input Validation

MCP tools receive arguments from AI clients. Always validate inputs before processing them.

from pydantic import BaseModel, Field

class FileReadRequest(BaseModel):
    path: str = Field(..., description="Path to read")

    @classmethod
    def validate_path(cls, path: str) -> str:
        # Prevent directory traversal
        if ".." in path or path.startswith("/"):
            raise ValueError(f"Invalid path: {path}")
        return path

For file system servers, always sanitize paths:

import { resolve, relative } from 'path';

const ALLOWED_ROOT = resolve('./data');

function safePath(userPath: string): string {
  const full = resolve(ALLOWED_ROOT, userPath);
  if (!full.startsWith(ALLOWED_ROOT)) {
    throw new Error('Path traversal detected');
  }
  return full;
}

4. Rate Limiting

Prevent abuse by limiting how often tools can be called:

import time
from functools import wraps

rate_limits: dict[str, list[float]] = {}

def rate_limit(max_calls: int, per_seconds: int):
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            now = time.time()
            key = func.__name__
            if key not in rate_limits:
                rate_limits[key] = []
            rate_limits[key] = [t for t in rate_limits[key] if now - t < per_seconds]
            if len(rate_limits[key]) >= max_calls:
                raise ToolError(f"Rate limit exceeded. Try again in {per_seconds} seconds.")
            rate_limits[key].append(now)
            return await func(*args, **kwargs)
        return wrapper
    return decorator

@server.tool()
@rate_limit(max_calls=10, per_seconds=60)
async def expensive_operation(data: str) -> str:
    """An expensive operation with rate limiting"""
    return f"Processed: {data}"

5. Read-Only Mode for Sensitive Resources

If a tool only needs to read data, don’t expose write operations:

@server.tool()
async def read_document(path: str) -> str:
    """Read a document (read-only)"""
    safe = safePath(path)
    with open(safe, 'r') as f:
        return f.read()

# Don't add a write_document tool unless needed

6. Audit Logging

Log all tool calls and resource accesses:

import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("mcp-server")

@server.tool()
async def delete_user(user_id: str) -> dict:
    """Delete a user from the system"""
    logger.warning(f"DELETE USER requested: {user_id}")
    result = database.delete_user(user_id)
    logger.info(f"User {user_id} deleted by {os.environ.get('USER', 'unknown')}")
    return {"deleted": user_id, "status": "success"}

7. Network Restrictions

For MCP servers that connect to external services:

  • Use private VPC endpoints for AWS services
  • Whitelist specific IP ranges for API calls
  • Never expose MCP servers directly to the internet (they communicate over stdio)

8. Regular Audits

Periodically review your MCP configuration:

  • Which servers are running?
  • What permissions do they have?
  • Are there any unused servers?
  • Are API keys rotated regularly?

Security Checklist

  • No secrets in source code or config files
  • IAM policies follow least privilege
  • Input validation prevents injection attacks
  • Rate limiting is configured
  • Audit logging is enabled
  • Network access is restricted
  • Secrets are rotated at least every 90 days
  • Unused servers are removed from config

Next Steps