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_products returns id, name, category, a formatted price and inStock, not the raw priceCents or 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 when null, 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: true lets 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_order followed by a retry must return the original order, not create a second one. The examples require an idempotencyKey argument 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 answers 409 if 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_order takes a confirm boolean. 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 with confirm: true. Only then does it act.
  • Why an error, not a result? Because an isError result 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 until confirm is 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.