Originally published on Loop & Retry — field notes on building LLM agents that survive production.
If you've connected an agent to a ticketing system, a database, and a search index by hand, you've written the same 200 lines three times: a client for the API, a translation layer that turns its endpoints into something a model can call as a tool, and error handling that's subtly different for each because each API fails in its own way. MCP exists to kill that duplication. It doesn't exist to make the tools on the other end good — that part is still on you, and it's where most MCP servers actually break.
What MCP is
MCP — the Model Context Protocol — is a JSON-RPC-based protocol, released by Anthropic in late 2024, that standardizes how an LLM application talks to something that can give it tools and context. Before it, every agent host wrote a bespoke integration per data source: a Slack integration, a GitHub integration, a Postgres integration, each with its own auth, its own request shape, its own error handling. Wire up three hosts to three data sources by hand and you've written nine integrations. MCP turns that into three-plus-three: one server per data source, speaking one protocol, usable by any compliant host.
The vocabulary has two sides. A host is the application driving the model — Claude Desktop, Claude Code, Cursor, VS Code, or an agent runtime you built yourself. Inside the host, a client holds one connection to one server — a separate process, local or remote, that exposes capabilities over the protocol. A single host commonly holds many client connections open at once: a filesystem server, a database server, a Slack server, all in the same agent session.
A server exposes three kinds of capability. Tools are functions the model can call with arguments and get a result back — the direct analog of function calling in a normal LLM API. Resources are readable content the host can attach to context without a tool call — a file, a document, a database schema — closer to a GET than an RPC. Prompts are reusable prompt templates a user can select, parameterized by arguments, so a server can ship "the right way to ask about this data" alongside the data itself. Most of what breaks in practice is in the first category, because tools are the only one of the three that executes a side effect.
Discovery is the other piece that matters for what comes next. A host doesn't need documentation to know what a server offers — it calls tools/list and gets back every tool's name, description, and JSON Schema input shape, at runtime, in the same session. That's convenient. It's also exactly why the next section matters: nothing forces that schema to be good, and a model deciding what to call from a list it just discovered has no other source of truth to correct a bad one.
MCP vs. a plain API
Here's the question worth answering directly, because it's the one people actually type: is MCP just an API with extra ceremony? No — and the difference is about who's on the other end of the call, not the wire format.
A REST API is designed for a client written once by a human who read the docs, decided what each endpoint means, and hardcoded that decision into a codebase. If GET /tickets?filter=open is ambiguous — does filter take a string, a query language, a JSON blob? — the human engineer resolves the ambiguity once, writes the correct call, and it stays correct forever. The API's design quality mostly shows up in developer time: a confusing endpoint costs you an afternoon reading docs, once.
An MCP tool is designed for a client that reads the shape fresh, every call, and decides what to send based on a description and a schema with no human in that specific decision. There is no afternoon of doc-reading that happens once and then never again — the model re-derives "what does filter mean" from the same words every single invocation, and it does so under uncertainty, because natural-language descriptions don't fully constrain behavior the way a human's internalized understanding does. An ambiguous MCP tool doesn't cost you an afternoon. It costs you a wrong call in production, silently, on whichever invocation happened to land on the wrong interpretation.
This is the same shift this blog has been describing since designing tools an LLM won't misuse: a tool schema is a contract with a caller that guesses. MCP doesn't introduce that problem — Claude's own function-calling API has the same contract — but MCP is where the problem shows up at scale, because MCP servers get built once and then plugged into hosts and agents their authors never anticipated. A REST API written for one internal frontend can get away with a slightly-off filters string because the one client it has learned to work around it. An MCP server has no fixed client. It gets composed into sessions with tools it's never met, driven by a model reading its schema cold, and the badly-designed tool that "worked fine" for its original author breaks the moment a different agent, with a different task, calls it the way its schema actually implies.
The other structural difference is discoverability plus composition. A REST API is one service with one set of endpoints, and a client talks to it in isolation. An MCP session is usually several servers at once — filesystem, database, Slack, your internal ticketing system — all discovered and callable in the same context, by the same model, in the same turn. That composition is the point of MCP, and it's also where a mediocre tool on one server can be driven by tainted content that arrived through a completely different server. mcp vs api, as a question, comes down to this: an API is a fixed contract for a fixed client; MCP is a runtime-discovered contract for a probabilistic caller operating inside a room full of other tools it didn't choose.
The tool-design mistakes that actually break MCP servers
Almost every broken MCP server I've seen breaks the same handful of ways, and all of them are a REST-API habit that doesn't survive the trip.
1. Wrapping the REST endpoint 1:1 instead of designing for the caller. The fastest way to ship an MCP server is to mirror your existing API: one tool per endpoint, same parameter names, same opaque IDs, same free-text filter string your frontend team memorized years ago.
# BAD: a direct passthrough of the REST endpoint's own shape
@mcp.tool()
def get_tickets(filter: str, cursor: str = None) -> str:
"""Get tickets. filter is passed to the API."""
return api.get(f"/tickets?filter={filter}&cursor={cursor}")
filter is a string of what, exactly? Whatever syntax the original REST client's authors memorized and never wrote down, because they never had to — they were the ones who wrote it. The model has no such memory, so it invents a syntax, and you parse whichever one it picked. This is the identical failure from designing tools an LLM won't misuse: enums instead of free strings, bounded ranges instead of open ones, a name and description that state the object. MCP doesn't add a new fix here — it just means you owe that translation layer explicitly, because the wire format won't do it for you.
2. Raising instead of returning — so a tool failure looks like a transport failure. MCP tool results carry a content array and an isError flag; a proper failure is a normal result with isError: true and an actionable message. Plenty of servers just let the underlying API's exception propagate:
# BAD: an unhandled exception becomes a protocol-level error, not a tool result
@mcp.tool()
def create_ticket(title: str, body: str) -> str:
return api.post("/tickets", {"title": title, "body": body}) # raises on 4xx/5xx
# GOOD: failures are structured results the model can act on, not raised exceptions
@mcp.tool()
def create_ticket(title: str, body: str) -> dict:
try:
resp = api.post("/tickets", {"title": title, "body": body})
except ApiError as e:
return {
"isError": True,
"content": [{"type": "text", "text":
f"Ticket creation failed: {e.reason}. "
f"Retry is safe — this call did not create a ticket."}],
}
return {"content": [{"type": "text", "text": f"Created ticket {resp['id']}."}]}
When the exception propagates raw, the model doesn't see "your ticket title was empty" — it sees the session degrade, or a generic tool-call failure with no actionable content, and its next move is a guess. An isError result with a real message is the MCP-specific instance of the error-as-prompt principle: whatever comes back is the next thing the model reads and reasons from, so write it for that reader.
3. No idempotency on the tools that write. Nothing about MCP changes how often a host retries a stalled tool call — if anything, a multi-server session gives the model more surface area to reissue a call it thinks silently failed. create_ticket, send_message, and charge_customer exposed as MCP tools have the exact same retry exposure as any other tool call, and the fix is the same one from idempotency keys for agents: give the tool an idempotency key, check it before executing, return the cached result on a repeat. An MCP server that skips this creates two tickets, two messages, or two charges from one intent, and nothing about the protocol will catch it for you.
4. Returning the whole resource instead of sizing the response to the context budget. A tool result isn't just a return value — it's tokens that land directly in the model's context window for the rest of the session. A read_file or get_logs tool that dumps 40,000 tokens back because "the data's all there" has technically succeeded and practically wrecked the budget for everything after it. Paginate, summarize, or truncate with an explicit "there's more, call again with offset=N" — the same discipline as treating the context window as a cache, applied at the point where content enters it.
5. No least-privilege boundary between servers in the same session. This is the mistake that's genuinely new to MCP rather than inherited from REST, because REST clients don't usually sit in a room with four other APIs' worth of untrusted data. A session with a fetch_webpage server and a send_email or run_sql server means content one server returns can flow straight into a tool call on a different server — the exact shape of the injection problem, except now the untrusted source and the privileged sink are two servers that have never heard of each other. Scope which servers get which tools per task, and don't hand a privileged write tool to a session that also holds an open-ended content-fetching one "just in case."
A worked example, and what's actually different here
Put the fixes together and the ticket-creation tool from above becomes:
@mcp.tool()
def create_ticket(title: str, body: str, idempotency_key: str) -> dict:
"""Create a support ticket. idempotency_key must be a stable ID for this
logical ticket (e.g. derived from the triggering event) — reusing it
returns the original ticket instead of creating a duplicate."""
existing = db.find_by_key(idempotency_key)
if existing:
return {"content": [{"type": "text",
"text": f"Ticket {existing.id} already exists for this key."}]}
try:
ticket = api.post("/tickets", {"title": title, "body": body})
except ApiError as e:
return {"isError": True, "content": [{"type": "text",
"text": f"Ticket creation failed: {e.reason}. Retry is safe."}]}
db.record(idempotency_key, ticket["id"])
return {"content": [{"type": "text", "text": f"Created ticket {ticket['id']}."}]}
Named clearly, schema-constrained, structured on failure, safe to retry — none of that is MCP-specific. It's the same discipline this blog has been arguing for since the first tool-design post. What MCP actually changes is the environment the tool has to survive: it will be discovered cold by hosts its author never met, composed into sessions with servers it's never seen, and called by a model that has only the schema in front of it to go on. Most MCP writing online is "how to stand up a server" — the protocol handshake, the SDK boilerplate, the client config. Very little of it is "why the server you stood up produces flaky agent behavior in production," which is the actual failure mode once the wiring works and real traffic starts hitting it.
The checklist is short:
- Don't mirror the REST endpoint's shape. Design the schema for a caller that reads it cold, not for a client that memorized the quirks.
-
Fail with a structured
isErrorresult, never a raw exception. The failure is a prompt; write it as one. - Every mutating tool needs an idempotency key. MCP doesn't add retries, but it doesn't remove them either.
- Size every response to the context budget, not just to correctness. A correct answer that's 40,000 tokens is still a bad tool result.
- Treat every other server in the session as a possible taint source. Least-privilege the tools that write, especially in a session that also holds a tool that fetches.
None of this is a reason to avoid MCP — the standardization is a real win over hand-rolled integrations, and it's why Claude Code, Claude Desktop, and most other agent hosts converged on it. It's a reason to stop treating "it responds correctly to tools/call" as the finish line. The protocol handles the wire format. The reliability is still entirely on the server you wrote.
Top comments (0)