Why Most MCP Servers Break in Production
I've built over 25 MCP servers. Some for personal projects, a bunch for clients through Aether Neural. Most of the early ones had the same three failure modes — and I keep seeing the same mistakes in repos people share.
This post is about those three mistakes and the patterns that replaced them. If you're just starting with MCP, start with the FastMCP quickstart in the docs first. This is for people who have a server running and are trying to figure out why it gets weird under load.
All examples use FastMCP. The patterns apply to any MCP implementation but the code samples are Python 3.11+ with FastMCP 0.9.x. The mcp-server-template repo on GitHub has the full production-ready version.
Mistake 1: Flat, Undescribed Tool Schemas
The most common mistake is treating the tool description as a formality. Agents use your description to decide whether to call the tool and how to construct the input. A bad description means the agent either calls it wrong or ignores it entirely.
Here's what most people write:
@mcp.tool()
async def search_documents(query: str) -> str:
"""Search documents."""
...
Here's what actually works. The description is prose your agent reads:
@mcp.tool()
async def search_documents(
query: str,
limit: int = 5,
collection: str = "default"
) -> dict:
"""
Semantic search over the document collection.
Use this when you need to find documents, notes, or stored content
by meaning rather than exact keyword. Returns ranked results with
relevance scores.
Args:
query: Natural language search query. Be specific.
limit: Max results to return (1-20). Default 5.
collection: Which collection to search. Options: 'default',
'archive', 'client-notes'. Default: 'default'.
Returns:
dict with 'results' list and 'total_found' count.
"""
...
Mistake 2: Silent Failures
When a tool fails silently — returns an empty string, returns None, raises an uncaught exception — the agent has no idea what happened. It will either retry forever or confabulate a result and continue.
Both outcomes are bad. Retrying forever burns tokens. Confabulation corrupts downstream state.
The pattern is to always return a structured result with an explicit success/failure field:
from pydantic import BaseModel
class ToolResult(BaseModel):
success: bool
data: dict | None = None
error: str | None = None
error_code: str | None = None
@mcp.tool()
async def search_documents(query: str) -> dict:
"""..."""
try:
results = await db.search(query)
return ToolResult(success=True, data={"results": results}).model_dump()
except DatabaseError as e:
return ToolResult(
success=False,
error=str(e),
error_code="DB_ERROR"
).model_dump()
Include error codes — not just messages. The agent can be instructed to handle specific codes differently. "DB_ERROR" means retry later. "PERMISSION_DENIED" means stop and report. "NOT_FOUND" means try a different query.
Mistake 3: No Rate Limiting
Agents are not polite. When they find a useful tool they will call it as fast as the loop allows. Without rate limiting, one agent run can saturate a database connection pool, hit external API limits, or — in multi-agent setups — cascade failures across the whole system.
The fix is a per-tool rate limiter that returns structured feedback the agent can act on:
import asyncio
from collections import deque
import time
class RateLimiter:
def __init__(self, calls: int, period: float):
self.calls = calls
self.period = period
self.timestamps = deque()
def check(self) -> bool:
now = time.time()
while self.timestamps and self.timestamps[0] < now - self.period:
self.timestamps.popleft()
if len(self.timestamps) >= self.calls:
return False
self.timestamps.append(now)
return True
Production Patterns That Actually Work
Beyond the three mistakes, here's what I consistently reach for in production MCP servers:
- Structured logging with structlog — every tool call gets a trace ID
- Health check endpoint at
/healthwith dependency status - Graceful shutdown handling — finish in-flight requests before closing
- Connection pooling for any database or external API
- Tool-level timeout with
asyncio.wait_for()
Full Production Template
The mcp-server-template repo has the complete implementation. It wires together everything in this post: typed schemas, structured errors, rate limiting, logging, health checks, and graceful shutdown. It's the file I copy at the start of every new server.
Clone it, rename the tool functions, update the descriptions, and you have a production-ready server in about 10 minutes.