Security and Trust Boundaries
Authenticating and authorizing tool calls, defending against prompt injection through tool input, and rate limiting a server against abuse.
Every tool call needs a trust decision
A tool that touches real data or takes real action is only as safe as the weakest check standing between "the model requested this" and "the server did it." Three checks matter most: who is making this call, are they allowed to do this specific thing, and is the input itself safe to act on.
Authentication and authorization
Continuing the support desk server from the last chapter, every sensitive call should resolve to a real, authorized user before it does anything:
class SupportDeskServer(FastMCP):
def __init__(self):
super().__init__("Support Desk")
self.auth = AuthManager()
self.permissions = PermissionChecker()
async def _authenticate(self, context: Context) -> User:
token = context.headers.get("Authorization", "").removeprefix("Bearer ")
if not token:
raise AuthenticationError("Missing credentials")
user = await self.auth.validate_token(token)
if not user:
raise AuthenticationError("Invalid or expired token")
return user
@mcp.tool()
async def escalate_ticket(self, context: Context, ticket_id: str, reason: str) -> dict:
"""Escalate a ticket to a senior agent"""
user = await self._authenticate(context)
if not await self.permissions.can(user, "escalate", ticket_id):
raise AuthorizationError(f"{user.id} cannot escalate ticket {ticket_id}")
await self.audit_log.record(user, "escalate", ticket_id, reason)
return await self._perform_escalation(ticket_id, reason)
Authentication answers "who is this." Authorization answers "can they do this specific thing." Skipping straight to executing the operation after only checking that a token exists is a common shortcut, and it's the one that turns into an incident later.
Prompt injection through tool input
Text that flows into a tool, whether typed by a user or pulled from a resource, can contain something that reads like an instruction rather than data: "ignore previous instructions," "disregard the system prompt," and similar. A server should treat tool input as data, never as instructions to itself:
import re
INJECTION_PATTERNS = [
r"ignore\s+(all\s+)?previous\s+instructions",
r"disregard\s+(the\s+)?system\s+prompt",
r"override\s+safety",
]
def sanitize_tool_input(text: str) -> str:
"""Strip patterns that look like an attempt to redirect the model's behavior"""
for pattern in INJECTION_PATTERNS:
text = re.sub(pattern, "", text, flags=re.IGNORECASE)
return text
This isn't a complete defense on its own; pattern matching catches the obvious cases, not a determined attacker. The more durable protection is architectural: a tool should only ever be able to perform the specific action it was designed for, so even a successfully injected instruction has nowhere useful to go.
Rate limiting
A single misbehaving client, or a legitimate one in a retry loop, can overwhelm a server without any malicious intent at all:
from collections import defaultdict
from datetime import datetime
class RateLimiter:
def __init__(self, calls_per_minute: int = 60):
self.limit = calls_per_minute
self.calls: dict[str, list[datetime]] = defaultdict(list)
def allow(self, user_id: str) -> bool:
now = datetime.utcnow()
recent = [t for t in self.calls[user_id] if (now - t).seconds < 60]
self.calls[user_id] = recent
if len(recent) >= self.limit:
return False
recent.append(now)
return True