Build a server: tools
Tool definitions, schemas from type hints, results and two kinds of error.
Create a free account Try part of it here
Why it matters
Tools are the reason most MCP servers exist: they let a model act. Everything a model knows about a tool comes from its definition, and everything it learns from using one comes from the result. A vague description gets the tool ignored or misused. A raw stack trace teaches the model nothing. This lesson builds the tools half of a server from scratch, including the type-hint magic SDKs use.
What you'll be able to do
- Write complete tool definitions: name, description, input and output schemas, and annotations
- Generate a tool's inputSchema from a Python function's signature, the way SDKs do
- Return results as content blocks and structured content, with paginated, cacheable tool lists
- Choose correctly between a protocol error and a tool execution error the model can learn from
What it covers
- A tool definition
- Schemas from type hints
- Results: content and structured content
- Two kinds of error
- Turning a return value into a result
- Writing tools a model can use well
- Tool order
A tool definition
Think of the instruction card taped to a rented machine: what it's called, what it's for, what you feed it. The person using it may never have seen one before, so the card has to do all the explaining. A model reading a tool is in the same position. tools/list returns definitions like this:
{
"name": "pause_campaign",
"title": "Pause a campaign",
"description": "Pause one running campaign so it stops spending. Use when the user asks to stop or hold a campaign. Returns the campaign's new status.",
"inputSchema": {
"type": "object",
"properties": {
"campaign_id": {"type": "string", "description": "Campaign id, like cmp_103"},
"reason": {"type": "string", "description": "Short note for the audit log"}
},
"required": ["campaign_id"],
"additionalProperties": false
},
"outputSchema": {"type": "object", "properties": {"id": {"type": "string"}, "status": {"type": "string"}}},
"annotations": {"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false}
}
name: 1 to 128 characters of letters, digits,_,-and., unique on this server.title: a friendly name for people.description: what it does and when to use it, written for the model.inputSchema: JSON Schema for the arguments. A tool with no arguments should use{"type": "object", "additionalProperties": false}.outputSchema(optional): the shape of the structured result, so clients can check and rely on it.annotations: hints about behavior.readOnlyHint: it changes nothing.destructiveHint: it may destroy data.idempotentHint: repeating it is harmless.openWorldHint: it reaches outside systems, such as the web.
Annotations help a host decide when to ask the user for approval. But they're the server's own claims, like a "safe for all ages" sticker the maker put on the box. So the spec says clients must treat them as untrusted unless the server itself is trusted.
Listing tools
tools/list takes an optional cursor and returns a page of tools, plus nextCursor if there are more. It's cacheable: results carry ttlMs (how many milliseconds the list stays fresh) and cacheScope ("public" if any cache may share it, "private" if it's per user). Return tools in a fixed order. A list that's identical every time lets clients cache it, and keeps the model's prompt identical too, so prompt caching works.
readOnlyHint: true, but the server is one you found on a forum. What should the host do?Show the answer
Treat the hint as untrusted and keep its normal approval rules
Annotations are self-reported. A malicious server could label a destructive tool read-only. Only a trusted server's hints should relax safety checks.
Schemas from type hints
Writing JSON Schema by hand for every tool is tedious and drifts out of date. So SDKs read the labels on your function instead, the same inspect trick FastAPI uses:
import inspect, json JSON_TYPES = {str: "string", int: "integer", float: "number", bool: "boolean", list: "array", dict: "object"} def schema_for(fn): props, required = {}, [] for name, p in inspect.signature(fn).parameters.items(): props[name] = {"type": JSON_TYPES[p.annotation]} if p.default is inspect.Parameter.empty: required.append(name) return {"type": "object", "properties": props, "required": required, "additionalProperties": False} def search_products(query: str, max_price: float = 500.0, in_stock: bool = True): """Search the catalog by keywords. Use for any question about what the shop sells.""" print(inspect.getdoc(search_products)) print(json.dumps(schema_for(search_products), indent=1))
The docstring becomes the description, parameters without defaults become required, and the type hints become JSON types. With the official Python SDK, a whole server looks like this:
from mcp.server.fastmcp import FastMCP mcp = FastMCP("shop") @mcp.tool() def search_products(query: str, max_price: float = 500.0) -> list[dict]: """Search the catalog by keywords. Use for any question about what the shop sells.""" return catalog.search(query, max_price) mcp.run() # stdio; other transports are an argument away
SDK names and options change between versions, so check its docs. The ideas don't change.
Results: content and structured content
tools/call sends {"name": ..., "arguments": {...}}. The result holds a list of content blocks, the material the model will read:
| block | holds |
|---|---|
{"type": "text", "text": ...} | plain text, the most common |
{"type": "image", "data": <base64>, "mimeType": "image/png"} | an image, such as a chart |
{"type": "audio", ...} | audio data |
{"type": "resource_link", "uri": ..., "name": ...} | a pointer to a resource the client can read later |
{"type": "resource", "resource": {"uri": ..., "text": ...}} | a resource's contents, embedded |
A result can also carry structuredContent: plain JSON data for programs, matching the outputSchema. Think of a store receipt, with printed lines for you and a barcode for the scanner. Good servers send both. Code reads structuredContent reliably, and a text block with the same data as JSON serves clients and models that only read text.
result = {"content": [{"type": "text", "text": "12 sold"}], "structuredContent": {"sold": 12}, "isError": False}
print(result["structuredContent"]["sold"] + 1)Show the answer
13
structuredContent holds sold as the number 12, so 12 + 1 is 13. Structured content gives programs real values, while content text is what a model reads.
Two kinds of error
A letter can come back for two reasons. "No such address" means it never got to anyone. "We don't stock that, try the blue one" means someone read it and is helping you. MCP has the same split:
- Protocol errors are JSON-RPC errors: an unknown tool or a malformed request. Unknown tools use -32602 (Invalid params). These mean the call itself was broken.
- Tool execution errors are normal results with
"isError": trueand a message in the content: a failed API call, an invalid date, "no campaign with that id". The host passes these back to the model, which can correct itself and retry.
So write execution errors for the model: say what was wrong and what would work. "No campaign cmp_999; ids look like cmp_101 to cmp_106" gets fixed on the next try. "Error 500" doesn't.
In plain words: if the model could fix it, tell the model. That means a normal result with
isError: trueand a helpful message.
Tool errors are written for the model, so it can correct itself.
get_campaign with {"id": "103"}, but ids look like cmp_103. What should the server return?Show the answer
A result with isError: true and a message like "No campaign 103. Ids look like cmp_103."
The call was well-formed; the value was wrong. An execution error with a helpful message lets the model fix it on the next call.
Turning a return value into a result
Strings become text. Data becomes structuredContent plus a text copy. Exceptions meant for the model become execution errors.
import json class ToolError(Exception): """Raise with a message written for the model.""" def to_result(fn, arguments): try: value = fn(**arguments) except ToolError as err: return {"resultType": "complete", "content": [{"type": "text", "text": str(err)}], "isError": True} if isinstance(value, str): return {"resultType": "complete", "content": [{"type": "text", "text": value}], "isError": False} return {"resultType": "complete", "content": [{"type": "text", "text": json.dumps(value)}], "structuredContent": value, "isError": False} def stock(sku: str): levels = {"TENT-2": 12, "MUG-1": 0} if sku not in levels: raise ToolError(f"Unknown sku {sku!r}. Known skus: {', '.join(levels)}.") return {"sku": sku, "in_stock": levels[sku]} print(to_result(stock, {"sku": "TENT-2"})) print(to_result(stock, {"sku": "tent"}))
The good call returns structured data and a JSON text copy. The bad one returns a normal result flagged isError, with a message listing the valid skus. That's everything the model needs to retry correctly.
Show the answer
destructiveHint
Hosts can ask before running destructive tools. Annotations are claims, so trust them only from servers you trust.
Writing tools a model can use well
The description isn't documentation for developers. It's the only thing the model reads when choosing what to do, so it deserves more care than any other string in your server. Treat it as a job description for someone starting today.
Compare these two:
- "Gets order." The model doesn't know when to use it, or what it needs.
- "Look up one order by its id, like ord_123. Returns status, items and total. To find orders by customer email, use find_orders instead."
The second says when to use it, what it takes, what comes back, and when to use something else. In practice, improving descriptions often helps more than changing the model.
What errors should say
Don't return stack traces. The model can't act on one, and it may reveal your file paths and library versions.
Decide who can fix each problem. If the model can fix it, such as a mistyped date, use a normal result with isError: true and a message explaining what to change. If the call itself was malformed, that's a protocol error. Reporting a fixable problem as a protocol error means the model never sees the message, so it can't try again.
Annotations are claims, not guarantees
readOnlyHint: true is your server telling hosts that a tool changes nothing. A host may then skip asking for approval. A careful host only trusts these from servers it already trusts, because nothing stops a malicious server from labeling a delete tool as read-only.
So on your own server, make them true. On the host side, treat them as hints from a stranger.
Tool order
Keep tools in a fixed order, which helps caching. It's the same reason a menu is printed the same way each day.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Which annotation says repeating a tool call with the same arguments is harmless?