Seven MCP Server Design Mistakes (And the Fixes)
One: mirroring your API surface
The most common mistake is generating one tool per REST endpoint. Thirty endpoints become thirty tools, and selection accuracy collapses because half of them are near-identical and the model has to disambiguate `get_order`, `get_order_items` and `get_order_shipment` on every turn.
Your API was designed for a programmer who reads documentation once and then knows. A model re-decides on every call, with only the descriptions in front of it.
Raise the level of abstraction instead. One `get_order_summary` that joins the three server-side returns a complete answer in one call, costs one round trip instead of three, and removes two selection decisions.
Two: unbounded return values
A tool that returns a full table, an entire document, or an unpaginated list can consume the whole context window in a single call. The turn does not fail cleanly — it degrades, because everything else in the window gets crowded out.
Cap every collection return, and always report what you truncated. A model that knows it saw 50 of 4,000 rows will narrow its query. A model that thinks it saw everything will confidently answer from a partial set, which is the more dangerous failure.
MAX_ROWS = 50
MAX_CHARS = 20_000
def bounded(rows: list, total: int) -> dict:
out = rows[:MAX_ROWS]
return {
"ok": True,
"returned": len(out),
"total_matches": total,
"truncated": total > len(out),
"rows": out,
}Three: leaking internals in errors
A raw stack trace or database error tells the model nothing actionable and may expose schema, file paths or credentials to whoever is watching the transcript. Remember that the client is somebody else's agent and the transcript may be visible to their user.
Translate into an error the model can act on and a hint that names the next step — the contract from the error-handling post in this series. Log the raw detail server-side where it belongs.
# Leaks schema, path and driver internals into someone else's transcript
return {"ok": False, "error": str(exc)}
# Actionable, and safe to show
log.exception("query failed") # full detail stays server-side
return {"ok": False,
"error": "No column named 'customer_name'.",
"hint": "Available columns: id, account_id, full_name, email."}Four: thin descriptions
"Gets order data." That is the whole description, and it is why the model calls the wrong tool. The description is the only thing distinguishing this from the three neighbouring tools, and it is doing none of that work.
Three sentences: what it does, when to use it, and — the one almost everyone omits — when not to. The negative clause draws the boundary against the adjacent tool in a way positive description cannot. The earlier post on tool schemas goes deeper, and every word of it applies across MCP.
Five: no pagination on anything that grows
Truncation without a cursor is a dead end: the model can see there were more results and has no way to reach them. It will usually respond by re-running the same query with a slightly different filter, which burns turns and often makes things worse.
Give anything list-shaped an explicit cursor and say in the description how to use it. A model handles cursor pagination well when it can see the mechanism.
@mcp.tool()
def list_orders(status: str, cursor: str | None = None) -> dict:
"""List orders with a given status, 50 at a time.
Args:
status: One of 'pending', 'shipped', 'delivered'.
cursor: Pass `next_cursor` from a previous call to get the next page.
Omit for the first page.
"""
rows, nxt = db.page(status, after=cursor, limit=50)
out = {"ok": True, "returned": len(rows), "orders": rows}
if nxt:
out["next_cursor"] = nxt # the model can now continue
return outSix: destructive tools sitting next to read tools
If `delete_order` is registered alongside `get_order`, then every client that connects gets deletion, and the only thing standing between a misread instruction and data loss is the model's judgement.
Separate the surfaces. Read-only by default; destructive operations behind an explicit flag, a separate server, or a confirmation parameter the caller must set deliberately. This is not paranoia — a tool a model can call is a tool that will eventually be called under a misunderstanding, including one injected by content the model was asked to summarise.
ALLOW_WRITES = os.environ.get("MCP_ALLOW_WRITES") == "1"
@mcp.tool()
def cancel_order(order_id: str, confirm: bool = False) -> dict:
"""Cancel an order. Irreversible.
Requires confirm=True. Do not call speculatively - if the user has not
explicitly asked to cancel this specific order, ask them first.
"""
if not ALLOW_WRITES:
return {"ok": False, "error": "This server is read-only.",
"hint": "Cancellation is not available through this tool."}
if not confirm:
return {"ok": False, "error": "confirm=True is required.",
"hint": "Check with the user, then call again with confirm."}
...Seven: assuming one caller
A server written against one agent tends to bake in assumptions — that a customer was resolved earlier, that calls arrive in a particular order, that some state persists between them. MCP servers are stateless from the client's perspective and may be called by several clients concurrently.
Make each tool independently callable. If a tool needs a customer ID, take it as a parameter rather than assuming an earlier call established it. If two tools must be used in order, say so in both descriptions rather than relying on it.
The review checklist
Before publishing a server, walk the list. Are tools named for jobs rather than endpoints? Is every collection bounded and does it report truncation? Do errors carry a hint and hide internals? Does every description say when not to use the tool? Can the model page through long results? Are destructive operations behind an explicit gate? Is each tool callable on its own?
Then the count. If you are past a dozen tools, look for consolidation — variants that could be one tool with a parameter, or a split into focused servers. Selection accuracy is the constraint that quietly governs how useful your server is, and it is the one nobody measures.