MCP Server Best Practices
Your server works. You wrapped an API in article 05: Wrapping an API as an MCP Server, the tools show up, and the model calls them. Then it picks the wrong tool for the job, passes an argument that makes no sense, and the whole thing feels flaky.
That gap between “runs” and “trustworthy” is not about more features. It is about six habits: writing tools the model can pick correctly, never trusting the arguments it sends, guarding anything destructive, returning errors the model can recover from, keeping responses small, and testing the server the way a client does.
Tool descriptions are your API contract with the model
The model does not read your code. It reads the name and description of each tool and picks from those. If two tools sound the same, or a description is vague, the model guesses. That guess is your bug.
Treat the description as the whole contract. State what the tool does, when to use it, and just as important, when not to. Name the units and formats it expects. If a tool is destructive or slow, say so in the text; the model will factor that in.
@mcp.tool()
def search_orders(customer_email: str, status: str = "any") -> str:
"""Find orders for one customer by email.
Use this to look up a customer's order history before answering
billing questions. Returns up to 20 recent orders, newest first.
Do NOT use this to change an order; use update_order for that.
Args:
customer_email: full email address, e.g. "sam@example.com"
status: one of "open", "shipped", "cancelled", or "any"
"""
Notice the “Do NOT use this to change an order” line. That single sentence stops the model from reaching for a search tool when it meant to mutate something. Descriptions that disambiguate between neighboring tools do more for reliability than any amount of prompt engineering on the host side.
Why does phrasing in a description change which tool the model picks?
The model chooses tools the same way it chooses words: it scores each option against the conversation so far. The schema, name plus description plus argument docs, is the only signal it has about what a tool is for.
A description that overlaps with another tool splits the model’s confidence, and it may pick either one. A description that names a clear boundary (“use this for reads, not writes”) gives it a sharp edge to decide on. You are not writing docs for a human; you are writing the classifier’s features.
Never trust model-generated arguments
The arguments to your tool come from a language model interpreting a user’s request. They are user input with an extra layer of unpredictability on top. The model can hallucinate an ID, invent a field, swap two arguments, or pass a string where you expected an enum.
Validate before you act. Every time. Treat the boundary of your tool exactly like the boundary of a public HTTP endpoint.
@mcp.tool()
def refund_order(order_id: str, amount_cents: int) -> str:
"""Issue a partial or full refund on an order."""
if not order_id.startswith("ord_"):
return "Error: order_id must look like 'ord_...'. Got: " + repr(order_id)
if amount_cents <= 0 or amount_cents > 100_000:
return f"Error: amount_cents must be between 1 and 100000. Got {amount_cents}."
order = db.get_order(order_id)
if order is None:
return f"Error: no order found with id {order_id}."
# ...only now do the real work
Type hints in the SDK give you a first layer for free: declare amount_cents: int and the client rejects a non-integer before it reaches you. But type is not the same as valid. A negative refund is a valid integer and a real problem. Check ranges, formats, and existence yourself.
The most dangerous case is a tool that takes something which becomes a query, a path, or a shell command. A model-supplied string flowing into SQL is an injection waiting to happen. Use parameterized queries, allowlists for paths, and never interpolate a model argument into a command line.
Least privilege, and treat destructive tools as special
Every tool you expose is something the model can decide to call. The question before adding one is not “can I build this?” but “am I comfortable with the model doing this on its own?”
Default to read-only. A server that can look things up is useful and hard to misuse. The moment a tool deletes, sends, charges, or emails, the failure mode changes from “wrong answer” to “wrong action that already happened.”
For anything destructive, three habits keep you safe:
- Scope the credentials. Give the server a token that can do only what its tools need. A read-only API key for a read-only server. No admin key “just in case.”
- Make the destruction explicit in the tool. A tool named
delete_all_recordswith a description that says it is irreversible is honest. A tool namedcleanupthat quietly deletes is a trap. - Gate it. Require a confirmation argument the model has to set deliberately, or design the flow so a human approves the action in the host. Do not let one fuzzy sentence trigger an irreversible call.
@mcp.tool()
def delete_project(project_id: str, confirm: bool = False) -> str:
"""Permanently delete a project and all its data. Irreversible.
You MUST set confirm=True to proceed. If the user has not
clearly asked to delete, call get_project first and ask them.
"""
if not confirm:
return "Refused: set confirm=True only after the user explicitly confirmed deletion."
# ...
flowchart LR
A["Model wants<br/>to call a tool"] --> B{"Destructive?"}
B -->|"No"| C["Run it"]
B -->|"Yes"| D{"confirm set<br/>and scoped token?"}
D -->|"No"| E["Refuse with<br/>a clear reason"]
D -->|"Yes"| C
The confirmation flag is not real security; a determined model will set it. It is a speed bump that turns a one-word slip into a two-step decision, and it gives the host a natural place to ask the user. Pair it with credentials that physically cannot do more than intended, and your defense no longer depends on the model behaving.
Write errors the model can recover from
When a tool fails, the model reads your error and decides what to do next. An opaque error is a dead end. A specific one is an instruction.
Compare these two responses to the same failure:
Error: 400 Bad RequestError: start_date is after end_date. Pass start_date=2026-01-01, end_date=2026-03-01 with start before end.
The first tells the model nothing; it will retry the same broken call or give up. The second tells it exactly what was wrong and what a valid call looks like, so it can fix the arguments and try again. Good tool errors read like a helpful 422 response: what went wrong, and what to try instead.
Return errors as normal tool results, not by crashing the server. On stdio, an uncaught exception can take down the connection (see the lifecycle in article 02: MCP Architecture). Catch expected failures, turn them into a clear text result, and let the model handle it.
try:
resp = api.charge(customer, amount_cents)
except RateLimited as e:
return f"Error: rate limited by the payment API. Retry after {e.retry_after}s."
except CustomerNotFound:
return f"Error: no customer '{customer}'. Call list_customers to find the right id."
Keep responses small
Every token you return costs money and eats context. A tool that dumps a raw 40 KB JSON payload back into the conversation is expensive, and it buries the useful part under fields the model does not need.
Return the minimum that answers the question, shaped for a reader. If the underlying API returns fifty fields per record, pick the five that matter. If it returns a hundred records, return a count and the top few, with a way to fetch more.
@mcp.tool()
def list_open_incidents() -> str:
"""List currently open incidents, most severe first."""
incidents = api.get_incidents(status="open")
lines = [
f"{i.id} {i.severity} {i.title} (opened {i.opened_at:%Y-%m-%d})"
for i in incidents[:10]
]
header = f"{len(incidents)} open incidents. Showing top 10:"
return header + "\n" + "\n".join(lines)
This is a design choice, not laziness. The full incident object has stack traces, timelines, and metadata. The model asked “what is on fire?” and ten one-line summaries answer it. If the user drills into one, that is a second, narrower tool call.
Why not just return everything and let the model sort it out?
Two reasons: cost and attention.
Every field in your response is tokens the host pays for on every turn that keeps the result in context. A chatty tool called ten times can dominate the bill.
Attention is the subtler one. Models get less reliable as the relevant fact sinks into a wall of irrelevant text. A tight result keeps the signal on top. Returning the whole payload does not make the model smarter; it makes it work harder to find the part you already knew mattered.
Test your server the way a client does
The host is a bad place to debug. It hides the wire, swallows errors, and adds its own quirks. Before you connect a server to anything, exercise it directly.
The fastest smoke test is to speak the protocol by hand. Launch the server and pipe it a raw initialize request, then confirm you get one clean JSON-RPC response and nothing else on stdout.
# One clean line back on stdout means the handshake works.
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}' \
| python server.py
If you see anything other than a single valid response, that is your bug: a stray print, a crash, or a logging line on stdout instead of stderr. For anything past the handshake, the MCP Inspector gives you a UI to list tools, call them with real arguments, and read the raw responses. Get every tool green there before you touch the host.
Common beginner mistakes
- Vague or overlapping descriptions: two tools that sound alike make the model guess. Name the boundary between them.
- Trusting model arguments: validate ranges, formats, and existence; a valid type is not a valid value.
- Destructive tools with no gate: a
cleanuptool that silently deletes is a trap. Make it explicit and require confirmation. - Opaque errors:
400 Bad Requesttells the model nothing. Say what failed and what to try. - Returning whole payloads: fifty fields when five answer the question wastes tokens and buries the signal.
- Only testing through the host: the host hides the wire. Call the server directly first.
Questions you will face in production
“How do I stop the model from calling the wrong tool?” Fix the descriptions before you touch anything else. Give each tool a clear “use this when” and “do not use this for” line that separates it from its neighbors. If two tools genuinely overlap, merge them or make the split obvious in the names. The model picks from text; make the text decisive.
“Should destructive actions be MCP tools at all?” Sometimes yes, but never casually. If a mistake is cheap to undo, a gated tool with a confirmation flag and a scoped credential is fine. If a mistake is irreversible or expensive, prefer a design where the human approves in the host, or split the action so the model can only stage it, not commit it. Default read-only and add write tools deliberately.
“My tool works in the Inspector but fails in Claude Desktop. What now?” The Inspector proves your logic and wire format are fine, so the problem is the host boundary: usually the launch command, working directory, or environment variables the host uses to start your server. Check that the host runs the exact command you tested, from a directory where your paths resolve, with the same env. Confirm all logging still goes to stderr, not stdout.
What to remember
- The model picks tools from their descriptions; write them to disambiguate, including when not to use a tool
- Model arguments are untrusted input; validate format, range, and existence before acting
- Default to read-only; make destructive tools explicit, gated, and backed by scoped credentials
- Errors are instructions to the model; say what went wrong and what to try, and return them as results, not crashes
- Small structured responses save tokens and keep the signal on top; do not dump whole payloads
- Test by speaking JSON-RPC directly and with the Inspector before connecting to any host
What to study next
You now have a server worth trusting. The next step is getting it in front of a real host: how to register it with article 07: Connecting to Claude Desktop, Cursor, and Others, where the config lives, and how to debug the launch problems that only show up once a host is starting your process. If a tool misbehaves there, come back to the descriptions and errors sections here first.
Further reading
- MCP spec: Tools. The tool definition, including how names, descriptions, and input schemas are declared.
- Model Context Protocol: Security best practices. The official guidance on trust, consent, and least privilege for servers.
- MCP Inspector. The reference tool for calling your server directly and reading raw responses.
- OWASP: Input validation cheat sheet. Why you validate at every boundary, applied here to tool arguments.
Where this article comes from. This is a synthesis of the MCP specification and common practice in AI engineering as of 2026, not a citation of any single paper. The sources above are where the mechanics come from. If you find an error or have a better source for a claim, the article gets fixed within a day, send me a note.