C.W.K.
Stream
Lesson 03 of 05 · published

Tools — Callable, Side-Effectful

~22 min · tools, side-effects, schema, annotations

Level 0Curious Reader
0 XP0/48 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete

Tools in MCP look identical to tools in any provider tool-calling API: a name, a description, an input schema. The key difference from Resources is that tools do things — they may have side effects in the world. The protocol acknowledges this by carrying optional annotations on each tool description (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) that hosts can surface to users when asking for approval.

The flow is: client lists tools, the host integrates them into the LLM's tool list, the LLM emits a tool call, the host calls tools/call on the server, the server executes and returns a structured result, the host feeds the result back to the LLM. The server is responsible for argument validation, idempotency on retry, and structured error returns; the host is responsible for surfacing destructive intent to the user.

One subtlety: tool results are structured content, not just strings. A result is a list of content items that may be text, image, audio, or resource references. This lets a tool return a chart image alongside a text summary, or a list of resource URIs the host can then read. Treating tool results as monomorphic strings is leaving capability on the table.

Code

Tool with annotations and a structured result·python
from mcp.server.fastmcp import FastMCP
from mcp.types import TextContent, ImageContent

app = FastMCP("orders-server")

@app.tool(
    annotations={
        "readOnlyHint": False,
        "destructiveHint": False,
        "idempotentHint": True,
    }
)
async def refund_order(order_id: str, amount_cents: int, reason: str) -> list:
    """Issue a refund. Idempotent on (order_id, amount_cents)."""
    receipt = await stripe.refund(order_id, amount_cents, idempotency_key=f"{order_id}:{amount_cents}")
    return [
        TextContent(type="text", text=f"Refunded ${amount_cents/100:.2f}"),
        TextContent(type="text", text=f"Receipt id: {receipt.id}"),
    ]
tools/list and tools/call on the wire·json
// Client -> Server
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}

// Server -> Client
{"jsonrpc":"2.0","id":2,"result":{"tools":[
  {"name":"refund_order","description":"Issue a refund. Idempotent on (order_id, amount_cents).",
   "inputSchema":{"type":"object","properties":{...},"required":["order_id","amount_cents","reason"]},
   "annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":true}}
]}}

// Client -> Server
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"refund_order","arguments":{...}}}

External links

Exercise

Add a write tool to your tiny server. Mark it with destructiveHint=true and openWorldHint=true. Now connect to it from Claude Desktop or an MCP-aware client and watch how the host surfaces the warning when the model first tries to call it. The annotations are working when the prompt looks scarier than for a read-only tool.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.