Write when to call the tool, not what it does
30 July 2026
There is a tool for this on the siteA 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:
| Option | When it is right |
|---|---|
| Describe them contrastively | They are genuinely different operations with different costs or side effects |
| Merge them into one with a parameter | The difference is a filter, a mode, or a scale threshold |
| Split the server | The 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.