MCP in Practice

Lesson 17 of 36

Tools III: annotations, descriptions, and honest errors

The host has to decide whether to ask

A host deciding whether to run a tool without prompting has exactly two pieces of evidence: your description, and four boolean hints. There is no other channel. If your server is wrong about them, the host is wrong about when to interrupt the user.

python
from mcp.types import ToolAnnotations

@mcp.tool(
    title="Find free slots",
    annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True, open_world_hint=False),
)
def find_slots(room_id: str, day: str, minutes: Literal[30, 60, 120] = 60) -> list[Slot]:
    """Free slots in a room on a given day. Call this before booking anything."""

The four hints:

  • read_only_hint — this tool changes nothing. A host can run it freely.
  • destructive_hint — this tool may remove or overwrite something. A host should probably ask.
  • idempotent_hint — calling it twice with the same arguments has the same effect as calling it once. This is what makes a retry safe.
  • open_world_hint — this tool touches things outside a known, closed set: the public internet, arbitrary files. False says the blast radius is bounded.

The labels on a fuse box

Nobody reads them until the lights go out, and at that moment they are the only thing that matters. The cost of labelling correctly is thirty seconds; the cost of labelling wrongly is discovered by somebody else, in the dark, in a hurry.

A beginner takes from this that it is worth doing even though nothing checks it. An engineer recognises what kind of metadata this is: unverified, unenforced, and load-bearing. Nothing in the protocol confirms your read_only_hint. A host that trusts it and skips a confirmation is trusting you, which is precisely why lying here — or being careless — is worse than omitting them.

Atrium's three tools, honestly labelled

The three tools span the space, which is why this example is worth reading closely.

Atrium's three tools span the annotation space, and unset means no claim is being made
Atrium's three tools span the annotation space, and unset means no claim is being made
python
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True, open_world_hint=False))
def find_slots(room_id: str, day: str, minutes: Literal[30, 60, 120] = 60) -> list[Slot]:
    """Free slots in a room on a given day. Call this before booking anything."""

Reads, repeatable, bounded to three rooms. Safe to run without asking.

python
@mcp.tool(title="Book a room")
def book_room(day: str, hour: int, room_hint: str | None = None) -> Booking:
    """Book a room. If the room is ambiguous the user is asked which one they meant."""

All four hints left unset, and that is the honest answer rather than an oversight. Booking is not read-only. It is not destructive — nothing is lost. It is emphatically not idempotent: call it twice and you have two bookings, or an error. Unset means "none of these claims hold", which is exactly right.

python
@mcp.tool(annotations=ToolAnnotations(destructive_hint=True, idempotent_hint=True, open_world_hint=False))
def cancel_booking(booking_id: str) -> str:
    """Cancel a booking and free the slot."""

Destructive and idempotent, which reads like a contradiction and is not. Destructive because a booking disappears. Idempotent because cancelling an already-cancelled booking changes nothing further — so a client that retries after a timeout cannot cause harm. Watch it hold:

text
Cancelled bk_1.
No booking called bk_1 — nothing to cancel.

Second call, no error, no change. That is what idempotent_hint=True promises, and the promise is only true because the implementation was written to make it true.

Unset hints come back as None rather than False, which is a real distinction: None means the server said nothing, False means it made a claim.

Errors: two kinds, and the difference is the lesson

Most servers get this wrong, and it is worth getting right because it decides whether the model can recover.

A domain failure is data. The request was well-formed; the world said no. Raise an ordinary exception and the SDK returns it as a result with is_error=True:

python
if hour in store.taken_hours(picked, day):
    raise ValueError(f"{ROOMS[picked]['name']} is already booked at {hour:02d}:00 on {day}.")
text
is_error: True | Error executing tool book_room: Studio B is already booked at 09:00 on 2026-09-17.

The call succeeded at the protocol level. The model reads that sentence, learns the slot is taken, and tries another hour. This is the outcome you want almost always.

A protocol failure is an error. The request itself was malformed, and no amount of retrying the same thing will help. Raise MCPError:

python
from mcp import MCPError

if not booking_id.startswith("bk_"):
    raise MCPError(-32602, f"malformed booking id: {booking_id!r} (expected bk_<number>)")
text
raised: MCPError malformed booking id: '42' (expected bk_<number>)

That propagates as a JSON-RPC error and surfaces in the client as an exception, not as text in the conversation.

If you have read an older tutorial, MCPError was McpError and took an ErrorData object; it is now positional — MCPError(code, message) — as part of the v2 tidy-up. Access the parts as e.error.code and e.error.message.

The rule: if the model could plausibly do something different next, it is data. If nothing the model does could help, it is an error. "That room is booked" is data — try another room. "That booking id is not a booking id" is an error — the model constructed a malformed call, and telling it so in the conversation would invite it to retry the same malformed call.

A domain failure returns as data the model can work around; a protocol failure raises MCPError
A domain failure returns as data the model can work around; a protocol failure raises MCPError

Descriptions, one more time

The description is read by a model choosing between forty tools. Three things earn their space:

  • When to use it, not only what it does. "Call this before booking anything."
  • What it will not do. "Does not hold the slot; call book_room to reserve it."
  • The vocabulary the caller will actually use. If members say "the big room", the description is a good place for that phrase to appear, because it is what the model will see in the user's message.

What to take into the next lesson

Four hints and a description are the host's only evidence for whether to interrupt the user, and nothing verifies them — so label honestly, and leave hints unset when no claim holds. Raise ordinary exceptions for domain failures the model can work around, and MCPError only when the request itself was malformed. Next: the primitive the application controls.

← Previous