Designing an MCP server a model can actually drive
Table of Contents
An MCP server exposes your system to someone else’s AI. That inversion is the whole point, and it is the thing most implementations miss.
You are not building an API for a developer who reads documentation. You are building one for a reader that sees your tool list fresh every session, has no screen to check, cannot ask a colleague, and has to pick the right call on the first attempt from the tool name and description alone.
Design for that reader and the server works. Design for a developer and it does not.
Wrap the job, not the endpoint
The most common failure is a one-to-one mapping from REST endpoints to tools.
You end up with list_customers, get_customer, update_customer,
list_subscriptions, get_subscription. Technically complete. Functionally
unusable, because the model now has to orchestrate five calls to answer one
question, and each hop is a chance to pick wrong.
Wrap the job instead. find_customer_billing_state that takes an email and
returns the customer, their plan, their last invoice and whether they are in
arrears is one call, and it maps to something a person would actually ask.
The test: read the tool name out loud as a sentence starting with “I want to”. If it sounds like a task, keep it. If it sounds like a database operation, it is too granular.
Keep the tool list short
Every tool you add is a distractor for every other tool. A surface of 15 well-chosen tools outperforms 60 exhaustive ones, because the failure mode you are guarding against is not “the model cannot do the thing” - it is “the model picked the wrong thing”.
When a surface grows past what fits comfortably in one screen of context, split it by role rather than adding more. An agent doing customer support does not need the tools that move money.
Name for retrieval, describe for selection
The name is what gets scanned. The description is what breaks the tie.
Names should be verb-first, specific and unambiguous across the whole set:
refund_payment, not payment_action. Avoid two tools whose names differ by
one word - that is precisely where selection errors cluster.
Descriptions should say what the tool does, when to use it, and explicitly
when not to. That last part does more work than people expect. A line like
“Do not use this to check whether a refund already exists - use
get_payment_status for that” prevents a whole class of wrong call.
Errors are instructions, not reports
A model cannot open your logs. The error string is the entire debugging surface it has, and it will act on whatever that string implies.
400 Bad Request teaches nothing and usually triggers a blind retry with the
same arguments. Compare:
Error: 400 Bad Request
Invalid `currency`: "dollars". Expected a three-letter ISO 4217 code,
e.g. "GBP" or "USD". The account's default is "GBP".
The second one is recoverable in a single turn. Write every error as though its only job is to let the caller fix the call and try again - because that is its only job.
Distinguish the three cases clearly, because the right response differs:
- Retryable - transient, try again unchanged. Say so.
- Correctable - the arguments were wrong. Say what was wrong and what valid looks like.
- Terminal - not permitted, or impossible. Say so plainly so the model stops rather than looping.
That third one matters more than it sounds. An agent that does not know a thing is impossible will keep trying variations until something else stops it.
Auth belongs to the server
The model is not a security boundary. Never accept a caller-supplied identity claim, never expose a tool whose scope depends on the model behaving, and never put a credential in a tool argument where it will be logged in the transcript.
The server holds the credential, resolves the caller’s identity out of band, and scopes every tool to what that identity may do. If a tool can only ever be called for the authenticated account, it should not take an account ID parameter at all - a parameter that must always equal one value is an invitation to pass a different one.
Put the gate inside the tool
Anything irreversible, anything that moves money, anything published externally: the approval gate belongs in the server, not in the prompt. A prompt instruction is a preference. A server-side gate is a guarantee.
The shape that works is a two-step tool pair. prepare_refund validates,
computes the amount, and returns a summary plus a short-lived token. Nothing
has happened yet. execute_refund takes that token, and refuses unless a human
has approved it out of band.
The agent can do all the work up to the gate on its own. The gate holds regardless of what the model was told, or talked into.
Test it the way it will be used
Unit tests on the handlers tell you the server works. They do not tell you the model can drive it, and that is the property you actually care about.
Test by giving a model a realistic task and the tool list, then watch which tools it reaches for. Wrong first choices are a naming or description problem, not a model problem. Fix the surface and run it again.
Do this before shipping. The cost of a badly-shaped tool surface is paid on every single call, forever.
What to read next
- The filesystem is my database - how the memory behind these tools is actually stored.
- Auth for agents - SSO, SCIM and scoping the identity your server resolves.
- Approval gates - the design rules behind the two-step tool pair above.
- Model Context Protocol specification - the protocol itself.