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.

// Context

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:

// bad: vague description
@mcp.tool() async def search_documents(query: str) -> str: """Search documents.""" ...

Here's what actually works. The description is prose your agent reads:

// good: agent-readable description
@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:

// structured error responses
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()
// Pattern Note

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:

// simple per-tool rate limiting
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:

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.