Skip to content
MCP Five

The ServerProject #17 / 51

Anatomy of a tool

Name, description, schema, function — and why the description is the whole ballgame.

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.

app/api/mcp/route.ts
ts
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:

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

The path of one tools/call
Two of the three refusals are free — you get them from z.object(). The amber one is the only one you write, and writing it well is what separates a tool that helps from a tool that confuses.

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

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

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

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.

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:

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

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. 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.
  • annotations are a hint for humans and UIs, and must never be an input to a permission decision.