MCP Security Best Practices
MCP servers often have direct access to databases, file systems, internal APIs, and sensitive user data. A poorly secured MCP server is not just a vulnerability — it's an open door for automated exploitation by anyone who can reach the endpoint. These 10 practices cover the essential baseline every MCP server should meet.
Section 1: Why MCP Security Matters
Traditional web APIs are consumed by human developers who read documentation, handle errors, and can recognise anomalies. MCP servers are consumed by AI agents that call tools automatically, at scale, often with elevated permissions.
This creates a different risk profile:
- Automated abuse at scale: A single misconfigured endpoint can be discovered and exhausted by bots in minutes.
- Elevated permissions: MCP tools commonly have write access to data stores, email systems, or internal services — making exploitation more impactful.
- Prompt injection propagation: Malicious content returned by one tool can hijack an AI agent's subsequent actions across a session.
- Credential blast radius: If your MCP server exposes an API key, that key may grant access to third-party services far beyond your server itself.
Section 2: The 10 Essential Practices
1. Always Validate Tool Inputs
What: Reject tool calls that contain unexpected parameter types, values outside expected ranges, or patterns matching injection payloads.
Why: AI models generate tool arguments from natural language. Malicious content in user input can produce tool arguments containing SQL injection, path traversal, or shell metacharacters. Validating at the MCP layer protects all downstream systems.
# Validate before passing to any downstream system
def search_records(query: str, limit: int) -> list:
if not isinstance(query, str) or len(query) > 200:
raise ValueError("Invalid query")
if not isinstance(limit, int) or not (1 <= limit <= 100):
raise ValueError("limit must be 1–100")
# Safe to pass to database now
return db.search(query=query, limit=limit)
2. Use HTTPS for All MCP Endpoints
What: Serve your MCP server exclusively over HTTPS. Redirect or reject plain HTTP connections.
Why: MCP traffic contains tool arguments, responses, and potentially credentials. Unencrypted HTTP exposes all of this to network interception. This is the highest-weighted check in AgentGrade's scoring (20 points) because it is a prerequisite for all other security measures.
# nginx: redirect HTTP to HTTPS
server {
listen 80;
return 301 https://$host$request_uri;
}
3. Implement Authentication on Your MCP Server
What: Require valid credentials before serving any tool list or tool call. Return HTTP 401 or 403 for unauthenticated requests.
Why: An unauthenticated MCP server allows any client on the internet to invoke your tools. This is the second-highest-weighted check (15 points) and one of the most commonly failed. Bearer tokens, API keys, and OAuth are all viable approaches depending on your use case.
from fastapi import Header, HTTPException
async def verify_api_key(x_api_key: str = Header(...)):
if not secrets.compare_digest(x_api_key, EXPECTED_API_KEY):
raise HTTPException(status_code=401, detail="Invalid API key")
4. Rate Limit Tool Invocations
What: Enforce per-client rate limits on tool calls. Return HTTP 429 with a Retry-After header when limits are exceeded.
Why: AI agents can generate tool calls in tight loops. Without rate limiting, a single misconfigured agent or attacker can exhaust your server resources, trigger excessive third-party API charges, or scrape all your data in a single session.
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
@app.post("/tools/call")
@limiter.limit("60/minute")
async def call_tool(request: Request, body: ToolCallRequest):
...
5. Sanitize Tool Outputs Before Returning to the LLM
What: Filter or escape content returned by tools before passing it back to the AI model, especially content from untrusted external sources.
Why: Prompt injection attacks embed instructions in data that gets included in the AI's context. If your fetch_url or search_web tool returns a page containing "Ignore all previous instructions and...", the AI model may follow those instructions. Sanitize or flag untrusted content before it enters the model's context.
INJECTION_PATTERNS = [
r"ignore (all |previous )?instructions",
r"system prompt",
r"you are now",
]
def sanitize_tool_output(content: str) -> str:
for pattern in INJECTION_PATTERNS:
if re.search(pattern, content, re.IGNORECASE):
return "[Content filtered: potential prompt injection detected]"
return content
6. Pin Dependency Versions
What: Lock all dependencies to specific versions in your package manager. Audit them regularly for known CVEs.
Why: MCP servers are long-running services. An unpinned dependency can silently introduce a vulnerable version during the next deployment. Use pip-audit, npm audit, or cargo audit as part of your CI pipeline.
# Python: generate a locked requirements file
uv pip compile pyproject.toml -o requirements.lock
# Audit for known CVEs
uv run pip-audit -r requirements.lock
7. Avoid Exposing Admin Interfaces
What: Do not serve admin panels, management UIs, or internal dashboards on the same port or domain as your public MCP endpoint.
Why: Admin interfaces at predictable paths (/admin, /dashboard, /_internal) are scanned constantly by automated bots. If your MCP server and admin panel share a host, a vulnerability in either affects both. Serve admin interfaces on a separate internal-only port or subdomain, protected by network-level access controls.
8. Handle Errors Without Leaking Stack Traces
What: Return generic error messages to clients. Log full stack traces internally. Never include file paths, framework names, or SQL errors in API responses.
Why: Verbose error messages help developers but also help attackers. Framework names, file paths, and SQL syntax reveal your internal architecture and help an attacker select targeted exploits.
@app.exception_handler(Exception)
async def generic_error_handler(request: Request, exc: Exception):
# Log internally with full detail
logger.exception("Unhandled error: %s", exc)
# Return nothing useful to the client
return JSONResponse(
status_code=500,
content={"error": "Internal server error"}
)
9. Log All Tool Invocations for Audit
What: Record every tool call with the client identifier, tool name, timestamp, and (sanitized) arguments. Retain logs for at least 90 days.
Why: When something goes wrong — a data leak, unexpected tool calls, a compromised client — logs are the only way to reconstruct what happened. Without audit logs, you cannot detect abuse, investigate incidents, or demonstrate compliance.
def log_tool_call(client_id: str, tool: str, args: dict) -> None:
logger.info(
"tool_call",
extra={
"client_id": client_id,
"tool": tool,
"args_keys": list(args.keys()), # log keys, not values
"timestamp": datetime.utcnow().isoformat(),
}
)
10. Run Regular Security Scans
What: Continuously monitor your MCP server's observable security posture. Check that HTTPS is enforced, auth is required, headers are set, and no sensitive files are exposed.
Why: Security posture drifts. A deployment change can accidentally remove authentication. A new route can expose a sensitive path. Certificate renewals can fail. Regular automated scanning catches regressions before attackers do.
AgentGrade scans publicly-accessible MCP servers on a regular schedule and grades them A–F on 14 observable security checks. Check your server's grade →
Section 3: Common Vulnerabilities We Find
AgentGrade's scanner runs 14 passive security checks against every MCP server it can reach. Based on our scanning data, the most commonly failed checks are:
| Check | Weight | Failure Rate |
|---|---|---|
| Authentication Required | 15 pts (Critical) | High — most remote MCP servers have no auth |
| CORS Wildcard Policy | 8 pts (High) | High — wildcard CORS is common in development configs left in production |
| Content-Security-Policy Header | 3 pts (Low) | Very high — CSP is rarely set on API-only servers |
| HTTP Strict Transport Security | 3 pts (Low) | Very high — HSTS requires deliberate configuration |
| Server Version Disclosure | 5 pts (Medium) | Moderate — default server configs expose version strings |
The good news: most of these are configuration changes, not code rewrites. An F-graded server can reach a B by adding authentication, fixing CORS, and setting three security headers — work that can often be completed in an afternoon.