Model Context ProtocolMCP Python SDK 2.2 · LangChain 1.4 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
27 small wins to finish your pathNext lesson →

Tool annotations

Tool annotations are advisory hints on a tool that tell a host how it behaves, such as whether it only reads or changes things.

Last updated: 29 Sep, 2026 · MCP 2.2

Looking an order up is harmless; refunding one moves money. This lesson adds a refund tool and marks which tools are safe, so a host can ask a person before the risky ones.

The ToolAnnotations hints

python
from mcp.types import ToolAnnotations

@mcp.tool(
    title="Refund an order",           # a name for people, shown to users
    annotations=ToolAnnotations(read_only_hint=False, idempotent_hint=False),
)
def refund_order(order_id: str, reason: str) -> str:
    ...

The refund tool

Add from mcp.types import ToolAnnotations to the top of shop.py. title is a name for people, shown in place of refund_order. The annotations describe the behaviour.

python
@mcp.tool(
    title="Refund an order",
    annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=False),
)
def refund_order(order_id: str, reason: str) -> str:
    """Refund the full total of an order to the customer's card."""
    if order_id not in ORDERS:
        raise ToolError(f"No order with id {order_id!r}.")
    return f"Refunded {ORDERS[order_id]['total']:.2f} for order {order_id}: {reason}."

What each hint means

  • read_only_hint: the tool changes nothing. lookup_order sets it to True.
  • destructive_hint: an update may delete or overwrite. A refund adds a transaction rather than destroying data, so False.
  • idempotent_hint: calling twice with the same arguments does no more than once. Two refund calls would be two refunds, so False.
  • open_world_hint, not set here: the tool reaches outside systems, like the web.

Marking the read-only tool

Add the read-only hint to the lookup tool's decorator.

python
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def lookup_order(order_id: str) -> Order:
Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports this file. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep it in the same folder.
View the code here
shop.py
from typing import Annotated, Literal

from pydantic import BaseModel, Field

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations

mcp = MCPServer("Shop support")

ORDERS = {
    "A17": {"item": "blue mug", "status": "shipped", "total": 12.50},
    "B42": {"item": "desk lamp", "status": "processing", "total": 48.00},
}

ARTICLES = {
    "Where is my order?": "orders",
    "Changing an order": "orders",
    "How refunds work": "refunds",
    "Refunds for damaged items": "refunds",
    "Resetting your password": "account",
}


class Order(BaseModel):
    id: str
    item: str
    status: Literal["processing", "shipped", "delivered"]
    total: float


@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def lookup_order(order_id: str) -> Order:
    """Look up an order by its id."""
    if order_id not in ORDERS:
        raise ToolError(f"No order with id {order_id!r}. Order ids look like A17.")
    return Order(id=order_id, **ORDERS[order_id])


@mcp.tool()
def search_help(
    query: Annotated[str, Field(description="Words to look for in the help articles.")],
    topic: Literal["orders", "refunds", "account"] | None = None,
    limit: Annotated[int, Field(ge=1, le=5)] = 3,
) -> str:
    """Search the help centre and return matching article titles."""
    found = [title for title, t in ARTICLES.items() if query.lower() in title.lower() and topic in (None, t)]
    return "; ".join(found[:limit]) or "No articles found."


@mcp.tool(
    title="Refund an order",
    annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=False),
)
def refund_order(order_id: str, reason: str) -> str:
    """Refund the full total of an order to the customer's card."""
    if order_id not in ORDERS:
        raise ToolError(f"No order with id {order_id!r}.")
    return f"Refunded {ORDERS[order_id]['total']:.2f} for order {order_id}: {reason}."

Listing the hints a host reads

A client reads each tool's title and hints alongside its schema.

Example
import asyncio

from mcp import Client
from shop import mcp


async def main():
    async with Client(mcp) as client:
        tools = await client.list_tools()
        for tool in tools.tools:
            hints = tool.annotations
            read_only = hints.read_only_hint if hints else None
            print(f"{tool.name:14} title={tool.title!r:20} read_only={read_only}")


asyncio.run(main())

What the listing tells a host

  • lookup_order is read-only, so a host can call it without a prompt.
  • search_help has no annotations, so a careful host assumes the worst about it.
  • refund_order shows a human title and is not read-only, a signal to ask first.

A hint vs an enforced rule

An annotationCode in your tool
Who acts on itThe host, if it choosesThe server, always
Can be ignoredYesNo
Can be falseA server can lieIt is your own logic
Good forHelping a host decideAnything that must hold

When to set annotations

  • Mark every read-only tool, so hosts can run it without interrupting the user.
  • Flag tools that move money or change data, so a host can require approval.
  • Do not rely on them for safety; enforce the rule in code as well.
Watch out. An annotation is the server describing itself. A host may ignore it, and a malicious server can lie in it. Anything that must not happen without approval has to be enforced by your own code, which Elicitation does for refunds.
Try it yourself
  • Mark search_help as read-only and list the tools again.
  • Add open_world_hint=False to lookup_order.
  • Print tool.annotations for refund_order.
PreviousTool errors

This is what real progress feels like.