Write when to call the tool, not what it does

30 July 2026

There is a tool for this on the site

A tool description is not documentation. It is the entire basis on which a model decides whether to call the thing, read in competition with every other tool’s description, before the model has any idea what any of them return.

That changes what a good one looks like, and most of the advice people carry over from writing API docs makes it worse.

Capability under-triggers

{
  "name": "search_docs",
  "description": "Searches the product documentation index."
}

Accurate, and it under-triggers. The model knows what the tool does and has no statement about when doing it would be appropriate, so it falls back on inference: does this question feel documentation-shaped? Sometimes yes.

{
  "name": "search_docs",
  "description": "Searches the product documentation index. Call this whenever the user asks how to do something in the product, or references a feature by name, rather than answering from memory."
}

The second half is the part that changes behaviour.

And it works better in the tool’s description than in the system prompt. That is the counterintuitive bit, because the system prompt is where most teams put this kind of instruction. A trigger condition co-located with the tool is read at the moment the decision is being made, against that specific tool, rather than as a general rule the model has to remember to apply.

This matters more than it used to. Recent models are noticeably more conservative about reaching for tools by default, preferring to reason from what they already have. Descriptions written against older, more tool-eager models under-trigger now.

Two tools described alike are a coin flip

{ "name": "get_user",   "description": "Fetch a user record from the database by identifier." }
{ "name": "fetch_user", "description": "Retrieve a user record from the database by id." }

There is nothing here to choose on. The model picks by name similarity to the question, or by position in the list, and gets it right about half the time.

The instinct is to make each description longer and more precise. That does not help, because the problem is not insufficient information about either tool. It is the absence of any statement about which situations favour one over the other.

Write contrastively, and name the other tool explicitly:

{
  "name": "get_user",
  "description": "Fetch one user by id. Use this when you already have a specific identifier. To find a user by name or email, use find_users instead."
}

Cross-referencing feels redundant when you can see both definitions side by side in your editor. The model is not reading them the way you are: it sees a flat list where the relationship between two entries exists only if you wrote it down.

A useful test: for any two tools in your list, can you state in one sentence when you would pick each? If you cannot, the model certainly cannot, and the fix may be merging them rather than describing them better.

The list is a prompt, and you pay for it every request

Tool definitions render at the front of the prompt, ahead of the system prompt and ahead of the conversation. Two consequences.

They are a fixed per-request cost. Not per session: per request, on every turn of every conversation. The thorough descriptions you wrote to be helpful are the same bytes as the ones you wrote to be useful, and forty tools of generous prose is a standing charge.

They are the start of the cache prefix. Adding or removing one tool mid-conversation changes byte zero, which discards the entire prompt cache including the whole conversation history, not just the tool section. A server that registers tools dynamically based on context is expensive in a way that never shows up in the tool list’s own size.

Keep the set stable for the life of a conversation, and serialise it deterministically, sorted by name. An object whose key order varies between runs changes the prefix bytes and produces the same invalidation for no reason at all.

There is now a beta escape hatch on the newest models: tools declared up front with defer_loading: true can be surfaced mid-conversation via tool_addition blocks without invalidating the prefix. If you genuinely need a dynamic tool set, that is the mechanism rather than rebuilding the array.

When the answer is fewer tools

Worth separating from “write better descriptions”, because it is often the better fix.

If a model is confusing two tools, the options are:

OptionWhen it is right
Describe them contrastivelyThey are genuinely different operations with different costs or side effects
Merge them into one with a parameterThe difference is a filter, a mode, or a scale threshold
Split the serverThe two tool sets serve different tasks and are rarely needed together

The middle row is under-used. get_user and find_users might be one lookup_user taking either an id or a query. One tool with a clear parameter is easier for a model than two tools with a boundary it has to infer.

For very large tool sets, the newer answer is tool search: mark most tools defer_loading: true and let the model search for what it needs. Schemas are appended rather than swapped, so the cache prefix survives. That is the structural fix when you have a hundred tools and any given request needs three.

Names are longer than you wrote them

Clients namespace tools by prefixing the server name. What reaches the model is myserver__search_docs, not search_docs.

So a name that is legal alone can exceed the length limit once prefixed, and the limit applies to the combined string. Letters, digits, underscore and hyphen; a dot or a space is rejected outright by the strictest consumer. Leave headroom, around forty characters is comfortable.

Duplicate names are worse than they look. Most clients keep the last and drop the first without reporting it, so a tool silently stops existing when somebody adds a second one with the same name in a different file.

Annotations are for the client, not the model

readOnlyHint, destructiveHint, idempotentHint and openWorldHint do not influence what the model does. They tell the client which calls warrant a confirmation prompt, which is a different audience and a different purpose.

Two to get right:

readOnlyHint and destructiveHint are mutually exclusive. Setting both means the tool is gated according to whichever flag that particular client checks first, which is undefined behaviour dressed as metadata.

A destructive tool that is not idempotent will eventually run twice. A retry after a timeout is indistinguishable from a retry after a failure, and nothing in the protocol deduplicates for you. If the operation can be made idempotent by taking a caller-supplied key, do that and set idempotentHint.

Fixing an existing tool surface

1. Read your own tools/list output. Not the source. The serialised JSON the client actually receives, which is what the model sees.

2. Measure its size. Serialised bytes, on every request. If it is over about 15,000 bytes, that is worth a conversation about scope.

3. For each description, check it answers “when”. Not just “what”. This is the single highest-value pass and it is quick.

4. Look for pairs sharing vocabulary. For each pair, write the one sentence that distinguishes them. If you cannot, consider merging.

5. Audit annotations against reality. Every destructiveHint should correspond to something that actually destroys, and every readOnlyHint to something that genuinely only reads.

When this advice does not apply

A single-tool server has no disambiguation problem, and a long description is cheap because there is nothing competing with it.

A server used by one hand-written agent rather than a general model: if you control the calling code, the description is documentation for a human and the triggering advice is irrelevant.

Tools with genuinely self-evident names in a narrow domain. get_weather does not need a paragraph. The advice here is for tool sets where the boundaries are not obvious, which is most of them past about six tools.

The short version

  • Say when to call it, not only what it does. The trigger condition is the half that changes behaviour.
  • Put it in the tool’s own description, not the system prompt.
  • Two tools sharing vocabulary get picked at random. Write contrastively and name the other tool.
  • Sometimes the right fix is one tool with a parameter, not two with better prose.
  • Definitions render first, cost you every request, and changing them mid-conversation discards the whole cache.
  • Names get prefixed with the server name. Leave headroom.
  • Annotations are for the client’s confirmation prompt, not for the model.

Paste a tools/list response into the MCP tool definition linter and it flags descriptions with no trigger condition, pairs whose vocabulary overlaps enough that a model has nothing to discriminate on, names that will not survive prefixing, contradictory annotations, and the exact byte cost of the whole set.