Everyone's first move with MCP is the same: take the REST API you already have and expose one endpoint per tool. Forty routes, forty tools, done in an afternoon. And then the agent starts behaving like an intern with admin access and no context: it pulls the full customer list to find one person, calls the same tool three times because the first answer looked odd, and at some point sends an email it shouldn't have. The model isn't the problem. An API is designed for a programmer who read the docs; an MCP tool is designed for someone who decides in one second, with whatever fits in the context window.
A tool is not an endpoint
The right granularity for a tool isn't the resource, it's the intent. A programmer who wants to generate a quote knows how to chain GET /customers?email=, GET /products, POST /quotes and POST /quotes/:id/send. The agent can do that too, but every hop is another call, more tokens, more latency and one more chance to get it wrong.
The version that works groups the steps into the unit of work the business recognises:
// Instead of four CRUD tools
create_quote({
customer_email: string,
items: Array<{ sku: string; quantity: number }>,
discount_pct?: number,
})
Internally that tool makes the four calls, checks the customer exists, verifies the discount is within policy and returns the quote. The agent makes one decision instead of four, and you control the order in code instead of praying the model gets it right.
The rule I go by: if two tools are always called together in the same order, they are one tool. If a tool needs the model to invent an internal identifier, it's badly designed.
The schema is the prompt
This one takes a while to sink in: a tool's JSON schema is the prompt the model reads to decide. It isn't documentation, it's instruction. A field called status: string says nothing; an enum does:
{
status: {
type: 'string',
enum: ['draft', 'sent', 'accepted', 'rejected'],
description: 'Quote status. Use "sent" only after the user approves.',
}
}
Same goes for errors. A 400 Bad Request with an empty body leaves the model guessing, and a model that guesses retries. An error that explains what failed and what to do turns that retry into a correction:
{
"error": "discount_not_allowed",
"message": "Maximum discount for this customer is 15%. You asked for 30%.",
"next_step": "Resend with discount_pct <= 15, or request approval with request_exceptional_discount."
}
I write error messages knowing the model will read them, because the model will read them.
The agent acts on someone's behalf
This is the mistake I see most and the one that costs most. The MCP server connects to the internal API with a service account that can do everything, and the permissions of the user actually talking to the agent vanish along the way. Net result: anyone who can talk to the bot can read anything.
Tools have to run as whoever invoked them. In practice: the user's token travels with the MCP session, the server uses it to call the system underneath, and if that user can't see another branch's contracts, neither can the agent.
Then there's a category of its own: actions with side effects. Send, charge, delete, sign. Permissions aren't enough there — you need an explicit human confirmation before execution. My pattern is to split the tool in two:
prepare_quote_send({ quote_id }) // returns a summary and a confirmation token
confirm_send({ token }) // only called by the flow after a human says yes
The agent can prepare all it wants; a person presses the button.
If it isn't idempotent, you send three emails
Agents retry. By design: the connection drops, the model never sees the response, the orchestrator resends. If confirm_send isn't idempotent, the customer gets the quote three times and you find out from the sales rep.
The fix is the usual one, the same as in a payment gateway: an idempotency key. The confirmation token above doubles as that key — it is consumed once, and later calls with the same token return the first result instead of running again.
What you return costs money
A REST endpoint can return 200 KB of JSON and nobody cares. In an MCP tool that response goes into the model's context window: you pay for it in tokens, you pay for it in latency and, worse, you bury the useful bit in noise.
Return the minimum that can be acted on. For a list, the first five results with the three fields that matter plus a total: 240 so the model knows there are more. For an object, the fields someone would use to decide, not the full database row. If the agent needs the detail, let it ask in another call: that second call is cheaper than dragging the full payload through every turn of the conversation.
What I take away
Building the MCP server for GenPM, the mindset shift that paid off most was going from "what do I expose" to "what decisions do I want the model to make". Every tool is a decision you delegate. Delegate it badly and the model decides for you, using exactly the information you gave it in the schema.
If you keep three things: group by intent, write errors for the model to read, and never let a side-effecting action run without a person saying yes.