Four tools, each chosen to teach exactly one thing. The choices are more deliberate than they look, and the reasoning transfers to whatever you build first.
| Tool | What it teaches |
|---|---|
say_hello | The simplest tool that can exist. One input, one sentence out. |
roll_dice | Optional inputs, defaults, and guard rails via .min() / .max(). |
cookie_jar | State that survives between calls — and a tool that politely says no. |
secret_code | Why tools beat guessing. Letter-shift maths a model would fumble. |
Plus one resource, cookiejar://status, and one prompt, bedtime_story.
Part 1 · Why a dice roller is the perfect first tool
This is the choice worth stealing, and the reason is not that dice are fun.
Compare that with a first tool like get_weather or summarise_text. The
model can produce a plausible-looking answer to either without calling
anything, and you will spend an afternoon unsure whether your integration works
or the model is just being helpful.
Part 2 · say_hello — the floor
server.registerTool(
"say_hello",
{
description: "Greet someone by name. Use when the user asks for a greeting.",
inputSchema: z.object({ name: z.string().min(1).max(60) }),
},
async ({ name }) => ({ content: [{ type: "text", text: `Hello, ${name}! 👋` }] }),
);One required input, one sentence out. It exists to establish the floor: this is
the least a tool can be, and it is still a complete tool — it appears in
tools/list, it gets validated, and the model picks it on description alone.
It is also the block you copy when adding your own.
Part 3 · roll_dice — optional inputs and free guard rails
inputSchema: z.object({
sides: z.number().int().min(2).max(1000).default(6),
times: z.number().int().min(1).max(20).default(1),
}),Four things happen in those two lines, and only one of them is obvious.
.default(6) means the model can say "roll a die" with no arguments at
all, and your handler still receives a number. You never write sides ?? 6.
.min(2) rejects a one-sided die before your code runs, with a message
the model can read and correct.
.max(20) on times is a seatbelt. Without it, "roll a million dice" is
a legal request.
All four constraints appear in the JSON Schema the model reads. So it knows not to ask for a thousand dice in the first place — the limits are documentation as well as enforcement.
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.
Part 4 · secret_code — the same argument, sharper
Letter-shift ciphers are the other kind of thing models are bad at: mechanical, per-character transformations that require tracking position across a whole string and never drifting.
Ask a model to Caesar-shift a sentence by 3 in its head and it will get most of it right and quietly corrupt a couple of letters. Give it a tool and the answer is exact, every time.
Part 5 · cookie_jar — state, and a tool that says no
Four actions: look, add, eat, refill. It is the first tool with a
memory — the count persists between calls — and the first one that can
refuse.
The refusal
Ask it to eat 500 cookies when there are 78 and it does not crash:
-> WANTS cookiejar__cookie_jar {"action":"eat","count":500}
<- RAN Can't eat 500 -- there are only 78 cookie(s) in the jar. Nice try.A sentence, not an exception. The model reads it, understands, and adapts:
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. It falls out of returning readable English — and the spec agrees: tool execution errors should go back to the model precisely so it can self-correct, unlike protocol errors which it cannot fix.
And notice the shape of the arguments
And one action that sounds safe and isn't
refill resets the jar to exactly 12. If the jar currently holds more than 12,
it silently destroys the difference.
Part 6 · The resource and the prompt
One of each, mostly to show the shape:
| Who decides | ||
|---|---|---|
| 📄 resource | cookiejar://status — the current count, as something to read | the host loads it |
| 💬 prompt | bedtime_story — a fill-in-the-blank instruction taking a hero | the human picks it |
Neither gets used again until project #5, which is its own small lesson: they were there from the very first project, in the same file as the tools, and four projects went by without anybody reaching for them.
Part 7 · Adding your own
Copy the say_hello 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.
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.
Adding a capability is a deploy, not an integration. That is the payoff of having a protocol at all.
What you now know
- A first tool should be something the model provably cannot fake, with output you can verify at a glance.
.default()fills arguments before your handler runs;.min()/.max()are free validation and documentation the model reads.- Refuse with a sentence, and the model recovers without you writing recovery logic.
- The same tool can be safe or dangerous depending on its arguments — the verb is not in the name.
- Friendly-sounding operations that overwrite unbounded state are among the most destructive things in a toolbox.