Tool descriptions are prompts — write them like it
A tool's description is a prompt the model reads before it picks anything. What has to be in it, what the name carries, and how to read back what your harness actually sends.
It calls the wrong tool, so you write a rule about it
So we shipped the server, and now, for example, it chooses another tool when you ask it to use the notes tool, or it calls it with an argument that doesn't make sense, or it never touches it at all, so you'd create a line in the memory file like "if they ask about the last incident, point them to the notes server". The thing is, the memory file isn't the right layer, it's too meta and far from the choice itself. The description field of the tool that it needs to choose between in a situation where there are multiple similarly-named search tools is what it's all about
Anthropic says without any reservations that detailed descriptions are the most important thing in terms of how well a tool works. But if you put in the description the most reasonable docstring in the world, it's one line long, present tense, and addressed to a human who has the repo open — what the model sees under the hood is the tool name, that one sentence, parameter names, and parameter descriptions. So let's look at what it actually sees:
What actually reaches the model
MCP:
definition for every tool: name (required), title (optional), description, icons array (optional), inputSchema (required), outputSchema (optional), annotations (optional)
the display label is set in the title, which is optional, while the description is what the model actually reasons over, so you need to be mindful of it and write good prose for the sake of the description field; the title you put there is written for a sidebar
The client:
If we were to run a server with two tools against a harness and ask the model "what can you see without calling anything?" — it'd tell us that it can see mcp__notes__notes_search and mcp__notes__search, and the descriptions of both would be identical to what we've put in there (for the second tool we made the description a single word on purpose). So it namespaces the names, and the prose itself gets there intact
The official filesystem server v2026.7.10 ships with 14 tools, definitions are 13635 characters long, which is 3400 tokens (4 characters per token) — all this before the first request
Descriptions are the biggest part of it with 4108 characters, compared to 3274 for schemas and 203 for names
The name is doing more work than you think
But names carry more weight than we think. We all assume that the name is just a handle, so whatever is important must be in the description — but people who measure say the opposite. In this r/mcp thread from August 2025, someone says:
We found out that surprisingly that tool names matter a ton; and with a good tool name, you don't need a long description
And when someone pushed back, they went further:
What was surprising was that tool names >> tool descriptions. We had many situations where fixing the name was the key instead of adding more to the description
And another commenter says:
there is functionally no barrier between the function name, the parameters, and the description. it's all just a chunk of text
Which is an important point; there's no separation between the function name, parameters, and description — it's all just a block of text.
So let's look at some examples:
The first one is from a post by the edgar.tools maintainer from June 2026. At that point they had 27 tools already in production, and the names of their tools were created side by side for each category like this:
search_companies
search_funds
search_advisers
fund_profile
adviser_profileSo after the first two, the model can predict the third one. They go on to say:
Eight tools with blurry boundaries route worse than 27 that form a grammar
What they're saying is that it wasn't about how many tools you have — if you have eight tools with unclear boundaries, they'll route worse than 27 tools that have a consistent naming pattern.
And the second example is from the same post:
search_companies
search_funds
search_advisers
fund_profile
adviser_profile
search_entitiesThey merged three searches into one search_entities tool with an additional type parameter, and then watched the traffic. In the same window, the abstract search_entities was called 15 times by six users, while the concrete search_companies was called 173 times by 37 users.
They say:
Models match the user's noun ('find the company'), not your type hierarchy
And the Docker server maintainer from this very thread has done something similar — they split a vague manage_container tool with an action parameter into four tools:
start_container
stop_container
restart_container
get_container_logsIt's the same functionality, but much better routing.
So it's not a controlled experiment, but if you have figures for how well it works, you can't ignore it. And these two do beat some of the rules of thumb because they attach numbers to it. It's also worth noting that:
MCP spec says that tool names can be 1–128 characters long and should consist of ASCII letters, digits, underscore, hyphen, dot
The Messages API from Anthropic has a similar limit, but more restrictive —
^[a-zA-Z0-9_-]{1,64}$, so letters, digits, underscore and hyphen, up to 64 characters, no dots
So you can use letters, digits, underscore, and hyphen, but keep it short because the client might prepend something on its own. We've even seen a dot surviving the trip, although it got converted into an underscore — the model saw admin.tools.list as mcp__notes__admin_tools_list. So admin.tools.list and admin_tools_list collapse onto the same visible name.
And when we tried to send a 60-character-long name with a 12-character-long prefix, it arrived in full at 72 characters, so no truncation in this version, but definitely not worth the hassle
Four questions the description has to answer
Now, let's have a look at what your description should say. Anthropic frames it as if you were to describe a tool to a new joiner, and you need to put there all the things you'd naturally assume they know so that you don't need to tell them about. Like what types of queries the tool supports, how some specific terms are defined in the context of this tool, or what are the relationships between different entities it operates on.
So it should answer four questions:
What does it do? — a single clause, verb and object
When to use it and when not to — we'll get back to that
What format each parameter is in — not type
What it doesn't return and what it can't see
Let's have a look at this before/after comparison of the same notes-search tool definition:
{
"name": "search",
"description": "Search.",
"inputSchema": {
"type": "object",
"properties": { "q": { "type": "string" } },
"required": ["q"]
}
}{
"name": "notes_search",
"title": "Search engineering notes",
"description": "Full-text search over the team's engineering notes: postmortems, RFCs and on-call handovers, from 2023 onward. Use it when the question is about a decision the team already made or an incident it already had. It does not read the codebase and does not see anything filed under `drafts/`. Returns at most 20 matches, newest first, each with its title, date and the matching paragraph.",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Words to match, not a question. 'redis eviction', not 'why did we change redis eviction'."
},
"since": {
"type": "string",
"description": "Oldest note to consider, as YYYY-MM-DD. Defaults to one year ago."
}
},
"required": ["query"]
}
}So we increased the name to a specific one, we added a display title for the sidebar, and then we created this four-sentence-long description, which covers:
the scope of what the tool is for: postmortems, RFCs, on-call handovers starting from 2023
when to use it: asking about decisions that have already happened or incidents that have already happened
what it's not for: it doesn't read the codebase and doesn't see anything filed under drafts
what it returns: at most twenty matches, newest first, each with a title, date, and a paragraph containing the matching phrase
We also added some metadata next to every parameter:
keyword is of type string, which is called query, and the tool accepts words to match rather than a question
YYYY-MM-DD date, optional, called since, if not provided it's set to one year ago from today
So 383 characters of description against 7.
Anthropic recommends at least 3–4 sentences for descriptions (even more for complex tools), and in this example we have four. If we were to look at the 14 descriptions from the filesystem server, they range from 85 to 457 characters.
But the most valuable line of text in this description is this one:
it does not read the codebase and does not see anything filed under `drafts/`Nobody would ever write it for a colleague, but if you don't include it, the model will try using the tool with a "what's the codebase like" query and return an empty list; then it will conclude that there must be no information about it
The negative space is the valuable part
But now let's talk about what the tool doesn't do. If it has a sibling, the most valuable sentence in its description is the one that points to the other tool. The edgar.tools maintainer phrased it like this:
With sibling tools, the sentence that earns its tokens is "not this — use that."
And their full-text search description ends with an explicit pointer to the event-shaped queries tool. Their financial-statement tools do the same thing with a scope warning — the structured XBRL data doesn't exist until the 10-K or 10-Q is filed, so on earnings day the just-reported quarter is invisible to them, and the description sends that intent elsewhere. Before that sentence existed:
the model confidently answered earnings questions with stale annuals. A description fixed a hallucination class no schema change could.
Their framing is worth stealing — at 27 tools, "you're writing one decision tree distributed across 27 descriptions". And the Docker maintainer from the same thread says the same pattern "cut misrouting roughly in half".
For every tool that has a reasonable neighbour, one sentence describing this neighbour and saying when it should be used. It's not repeating the purpose of this tool (the one you're working on), but a conditional handoff; if they ask about an incident that's still open, point them to the incidents-list tool
The schema is prose too
And the third question — what format each parameter is in. Every description inside inputSchema reaches the model too, so per-parameter text is where the format goes. The spec even has an example:
"location": { "type": "string", "description": "City name or zip code" }That one phrase tells the model what to put in the string, which is the difference between an argument that works and three retries.
And there are three things that should be said at the parameter level and nowhere else:
The shape of the values — dates as YYYY-MM-DD, keywords rather than questions, enums where applicable
The default value applied when the parameter is not provided — a silent one-year window if you don't say anything
Descriptive parameter names — Anthropic's example is user_id instead of user
And there's another place to put information that the model should be aware of before it decides to call a tool:
the server's retention policy should be stated in the creation tool's description (e.g., "baskets expire after 24 hours of inactivity") so the model can see it when deciding to create state
So just remember that everything the model might need to know at the moment of decision is prose, because there's no other place to put it
Errors are the second draft of your description
Now let's have a look at the error messages as well. The spec splits protocol-level errors from tool-execution errors — the latter are being deliberately surfaced to the model so they carry an actionable feedback for self-correction and trying with modified parameters, and clients are supposed to forward them:
Invalid departure date: must be in the future. Current date is 08/08/2025.Anthropic also tells tool creators to prompt-engineer the error messages into actionable steps towards specific improvements instead of opaque codes or tracebacks.
So if your server emits an error that the model encounters more than twice, it's a description defect with a stack trace attached. If the model keeps sending a natural-language question to a keyword parameter — you need to say so in the parameter description, not write a stricter validator
Long enough to route, cheap enough to keep
But you need to be mindful of the fact that this is rent; you're paying for the space these descriptions occupy as soon as the definitions are loaded (before the first request, if they load up front). So if you set up five servers (GitHub, Slack, Sentry, Grafana, Splunk) it's gonna be around 55k tokens of definitions before any work is done
If the definitions are deferred instead, it's the model that searches the catalogue for tools and fetches only what it needs — but it changes the cost rather than eliminates it. And btw, the Messages API docs are explicit about it — both variants search over tool names, descriptions, argument names, and argument descriptions. So under deferred loading the description is actually the index entry, and the word a user would actually type has to appear somewhere in that set, otherwise the tool never surfaces to be chosen
So just remember to write sentences that change the routing decisions and delete sentences that repeat the tool name. And if you run this script — the one that pins the 2025-06-18 handshake, which initialises the server and runs tools/list so it can print the definitions sizes, without a model, key or token spent — against the filesystem server, you'll see that it finds 14 tools with 13635 characters of definitions. Against your server, it usually finds something different
python3 scripts/tool-budget.py -- npx -y @modelcontextprotocol/server-filesystem /tmpReading it back in Claude Code
Let's set up a server in our project — adding it at project scope makes Claude Code write the .mcp.json file — and then see what the CLI will tell us about it:
claude mcp add notes --scope project -- python3 ./notes_server.py
claude mcp list
claude mcp get notesWhat comes back from list and get is transport, scope and health — the command, the arguments and a status line. Not the tools themselves, and not their descriptions.
So basically we can't be certain that after changing the description of a tool in the project the LLM is actually seeing these changes.
When we were working on this we particularly focused on name rewriting, for example, if we set up the "notes" server in our project, the LLM sees mcp__notes__notes_search, but what's more important it changes the dots to underscores and prepends the server name. That's why if we had one server exposing both admin.tools.list and admin_tools_list (the latter is a perfect name for a tool in theory, but…), the LLM would consider these as two identical names.
So what's more, if you create a server by checking in .mcp.json into your project it stays in ⏸ Pending approval state until a human opens the project interactively. That means that even if you see it on the list using the list command, it doesn't mean that the LLM actually has it.
The workaround is to use the CLI to run a server, without modifying the project's config (for example):
claude --strict-mcp-config --mcp-config ./notes.json -p "your prompt"But then, when we tried it, for the first time the LLM was saying it's still connecting and wasn't listing any tools; after running ToolSearch it listed them. So good to know in case you think it might be a problem with the project's config.
Reading it back in Codex CLI
Servers are defined in config.toml under the mcp_servers key, and there's a list command that reads them back
codex mcp list
codex mcp list --jsonWe can run for example list and then list --json and it shows us identical things (but in two different formats) — name, command, args, enabled, auth fields etc. But what's important here is that there's no tool field so as said there's no descriptions in it. And the debug prompt-input command, which is where we thought it should be, isn't it either — we grepped its 14,077 characters of JSON for the tool name and it wasn't there. It renders the prompt input (the context and messages the model sees), not the tools registry. What's more, the skills are visible there, on a separate line with name, description and path, so that's useful for calculating costs of skills but doesn't work for tools. Instead — use the script for that.
To avoid any changes to the real config we suggest setting CODEX_HOME to a new temporary directory before running it
CODEX_HOME=/tmp/mcp-scratch codex mcp listReading it back in GitHub Copilot CLI
You can explore the MCP configuration by using the GitHub Copilot CLI. Copilot's own help describes where it looks for configuration files.
At the user level:
~/.copilot/mcp-config.jsonAt the repository level:
.mcp.jsonor.github/mcp.jsonFor each server that comes with any installed plugin
It's worth to know that .mcp.json is the config file that Claude Code uses, so if you had this tool set up in a repository it should be compatible with Copilot CLI out of the box.
There are multiple commands that you can use in the GitHub Copilot CLI to explore your configuration:
listing servers
showing single server
showing single server as JSON
copilot mcp list
copilot mcp get notes
copilot mcp get notes --jsonNote though, that the Tools: * (all) line is just a per-server filter configuration, not an actual list of tools, the wildcard means "no filters configured." It's always good to set up this filter during server creation if you use a server with many tools — for example, it's the most efficient way to make sure it won't clutter the prompt with tools that you're not interested in.
For instance, let's register a local Python server with only one of its tools exposed:
copilot mcp add notes --tools notes_search -- python3 ./notes_server.pyAlso keep in mind that everything that goes to the prompt from a server isn't limited to tools descriptions, servers can also send their own instructions during discovery phase. GitHub Copilot CLI has an option --allow-all-mcp-server-instructions which is described as follows: "Include initialization instructions from all MCP servers in the system prompt instead of only allowlisted servers." Which means that by default it's set to allow these instructions to go to the prompt only for allowlisted servers, so just keep this in mind — it's good to know there's a channel for your own content to get there and one for the third-party.
Reading it back in Cursor
In Cursor the servers are configured in .cursor/mcp.json (per project) or ~/.cursor/mcp.json (global), and out of the six CLIs here this is the only one that will display tools without starting a session. For instance:
agent mcp enable notes
agent mcp list
agent mcp list-tools notesAnd then it will show you two tools, under notes there will be one with 2 parameters and another one with 1 parameter:
Tools for notes (2):
- notes_search (query, since)
- search (q)just the names of the tools and their parameters, so:
you can spot a tool that shipped and everyone forgot about
you can see that there are two very similar-looking parameters
So it's quite powerful. What it doesn't show is the description text or the parameter types, so it won't tell you whether the sentence that routes between those two tools exists.
The thing is, if you add a new server, it will appear in list servers as not loaded (you need to approve it) and if you run list-tools against it, it will throw an error until you enable it. Also, the approval is stored separately from the config — in ~/.cursor/projects//mcp-approvals.json file and indexed by path, so you can approve some server locally but for your teammate it won't be approved.
Reading it back in Gemini CLI
Let's inspect definitions from within the Gemini CLI: it's the one harness of the six whose own commands are documented to output descriptions, and inside a session it takes its own subcommands: /mcp. To explore it we need to pass a subcommand there, this feature is in the installed 0.46.0 build which comes with three options:
list— lists servers and tools without descriptions, also has aliases:ls,nodesc,nodescriptiondesc— lists servers and tools along with their descriptionsschema— lists servers and tools with descriptions as well as schemas
The last one is the closest equivalent of the six if we were to look at how the model sees the definitions. Let's register a server in the project scope and make sure it's registered:
gemini mcp add -s project notes python3 ./notes_server.py
gemini mcp listBut it's not all good, there are two obstacles. The first one is workspace trust. If the directory is not trusted yet, gemini mcp list will print "MCP servers are configured but disabled because this folder is untrusted", and will also hide the user-scoped ones. Even if the server is properly configured its definition won't be found in the above output. That's why we never got to watch /mcp schema render.
The second obstacle is that the schema you create might be different from what is sent to the model. Gemini does a bit of cleaning:
It removes
$schemaandadditionalPropertieskeysIt also removes defaults from anyOf in case of Vertex AI
Which means that whatever these keywords were used for, it needs to be expressed via description text instead.
The official documentation says that if multiple servers claim a tool name, the first one will get to keep the bare name, subsequent ones will get serverName__toolName. So the final name of a tool depends on what else you have installed and is therefore prone to collisions.
Reading it back in Kimi Code CLI
To make sure the MCP setup works from Kimi Code CLI you can check it by having a look at three files which configure it, in order defined by Kimi's bundled configuration skill:
The user level ~/.kimi-code/mcp.json
The .mcp.json file in the root of your repository (the same format as Claude's so if you used another tool for this project you can still make use of it)
The .kimi-code/mcp.json file in the directory you're working in
The latter one overrides the earlier ones and is the one that was read during the session and contains the server config with no prompt to confirm it. For example, when we asked the model "what tools do you have" it replied with two entries from the test notes server:
mcp__notes__notes_search
mcp__notes__searchas you can see, it's exactly the same namespacing as in Claude Code, and both descriptions came back word for word as we wrote them. The reply came straight from its tool list rather than after a search, so in this build the definitions sit in the context from the very beginning of the session and the token cost applies from the first message.
One warning: kimi doctor sounds like the command for checking this and isn't. It validates the config files it knows about — config.toml and tui.toml — and reports "All checked config files are valid" without mentioning your project's mcp.json at all. A clean bill of health there says nothing about whether your tools loaded.
The loop
To improve the descriptions, iterate — change wording, read it back, and run the prompt that made it choose the wrong tool last time (the ambiguous one, word for word as whoever reported it phrased it). Keep it, it's a test for this layer. If the model chooses the wrong tool it means you need to improve the description, sentence by sentence, until it stops
scripts/tool-budget.py#!/usr/bin/env python3
"""What one MCP server puts in the model's context before anyone types anything.
python3 scripts/tool-budget.py -- npx -y @modelcontextprotocol/server-filesystem /tmp
Speaks the startup handshake of protocol 2025-06-18 — initialize, then tools/list — and
prints what came back, measured. No model, no API key, no tokens spent.
"""
import json
import subprocess
import sys
PROTOCOL = "2025-06-18"
def rpc(proc, rid, method, params):
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": rid, "method": method, "params": params}) + "\n")
proc.stdin.flush()
for line in proc.stdout:
msg = json.loads(line)
if msg.get("id") == rid:
return msg.get("result", {})
raise SystemExit(f"server closed the pipe before answering {method}")
def notify(proc, method):
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": method}) + "\n")
proc.stdin.flush()
def main(command):
proc = subprocess.Popen(
command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1
)
rpc(proc, 1, "initialize", {
"protocolVersion": PROTOCOL,
"capabilities": {},
"clientInfo": {"name": "tool-budget", "version": "1.0"},
})
notify(proc, "notifications/initialized")
tools = rpc(proc, 2, "tools/list", {}).get("tools", [])
proc.terminate()
total = 0
print(f"{'tool':<34}{'name':>7}{'desc':>8}{'schema':>8}{'total':>8}")
for tool in sorted(tools, key=lambda t: len(json.dumps(t)), reverse=True):
name = len(tool.get("name", ""))
desc = len(tool.get("description", ""))
schema = len(json.dumps(tool.get("inputSchema", {})))
whole = len(json.dumps(tool))
total += whole
print(f"{tool.get('name', '?'):<34}{name:>7}{desc:>8}{schema:>8}{whole:>8}")
print(f"\n{len(tools)} tools, {total} characters — roughly {total // 4} tokens, every session.")
undocumented = [t["name"] for t in tools if len(t.get("description", "")) < 80]
if undocumented:
print(f"under 80 chars of description: {', '.join(undocumented)}")
if __name__ == "__main__":
argv = sys.argv[1:]
if argv and argv[0] == "--":
argv = argv[1:]
if not argv:
raise SystemExit(__doc__)
main(argv)