examples/MCP-tools Public
id: tool-design
type: concept
owner: justin
component: tools
describes:
- src/tools/**
- src/server.ts
reviewed: 2026-10-13
Tool design
A tool is one module in src/tools/ that exports the result of defineTool (src/tools/types.ts). It has no idea MCP exists: src/server.ts registers every tool in TOOLS with the SDK's McpServer, and that is the only place the two meet.
What a tool has
- name: snake_case, a verb and an object where one fits (
convert_units,text_stats). Models see the name first, and in a long tool list it is often all they skim. - title: a short human label for client UIs.
- description: written for the model, not for us. It says what the tool does, when to reach for it ("whenever the user needs an exact figure"), and the limits that matter (units must share a quantity, US customary volumes). If a model would otherwise guess, the description tells it not to.
- input: a zod object. Every field has a
.describe()with an example value. Defaults live in the schema (words_per_minutedefaults to 238), so they show up intools/listand the tool never sees a missing value. - output: a zod object too. The SDK publishes it as
outputSchemaand checks every result against it before it leaves the server, so a tool cannot drift from its contract quietly. - run: a pure function from parsed input and a
ToolContext(justnowtoday) to output. No I/O, no clock reads, no globals that change. That is what makes the unit tests one line each.
Lenient in, strict out
Inputs accept what a model is likely to send: pounds, °F and Kilometres all resolve in convert_units; tokyo resolves to Asia/Tokyo in time_in_zone. Outputs are always canonical: the unit symbol, the IANA name, ISO 8601 in UTC. A model that echoes the output back as input gets the same answer.
We stop being lenient where guessing would be wrong. time_in_zone refuses an instant without an offset, because the Worker would read it in its own zone, and refuses a city that matches more than one zone.
Results
A successful call returns structuredContent and the same object as JSON in a text block, as the spec recommends for clients that do not read structured content yet. Numbers are rounded to twelve significant digits so float noise (0.30000000000000004) never reaches the model.
Failures
A tool throws ToolError (src/tools/errors.ts) for anything the caller can fix. The server turns it into a result with isError: true and the message as text; see [[0002-tool-errors-are-results]]. Write the message as advice: what was wrong, then what would work.
Unknown time zone "Mars/Olympus_Mons". Use an IANA name such as Europe/Paris, Asia/Tokyo or America/New_York.
Arguments that fail the input schema never reach run; the SDK answers them with an isError result too. Any other exception is a bug: the server logs it and tells the model that retrying the same input will not help.
Annotations
Every tool is registered with readOnlyHint, idempotentHint and openWorldHint: false. Clients may use these to skip a confirmation prompt. If a tool ever writes something or calls out to the network, it must not inherit them; change register in src/server.ts to take them from the tool.
To add one, follow [[adding-a-tool]].