Skip to content
MCP Five

The ServerProject #18 / 51

The four tools

say_hello, roll_dice, secret_code, cookie_jar — and why a dice roller goes first.

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.

ToolWhat it teaches
say_helloThe simplest tool that can exist. One input, one sentence out.
roll_diceOptional inputs, defaults, and guard rails via .min() / .max().
cookie_jarState that survives between calls — and a tool that politely says no.
secret_codeWhy 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

ts
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

ts
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.

terminal
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.

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:

terminal
  -> 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:

terminal
  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
📄 resourcecookiejar://status — the current count, as something to readthe host loads it
💬 promptbedtime_story — a fill-in-the-blank instruction taking a herothe 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.

ts
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.