Every tool needs exactly four things. Three of them are obvious. One of them is the whole job, and it is the one that looks like documentation.
server.registerTool(
"roll_dice", // 1. NAME
{
description: "Roll one or more dice ...", // 2. DESCRIPTION ← the important one
inputSchema: z.object({ // 3. SHAPE
sides: z.number().int().min(2).max(1000).default(6),
times: z.number().int().min(1).max(20).default(1),
}),
},
async ({ sides, times }) => { // 4. DO-THING
return { content: [{ type: "text", text: "..." }] };
}
);Let us take them one at a time.
1 · The name
An identifier. It must match ^[a-zA-Z0-9_-]{1,128}$ — no dots, no slashes,
no colons — because that is what the Claude API accepts for a tool name.
It is mostly not how the model chooses. It matters for two other reasons:
It is how the host routes the call back to the right server. Which becomes
interesting the moment you connect two servers that both have a secret_code
tool — see One shelf, many servers.
It is what your gate rules key on. Project #3's approval list is written
against names like cookiejar__smash_jar.
2 · The description — the whole ballgame
The description is the only thing the model reads when deciding whether to use your tool.
Not the name. Not the schema. Not your code, which it never sees.
When the model is choosing among eleven tools, it is reading eleven paragraphs of English and picking one. That is the entire selection mechanism.
Which means a vague description is a functional bug
And a particularly nasty one, because nothing reports an error. Your tool simply never gets called. The agent does something else, or apologises that it cannot help, and every log is green.
Compare these two:
Written like a variable name
"Rolls dice."
Technically accurate. Tells the model nothing about when to reach for it, what it is good for, or what it should not be used for.
Written like a job posting
"Roll one or more dice and return the individual results and their total. Use this whenever real randomness is needed — the model cannot generate random numbers itself."
Says what it does, when to use it, and why you cannot fake it.
That last clause is doing real work. It tells the model something true about its own limitations, which makes it far more likely to reach for the tool instead of inventing a number.
A checklist for a good description
- What does it do, in one plain sentence?
- When should it be used? Give the trigger.
- When should it not be used? Name the neighbouring tool if there is one.
- Are there consequences worth stating? "Permanently deletes…", "costs money", "cannot be undone."
- What does it return? "…and their total" saves a follow-up call.
3 · The schema is a free bouncer
inputSchema is a Zod object. The SDK converts it to JSON Schema and ships
that in tools/list, so the model can see the shape it must produce.
It does two jobs, and the second is the good one.
It tells the model what to send. Types, which fields are required, which have defaults.
It rejects bad arguments before your code runs, with a message the model can read and correct:
Input validation error: Invalid arguments for tool roll_dice: sides: Too small: expected number to be >=2
You did not write a line of validation code. You described the shape honestly and got the check for free — and, crucially, the model gets an error it can act on rather than a stack trace.
Where each check happens
Three things worth noticing in that flow:
Defaults are applied before your function runs. times is optional with
.default(1), so your handler always receives a number and never has to write
times ?? 1.
Constraints double as documentation. .max(20) stops a runaway, and it
also appears in the JSON Schema the model reads, so the model knows not to ask
for a thousand dice in the first place.
The two red boxes cost you nothing. They come from z.object({...}).
4 · The function, and what it returns
async ({ sides, times }) => {
const rolls = Array.from({ length: times }, () => 1 + Math.floor(Math.random() * sides));
const total = rolls.reduce((a, b) => a + b, 0);
return {
content: [{ type: "text", text: `Rolled ${times}d${sides} -> [${rolls}] Total: ${total}` }],
};
}A tool returns content blocks, and for most tools that is one block of text.
Note that this is a sentence, not JSON. That is deliberate.
Refuse politely. Do not throw.
That amber box in the diagram matters more than it looks.
When your own rules say no — there aren't 500 cookies in the jar — return a sentence, not an exception.
if (count > cookiesInJar) {
return { content: [{ type: "text",
text: `Can't eat ${count} -- there are only ${cookiesInJar} cookie(s) in the jar. Nice try.` }] };
}Here is what that buys you, verbatim from a real run:
-> WANTS cookiejar__cookie_jar {"action":"eat","count":500}
<- RAN Can't eat 500 -- there are only 78 cookie(s) in the jar. Nice try.
ANSWER: Looks like the jar only has 78 cookies in it right now, so I can't
eat 500 — that would leave it in cookie-debt! Would you like me to eat all
78 instead, or a smaller amount?Nobody wrote that recovery. The model read a clear sentence, understood the situation, and offered an alternative.
Annotations, and a warning to carry forward
MCP lets a server describe its own tools as dangerous:
server.registerTool("smash_jar", {
description: "PERMANENTLY destroy the cookie jar...",
annotations: { destructiveHint: true }, // <- right here
}, handler);That is real, it is in the spec, and project #3 sets it honestly on its own destructive tool.
Adding your own tool
The genuinely nice thing about this design: copy an existing block, rename it, change the description.
server.registerTool(
"shout",
{
description:
"Convert a sentence to upper case with an exclamation mark. Use when the user asks for something to be shouted or emphasised.",
inputSchema: z.object({ text: z.string().min(1).max(200) }),
},
async ({ text }) => ({ content: [{ type: "text", text: `${text.toUpperCase()}!` }] }),
);Push it. That is all.
Checkpoint
You do not tell the model about the new tool. On its next tools/list it
picks the tool up, reads your description, and starts using it when your
description convinces it to.
That is the payoff of the protocol: adding a capability is a deploy, not an integration.
What you now know
- A tool is name, description, inputSchema, function — and no code is ever shown to the model.
- The description is the entire selection mechanism. Write it like a job posting.
- The schema validates for free, applies defaults before your handler runs, and doubles as documentation the model reads.
- Return a sentence, and refuse politely rather than throwing — an error string is a prompt.
annotationsare a hint for humans and UIs, and must never be an input to a permission decision.