Tools¶
A tool is a function the model may call. See Tools, resources, and prompts for when a tool is the right shape and when a resource or a prompt is.
Everything is declared on the Registry, once, with no mention of a protocol
revision.
from pydantic import BaseModel, Field
from aiohttp_tiny_mcp import Hint, MemoryHub, MemorySessionStore, Registry
registry = Registry("catalog", "1.0", hub=MemoryHub(), session_store=MemorySessionStore())
Declaring one¶
The first argument’s annotation is the input schema. The return annotation, if it is a model, is the output schema. The docstring is the description.
class Search(BaseModel):
query: str
limit: int = Field(10, ge=1, le=100, description="How many to return.")
class Hits(BaseModel):
found: int
items: list[str]
@registry.tool
async def search(args: Search) -> Hits:
"""Search the catalog."""
items = [f"{args.query}-{n}" for n in range(args.limit)]
return Hits(found=len(items), items=items)
The description is not decoration. The model reads it, the name and the schema, and decides from those alone whether this is the tool for what it is doing. Write for that reader: say what the tool does, when to use it, and what it returns.
Field descriptions and constraints go the same way. Field(..., description=...)
reaches the model; ge/le reach both the model and the validator.
Names, titles and hints¶
class Nothing(BaseModel):
"""No arguments. Every handler takes a model, even an empty one."""
@registry.tool(
name="purge",
title="Empty the catalog",
annotations=Hint.DESTRUCTIVE | Hint.IDEMPOTENT,
)
async def wipe_everything(args: Nothing) -> str:
"""Remove every item. This cannot be undone."""
return "emptied"
name is what the model calls. title is what a person sees. Hint carries
what the specification defines, one member per hint, joined with |:
Hint |
Says |
Where a tool says nothing |
|---|---|---|
|
Changes nothing |
false |
|
May remove or overwrite |
true |
|
Calling it twice is the same as once |
false |
|
Reaches something outside this server |
true |
Two of them default to true: the specification assumes the worst about a tool
that says nothing. Deny one with ~, and pass a single hint on its own:
@registry.tool(annotations=Hint.READ_ONLY | ~Hint.OPEN_WORLD)
async def count(args: Nothing) -> int:
"""How many items there are. Reads this server and nothing else."""
return 3
They are hints, not enforcement. A host uses them to decide what to confirm with the person first, which is the point of saying so. A plain mapping is still accepted, which is how a hint added to the specification later is written before this package catches up.
What a tool may return¶
A model produces an output schema and structured content. Anything else is rendered as text.
@registry.tool
async def health(args: Nothing) -> str:
"""One line, no schema."""
return "ok"
Returned |
Becomes |
|---|---|
a pydantic model |
|
a string |
one text block |
a number, list or dict |
one text block holding its JSON |
a |
itself, unchanged |
Build a CallToolResult where a tool needs several content blocks, or content
that is not text.
Failures are results, not errors¶
A handler that raises does not produce a JSON-RPC error. It produces a tool
result with isError set, because the model asked for something and deserves
to be told what happened rather than having the call disappear.
from aiohttp_tiny_mcp import Client
from aiohttp_tiny_mcp.protocol.selection import AdapterSet
@registry.tool
async def boom(args: Nothing) -> str:
"""Always raises."""
raise RuntimeError("kaboom")
url = await serve(registry)
async with Client(url, AdapterSet.default().by_version["2026-07-28"]) as client:
await client.initialize()
result = await client.call_tool("boom", {})
assert result.is_error is True
assert "kaboom" in result.content[0].text
Arguments that fail validation are reported the same way, for the same reason: the model can read what was wrong and try again.
A genuine protocol failure – an unknown tool, a malformed request – is still a JSON-RPC error, because there the call never reached a tool at all.
Without a decorator¶
Every declaration takes the handler as an argument too, and the two forms do the same thing.
from pydantic import BaseModel
from aiohttp_tiny_mcp import MemoryHub, MemorySessionStore, Registry
class Nothing(BaseModel):
pass
class Add(BaseModel):
a: int
b: int
async def add(args: Add) -> int:
"""Add two integers."""
return args.a + args.b
async def config(args: Nothing) -> dict:
"""Application configuration."""
return {"debug": False}
plain = Registry("plain", "1.0", hub=MemoryHub(), session_store=MemorySessionStore())
plain.tool(add)
plain.tool(add, name="sum", title="Add up")
plain.resource("config://app", config, mime_type="application/json")
assert set(plain.tools) == {"add", "sum"}
A decorator has to stand where the function is written. The argument form does not, which is what it is for:
A handler defined somewhere else. Collect the functions in the modules they belong to, and assemble the server in one place, with no import-time side effects and no registry that has to exist before them.
A handler that holds its own state. A method is bound to an object built at run time, which no decorator can reach.
from aiohttp_tiny_mcp import Client
from aiohttp_tiny_mcp.protocol.selection import AdapterSet
class Counter:
def __init__(self, start: int) -> None:
self.total = start
async def bump(self, args: Nothing) -> int:
"""Increase and report."""
self.total += 1
return self.total
counter = Counter(41)
plain.tool(counter.bump, name="bump")
url = await serve(plain)
async with Client(url, AdapterSet.default().by_version["2026-07-28"]) as client:
await client.initialize()
result = await client.call_tool("bump", {})
assert result.content[0].text == "42"
assert counter.total == 42
A handler chosen at run time. Register one implementation or another depending on configuration, without writing both and hiding one.
The name comes from the function unless name= says otherwise, so the same
handler may be registered more than once under different names.
Long calls¶
A tool declared streaming=True gets a response stream and can report progress
and log messages before it returns. See
Notifications.
Asking the user¶
A tool that needs a decision asks for one. See Asking the user.
Requiring a newer revision¶
A tool that cannot be expressed on an older revision can say so, and is hidden there instead of being offered in a broken form.
@registry.tool(min_revision="2026-07-28")
async def modern_only(args: Nothing) -> str:
"""Offered only where the revision can express it."""
return "new"
url = await serve(registry)
for version, expected in (("2026-07-28", True), ("2025-03-26", False)):
async with Client(url, AdapterSet.default().by_version[version]) as client:
await client.initialize()
names = {tool.name for tool in await client.list_tools()}
assert ("modern_only" in names) is expected, version
Some degradation happens without being asked for. A schema an older revision cannot represent is simplified where that is lossless, and the tool is hidden where it is not – one decision, in the adapter, rather than a condition in every handler. See What varies.