Tools you can actually describe
The description is not a comment. It is the part of the tool that ends up in the prompt.
Milestone The schema rejects a wrong category with a message that says which ones would have been right.
A tool has three parts, and the model never sees two of them. Not the body, not the database behind it. What it sees is the name, the description and the schema.
That sounds like a detail and it is the place where most servers fail. A tool that works perfectly and is described badly gets called either never or always. Neither is obvious to anyone.
The description is prompt
Where does the text in description end up? In no documentation. On
tools/list it travels into the model's context and sits there next to the
system prompt, on every single call, for the whole session.
Two things follow from that, and they contradict each other. First, the description is the cheapest lever you have. One more sentence, and a tool gets used correctly. Second, it costs: the tool list of the finished helpdesk from chapter 3 measures 4,206 characters, roughly a thousand tokens, and they travel along in every call. With three connected servers you are quickly at ten thousand before the first question is asked.
The component Tool calling in practice works that out further. For the server here the practical version is enough. Write the description the way you would explain to a new colleague how this tool differs from the one next to it. Not one sentence more.
What belongs in it
Two versions of the same tool.
// Fassung A
description: "Listet Tickets."
// Fassung B
description:
"Listet die Tickets des Helpdesks - Betreffzeile, Absender, Eingang, Kategorie, Status. " +
"`status` und `kategorie` grenzen ein, `limit` und `offset` blättern (Vorgabe 25, Höchstwert 100). " +
"Der Volltext eines Tickets steht hier NICHT, den holt `get_ticket`."
Version B is four times as long and answers three questions a model would otherwise answer by trial. What comes back? How do you narrow it down? And above all: what does not come back.
The last sentence is the most important, and it is the one you forget while
writing. A model that needs the full text and does not find it in the list will
typically call the list twice more before it tries get_ticket. Marking your
tool off against its neighbour saves more calls than any amount of wordsmithing.
Three things belong in every description:
- What comes back, in fields, not in adjectives.
- What the arguments do, including the default if there is one.
- The boundary against the tool it can be confused with.
And one thing does not belong in it: the order. „Nutze dieses Werkzeug immer zuerst“ describes nothing, it instructs the model, and that instruction then sits unchecked next to your system prompt although it comes from an entirely different author than the rest of what the model reads there. With a third-party server that becomes an attack surface; the deep dive of the component calls it tool poisoning.
The schema is the error message
inputSchema is an object of zod fields, not a zod object. The SDK assembles it
itself, turns it into JSON Schema for the tool list and validates every incoming
call against it.
inputSchema: {
key: z.string().regex(/^T-\d{4}$/, "Vorgangsnummer, etwa T-1041"),
kategorie: z.enum(["frage", "stoerung", "rechnung", "recht", "spam"]),
},
What should happen on an invented category? The body never starts. What goes back is this, verbatim:
MCP error -32602: Input validation error: Invalid arguments for tool
categorize_ticket: Invalid enum value.
Expected 'frage' | 'stoerung' | 'rechnung' | 'recht' | 'spam', received 'dringend'
at kategorie
That message is better than most of what you would write by hand. It names the field, the wrong value and the values that would have counted. A model can build its next call from it without guessing, and usually does.
So you should reach for z.enum whenever a field has a limited vocabulary. A
z.string() with a check in the body rejects the same thing, just without the
list of valid values. The same goes for .min(), .max() and .regex(). What
goes into the schema becomes self-description. What stays in the body remains a
riddle.
Required and optional
A field without .optional() is required. If it is missing, the model sees:
MCP error -32602: Input validation error: Invalid arguments for tool
answer_ticket: Required at antwort
Sounds trivial and it shapes how your tool gets used. Every optional field is an
invitation to leave it out, and whatever is left out your body has to fill in
with a default. For limit that is right. For grund in escalate_ticket it
would be wrong: whoever hands a ticket on without saying why leaves behind a
case that nobody can place three weeks later.
The rule behind it fits in one sentence. Required is what you want to read in the log afterwards.
Two tools or one with a switch?
A question that arrives with the third tool. list_tickets with a
volltext: boolean field, or list_tickets and get_ticket separately?
Separately, almost always. A switch that changes the shape of the output becomes a trap for a model, because it sits in the description as a subordinate clause and you only notice what it did from the context consumption. Two tools with clear names cost a hundred characters more in the list and spare you the whole class of errors where something works and is expensive anyway.
Conversely a switch that only filters may stay. status and kategorie
change nothing about the shape of the answer, they make it shorter. So what
matters is not the number of arguments. What matters is whether the answer still
has the same shape.