Every MCP integration that touches real business data ends up with three kinds of tool. Naming them up front, and giving each kind its own rules, prevents most of the incidents that assistant integrations cause: leaked records, duplicate orders, actions nobody asked for.
Read tools
A read tool retrieves information and changes nothing: search a catalogue, look up an order, fetch a document.
Rules the examples follow:
- Check permissions on every call. The sample API scopes nothing (it is a demo), but a real one must answer "may this account see this record?" on each request, not only at connection time.
- Return summaries, not records.
search_productsreturnsid,name,category, a formattedpriceandinStock, not the rawpriceCentsor internal fields. Assistants read results as text; smaller is clearer, and fields the user has no business seeing never leave the server. - Make pagination visible. The result carries
nextCursor, present even whennull, and the description says that a non-null cursor means the list is incomplete. Without that, an assistant will state the first page as the whole answer. - Annotate it.
readOnlyHint: truelets clients treat the tool as safe to call without asking.
Write tools
A write tool creates or changes something: add an item, create a request, place an order.
- Make it idempotent. Assistants retry. A timeout on
create_orderfollowed by a retry must return the original order, not create a second one. The examples require anidempotencyKeyargument and tell the assistant, in the description, to generate one key per user request and reuse it on retry. The sample API honours the key and answers409if the same key arrives with a different payload. - Confirm intent before calling. The description says to confirm product and quantity with the user first. That is cheaper than a confirmation flow inside the tool for actions that are reversible.
- Annotate it.
idempotentHint: true,destructiveHint: false.
Guarded actions
A guarded action is a write with real consequences: cancel a booking, delete a record, change billing.
- Refuse without confirmation.
cancel_ordertakes aconfirmboolean. Called without it, the tool fetches the order and returns an error whose text shows what would be cancelled and tells the assistant to ask the user, then call again withconfirm: true. Only then does it act. - Why an error, not a result? Because an
isErrorresult is the one thing every client surfaces to the model as "this did not happen". A success result with a "please confirm" message gets summarised as success. - Annotate it.
destructiveHint: true.
Errors that say what to do next
Every example maps upstream outcomes to four messages, because an assistant needs to know which of four things to do:
- Invalid input: "Adjust the arguments and call again."
- Not found: "Do not retry with the same identifier; ask the user to check it."
- Conflict: "Do not retry automatically; tell the user what happened."
- Unavailable: "Wait before retrying, and tell the user."
The mapping lives in one small function per language (explain in TypeScript, _explain in Python, Explain in C#, explain() in PHP).
The shared interface
All four example servers expose exactly this, which is what the conformance test checks:
search_products(query?, category?, limit=5, cursor?), read-only, returns{ items[], nextCursor }.get_product(productId), read-only, returns one summarised product with its description.create_order(productId, quantity, idempotencyKey), idempotent write, returns the order.cancel_order(orderId, confirm=false), guarded, refuses untilconfirmis true.
Parameter names are camelCase in every language, including Python, because the interface is the product: an assistant sees one schema, whichever language served it.