Tools: actions the model can take

Tools are the primitive everyone meets first and the one whose craft is most underrated. A tool is a function the model may decide to invoke — the only MCP primitive with side effects, the only one behind the host's approval gate, and the place where a hundred small design decisions determine whether your server is a joy or a liability.

A real tool, and what the machinery makes of it

Here is Paper Trail's first tool, complete:

python
from fastmcp import FastMCP

mcp = FastMCP("paper-trail")

@mcp.tool
def order_status(order_id: str) -> dict:
    """Look up one order by its id (format PT-NNNN). Returns status, carrier, and ETA."""
    if order_id not in ORDERS:
        raise ValueError(f"no such order: {order_id}")
    return ORDERS[order_id]

if __name__ == "__main__":
    mcp.run()

Eight lines of substance. Run discovery against it and this is what the framework generated — captured, not paraphrased:

text
TOOL: order_status - Look up one order by its id (format PT-NNNN). Returns status, carrier, and ETA.
  schema: {'additionalProperties': False, 'properties': {'order_id': {'type': 'string'}},
           'required': ['order_id'], 'type': 'object'}

The type hints became a JSON Schema — order_id: str became a required string property, additionalProperties: false closing the door on invented arguments. The docstring became the description. Nothing else about your function reaches the outside world.

The docstring is the interface

Which means: the docstring and the signature are the entire contract. The model chooses whether to call your tool, when, and with what based solely on that text. This reframes documentation from courtesy to control surface, and it is why the description above works as hard as it does — it names the id format (so the model does not try "1041"), and says what comes back (so the model knows this answers "where is my order" but not "how many orders shipped this week").

The craft, distilled: name tools verb_noun and describe them in one sentence a reasonable person could act on; state argument formats and units in the description, not in your head; say what the tool does not do when the boundary is easy to misread; and prefer three sharp tools over one manage_orders(action, ...) — the model composes small tools well and guesses multiplexed ones badly. If you find yourself writing "depending on the mode parameter…", split it.

Errors are part of the interface

Lesson 3 showed the frame: a raised exception comes back as isError: true with the message as readable text, inside a successful protocol exchange. Design for the reader that text actually has — a model, mid-conversation. "no such order: PT-9999" lets the model recover ("that order id doesn't exist — could you check it?"). A bare KeyError traceback lets it do nothing but apologise. Write error messages as instructions to a capable colleague: what went wrong, and ideally what would fix it.

The same frame carried structuredContent — the typed result object alongside its text rendering. Return real structures (dict, typed models) rather than pre-formatted strings: the text keeps the model grounded, and the structure lets host-side code use the result without re-parsing prose.

Side effects, and the shape of a safe verb

order_status reads. The interesting design problem is a tool that acts. Here is Paper Trail's, and its whole safety story is in the naming:

python
@mcp.tool
def draft_refund(order_id: str, reason: str) -> str:
    """Draft (but do not send) a refund email for an order. A human reviews and sends it."""

Called for real against order PT-1042:

text
Dear Arjun R,

We're sorry 'Amritsar: A City in Layers' didn't work out (arrived damaged).
Your refund has been initiated and will reach you in 5-7 working days.

— Paper Trail Books

The tool is draft_refund, not send_refund — it produces an artifact for a person, not an effect on the world. That is a server-side design choice, made before any host policy applies, and it composes with the host's approval gate rather than replacing it. The general rule, which section 4 sharpens into the main defence against a whole attack class: automate up to the irreversible step, and put the checkpoint immediately before it. When you must expose a genuinely irreversible verb, say so in the description — hosts increasingly surface destructive-operation hints to the user, and honest metadata is what makes those dialogs meaningful.

From decorated function to what the model sees: schema from the signature, contract from the docstring
From decorated function to what the model sees: schema from the signature, contract from the docstring

Try this: write the docstring for a search_orders(customer_name) tool such that a model would never confuse it with order_status. If your two descriptions could swap without anyone noticing, neither is doing its job.

← Previous