A working MCP tool is not the same as a well-designed one. A model decides whether to call your tool, and a client decides whether to auto-run it or ask the user first, based entirely on what the tool says about itself: its name, its description, its parameters, and its annotations. Get those wrong and a read-only lookup gets a confirmation prompt, a destructive delete runs silently, or the model picks the wrong tool.
This tutorial builds a small notes server with FastMCP
and focuses on the design, not the CRUD. You will annotate each tool with the
behavioral hints clients rely on (readOnlyHint, destructiveHint,
idempotentHint, openWorldHint), return error results the model can read and
retry, mask internal failures so they cannot leak, and see where naming and the
split-versus-merge decision matter. The stack is Mac-native: uv, make, and
pytest.
What annotations are
Every tool a server exposes carries an optional set of annotations — advisory hints about how the tool behaves:
| Annotation | Meaning | Example |
|---|---|---|
title | Human-friendly display name | “Delete note” |
readOnlyHint | Does not modify any state | a search or a lookup |
destructiveHint | May perform destructive updates (only meaningful when not read-only) | delete, overwrite |
idempotentHint | Repeating the call with the same arguments changes nothing further | delete by id, set a value |
openWorldHint | Interacts with systems outside the server | a web search, a payment API |
They are hints, not enforcement. A client such as Claude Desktop uses them to decide, for example, to auto-approve a read-only call but confirm a destructive one. Nothing stops a badly written “read-only” tool from writing, so annotations are for user experience and safety prompts — never a security boundary.
What you will build
store.py: an in-memory note store, seeded deterministically, kept separate from the tools.server.py: four tools —search_notes,get_note,create_note,delete_note— each with a full set of annotations and a docstring that states its semantics.client.py: an in-memory client that prints the annotation matrix and exercises reads, a not-found error result, and an idempotent delete.- A
pytestsuite that asserts the annotations, idempotency, error masking, and the tool-error-versus-protocol-error distinction. - A
makewrapper with a help screen.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - Familiarity with FastMCP tools and the in-memory
Client(see Build an MCP Server with FastMCP).
Step 1: Add project hygiene
Create the file
mkdir -p mcp-tool-annotations-semantics-macos
cd mcp-tool-annotations-semantics-macos
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
*.log
# OS / editor noise
.DS_Store
Detailed breakdown
- Standard Python ignores plus
.DS_Store, created first so the virtualenv and caches from later steps never get committed.
Step 2: Initialize the project with uv
Create the files
uv init --name macmcp --no-workspace
rm -f main.py hello.py
uv add fastmcp
uv add --dev pytest pytest-asyncio
Add the code: pyproject.toml
[project]
name = "macmcp"
version = "0.1.0"
description = "A notes MCP server that demonstrates tool annotations and semantics"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.4",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
fastmcpis the only runtime dependency; it providesToolAnnotations(via themcptypes) and theToolErrorexception used below.pytest-asyncioruns the async tests; Step 6 sets it to auto mode.
Step 3: A store, kept separate from the tools
Separating storage from the tool definitions is itself a design choice: each tool
stays a thin, single-purpose wrapper that is easy to name and describe. The store
is seeded with fixed data and exposes a reset() so the demo and every test start
from the same three notes.
Create the file
touch store.py
Add the code: store.py
"""In-memory note store, seeded deterministically.
The tools in server.py are thin wrappers over these functions. Keeping the
storage logic here — separate from the tool definitions — is part of good tool
design: each tool stays focused on one action and is easy to describe to a
model. Nothing in this module imports MCP.
"""
from pydantic import BaseModel
class Note(BaseModel):
"""A single stored note."""
id: int
title: str
body: str
tags: list[str] = []
# Fixed seed data so the demo and tests are reproducible.
SEED = [
("Groceries", "milk, eggs, bread", ["home"]),
("Standup notes", "shipped the parser fix", ["work"]),
("Book ideas", "a novel about a lighthouse", ["personal"]),
]
_NOTES: dict[int, Note] = {}
_NEXT_ID = 1
def reset() -> None:
"""Clear the store and re-seed it. Call this before each test."""
global _NOTES, _NEXT_ID
_NOTES = {}
_NEXT_ID = 1
for title, body, tags in SEED:
add(title, body, tags)
def add(title: str, body: str, tags: list[str] | None = None) -> Note:
global _NEXT_ID
note = Note(id=_NEXT_ID, title=title, body=body, tags=list(tags or []))
_NOTES[note.id] = note
_NEXT_ID += 1
return note
def get(note_id: int) -> Note | None:
return _NOTES.get(note_id)
def delete(note_id: int) -> bool:
"""Remove a note. Returns True if it existed, False if it was already gone."""
return _NOTES.pop(note_id, None) is not None
def search(query: str) -> list[Note]:
q = query.lower()
return [n for n in _NOTES.values() if q in n.title.lower() or q in n.body.lower()]
reset() # seed on import
Detailed breakdown
Noteis a Pydantic model, so a tool returningNoteorlist[Note]gets a structured output schema for free (see Return Structured Output from a FastMCP Server).reset()re-seeds the store to notes with ids1,2,3and sets the next id to4. The demo and the test fixture call it so results never depend on the order tests happened to run in.delete()returns whether the note existed. That boolean is what makes the delete tool honestly idempotent: a second delete returnsFalseinstead of raising.
Step 4: Declare the tools with annotations
The four tools sit at the two ends of the annotation matrix:
search_notesandget_noteare read-only and idempotent.create_notewrites and is not idempotent (a new id every call).delete_notewrites, is destructive, and is idempotent.
Each tool passes a ToolAnnotations object so a client sees the full picture
before it ever calls the tool.
Create the file
touch server.py
Add the code: server.py
"""A notes MCP server that shows deliberate tool design.
The lesson here is not what the tools do (basic CRUD over an in-memory store)
but how they are declared:
- Every tool carries **annotations** — `readOnlyHint`, `destructiveHint`,
`idempotentHint`, `openWorldHint`, and a display `title` — so a client can
decide, for example, to auto-approve a read but confirm a delete.
- Names are `verb_noun` and specific (`create_note`, not a mega-tool with a
`mode` argument).
- Expected failures raise `ToolError`, which returns an error *result* the model
can read and react to. Unexpected exceptions are masked by the server so they
cannot leak internals.
Run over stdio with `python server.py`, or drive it with the in-memory client
in client.py.
"""
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from mcp.types import ToolAnnotations
from pydantic import BaseModel
import store
from store import Note
# mask_error_details hides the text of *unexpected* exceptions from the client.
# Messages you raise deliberately with ToolError are always shown.
mcp = FastMCP("notes", mask_error_details=True)
class DeleteResult(BaseModel):
"""Outcome of a delete: which id, and whether it had existed."""
note_id: int
deleted: bool
@mcp.tool(
annotations=ToolAnnotations(
title="Search notes",
readOnlyHint=True,
idempotentHint=True,
openWorldHint=False,
)
)
def search_notes(query: str) -> list[Note]:
"""Find notes whose title or body contains `query` (case-insensitive)."""
return store.search(query)
@mcp.tool(
annotations=ToolAnnotations(
title="Get note",
readOnlyHint=True,
idempotentHint=True,
openWorldHint=False,
)
)
def get_note(note_id: int) -> Note:
"""Return one note by id. Raises if no note has that id."""
note = store.get(note_id)
if note is None:
raise ToolError(f"No note with id {note_id}.")
return note
@mcp.tool(
annotations=ToolAnnotations(
title="Create note",
readOnlyHint=False,
destructiveHint=False,
idempotentHint=False,
openWorldHint=False,
)
)
def create_note(title: str, body: str, tags: list[str] | None = None) -> Note:
"""Create a new note and return it with its assigned id.
Not idempotent: each call creates a distinct note, even with identical
arguments.
"""
return store.add(title, body, tags)
@mcp.tool(
annotations=ToolAnnotations(
title="Delete note",
readOnlyHint=False,
destructiveHint=True,
idempotentHint=True,
openWorldHint=False,
)
)
def delete_note(note_id: int) -> DeleteResult:
"""Delete a note by id.
Idempotent: deleting an id that is already gone still succeeds (with
`deleted=false`), so a client can safely retry the call.
"""
existed = store.delete(note_id)
return DeleteResult(note_id=note_id, deleted=existed)
if __name__ == "__main__":
mcp.run()
Detailed breakdown
mask_error_details=Trueon the server changes how unexpected exceptions are reported. A rawValueErrorbecomes a genericError calling tool '<name>'instead of shipping its message (and any secrets in it) to the client. Messages you raise on purpose withToolErrorare always shown, so this is safe to leave on in production.ToolAnnotationscarries the five hints. Set them honestly: areadOnlyHintthat lies removes a confirmation prompt the user needed. Leaving a hint unset (its defaultNone) means “unknown,” which a cautious client treats as the unsafe case — so state the ones you know.search_notes/get_notearereadOnlyHint=True, idempotentHint=True. These are the calls a client can run without asking.get_noteraisesToolErrorwhen the id is missing. That returns a result withisErrorset and your message intact, so the model reads “No note with id 99.” and can correct itself, rather than seeing a crash.create_noteisidempotentHint=False: calling it twice with the same arguments makes two notes. A client must not silently retry it on a timeout.delete_noteisdestructiveHint=True, idempotentHint=True. Destructive earns a confirmation prompt; idempotent means a retry after a dropped response is safe. TheDeleteResult.deletedflag reports whether anything was actually removed.openWorldHint=Falseeverywhere, because every tool operates only on our own store. Set itTrueon a tool that reaches outside the server (a web search, a third-party API, a shell command) so the client knows the effect is not contained.
Step 5: Read the semantics from a client
Create the file
touch client.py
Add the code: client.py
"""Drive the notes server in-memory and print each tool's semantics.
The first block prints the annotation matrix a client sees at connect time. The
rest exercises the read, write, and delete tools, including a not-found error
result and the idempotent second delete.
"""
import asyncio
from fastmcp import Client
import store
from server import mcp
def hints(t) -> str:
a = t.annotations
flags = []
if a.readOnlyHint is not None:
flags.append(f"readOnly={a.readOnlyHint}")
if a.destructiveHint is not None:
flags.append(f"destructive={a.destructiveHint}")
if a.idempotentHint is not None:
flags.append(f"idempotent={a.idempotentHint}")
if a.openWorldHint is not None:
flags.append(f"openWorld={a.openWorldHint}")
return ", ".join(flags)
async def main() -> None:
store.reset() # deterministic starting point
async with Client(mcp) as client:
print("Tool annotations:")
for t in await client.list_tools():
print(f" {t.name:14} [{t.annotations.title}] {hints(t)}")
# Read-only search over the seeded notes.
res = await client.call_tool("search_notes", {"query": "parser"})
print("\nsearch 'parser':", [n.title for n in res.data])
# A hit and a miss on get_note. The miss is an error *result*.
res = await client.call_tool("get_note", {"note_id": 1})
print("get_note(1):", res.data.title)
res = await client.call_tool("get_note", {"note_id": 99}, raise_on_error=False)
print("get_note(99): is_error =", res.is_error, "->", res.content[0].text)
# Create is not idempotent: a new id every time.
res = await client.call_tool(
"create_note", {"title": "Weekend", "body": "hike Mt Sanitas"}
)
new_id = res.data.id
print("\ncreate_note -> id", new_id)
# Delete is destructive but idempotent: the retry still succeeds.
res = await client.call_tool("delete_note", {"note_id": new_id})
print("delete_note first :", res.structured_content)
res = await client.call_tool("delete_note", {"note_id": new_id})
print("delete_note retry :", res.structured_content)
if __name__ == "__main__":
asyncio.run(main())
Detailed breakdown
hints()skips any annotation left atNone, so the printout shows only the hints a tool actually declares.raise_on_error=Falseon theget_note(99)call returns the error result instead of raising, so the client can inspectres.is_errorand read the message — exactly what a model does when it decides whether to retry.- The two
delete_notecalls show idempotency directly: the first returnsdeleted=True, the retrydeleted=False, and neither is an error.
Run it:
uv run python client.py
Expected output:
Tool annotations:
search_notes [Search notes] readOnly=True, idempotent=True, openWorld=False
get_note [Get note] readOnly=True, idempotent=True, openWorld=False
create_note [Create note] readOnly=False, destructive=False, idempotent=False, openWorld=False
delete_note [Delete note] readOnly=False, destructive=True, idempotent=True, openWorld=False
search 'parser': ['Standup notes']
get_note(1): Groceries
get_note(99): is_error = True -> No note with id 99.
create_note -> id 4
delete_note first : {'note_id': 4, 'deleted': True}
delete_note retry : {'note_id': 4, 'deleted': False}
Step 6: Test the contract
Create the files
mkdir -p tests
touch tests/__init__.py
touch pytest.ini
touch tests/test_server.py
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
filterwarnings =
ignore::DeprecationWarning
Detailed breakdown
asyncio_mode = autorunsasync def test_*without a marker.tests/__init__.pymakestests/a package, which puts the project root onsys.pathsofrom server import mcpresolves.
Add the code: tests/test_server.py
"""Tests for the notes server's tool semantics.
These assert the design contract, not just that the tools run: the annotations
each tool advertises, idempotency (create is not, delete is), error results the
model can read, masking of unexpected exceptions, and the difference between a
tool-error result and a protocol-level validation error.
"""
import pytest
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ToolError
import store
from server import mcp
@pytest.fixture(autouse=True)
def fresh_store():
"""Reseed the store before every test so ids and hits are deterministic."""
store.reset()
async def test_annotations_are_advertised():
async with Client(mcp) as client:
tools = {t.name: t.annotations for t in await client.list_tools()}
assert tools["search_notes"].readOnlyHint is True
assert tools["search_notes"].idempotentHint is True
# A destructive, idempotent write.
assert tools["delete_note"].readOnlyHint is False
assert tools["delete_note"].destructiveHint is True
assert tools["delete_note"].idempotentHint is True
# Create writes but is not idempotent.
assert tools["create_note"].idempotentHint is False
assert tools["create_note"].destructiveHint is False
async def test_search_finds_by_body():
async with Client(mcp) as client:
res = await client.call_tool("search_notes", {"query": "parser"})
assert [n.title for n in res.data] == ["Standup notes"]
async def test_get_note_not_found_is_an_error_result():
async with Client(mcp) as client:
res = await client.call_tool("get_note", {"note_id": 99}, raise_on_error=False)
assert res.is_error is True
# The ToolError message reaches the client unchanged.
assert res.content[0].text == "No note with id 99."
async def test_create_is_not_idempotent():
async with Client(mcp) as client:
first = await client.call_tool("create_note", {"title": "a", "body": "x"})
second = await client.call_tool("create_note", {"title": "a", "body": "x"})
assert first.data.id != second.data.id # distinct notes
async def test_delete_is_idempotent():
async with Client(mcp) as client:
first = await client.call_tool("delete_note", {"note_id": 1})
retry = await client.call_tool("delete_note", {"note_id": 1})
assert first.structured_content == {"note_id": 1, "deleted": True}
# The retry still succeeds; it is a no-op, not an error.
assert retry.structured_content == {"note_id": 1, "deleted": False}
async def test_unexpected_exception_is_masked():
"""With mask_error_details=True, an exception the author did not raise on
purpose is hidden, so internal details cannot leak to the model or user."""
masked = FastMCP("masked", mask_error_details=True)
@masked.tool
def boom() -> int:
raise ValueError("db password = hunter2") # must never reach the client
async with Client(masked) as client:
res = await client.call_tool("boom", {}, raise_on_error=False)
assert res.is_error is True
assert "hunter2" not in res.content[0].text
assert res.content[0].text == "Error calling tool 'boom'"
async def test_missing_argument_is_a_protocol_error():
"""Calling a tool with a missing required argument fails validation before
the tool body runs — a protocol-level error, not a tool-error result."""
async with Client(mcp) as client:
with pytest.raises(ToolError):
await client.call_tool("get_note", {}) # note_id missing
Detailed breakdown
fresh_storeis autouse, so every test starts from ids1,2,3.test_delete_is_idempotentencodes the whole point of the idempotent hint: the second call is a successful no-op, not an error.test_unexpected_exception_is_maskedproves masking hides theValueErrortext (including a fake secret) while still reporting an error.test_missing_argument_is_a_protocol_errorshows the other failure mode: an invalid call is rejected at the protocol layer and raises, because the tool body never runs. That is different fromget_note(99), where the tool ran and chose to return an error result.
Step 7: Wrap it in a Makefile
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help install demo serve test clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}'
install: ## Sync runtime and dev dependencies
uv sync
demo: ## Run the in-memory client and print the annotation matrix
uv run python client.py
serve: ## Run the server over stdio (for a real MCP client)
uv run python server.py
test: ## Run the test suite
uv run pytest -q
clean: ## Remove caches
rm -rf .pytest_cache __pycache__ tests/__pycache__
Detailed breakdown
.DEFAULT_GOAL := helpmakes a baremakeprint the help screen. Recipe bodies must be tab-indented ormakeerrors.
Step 8: Run everything
make # prints the help screen
make demo # runs client.py (output shown in Step 5)
make test # runs the suite
The suite passes:
....... [100%]
7 passed in 0.33s
Tool design guidelines
The annotations are half the story. These conventions decide whether a model picks the right tool and calls it correctly.
- Name tools
verb_noun, and be specific.create_noteanddelete_noteread as actions. A barenotesordatatells the model nothing. Consistent prefixes (note_*,calendar_*) help when a server exposes many tools. - Write the description for the model. The docstring is the tool’s manual. State what it does, what the arguments mean, and the semantics that annotations cannot express (“not idempotent,” “returns at most 50 rows”). The model reads this before every call.
- One tool, one action. Do not build a mega-tool. A single
manage_notes(action, ...)that switches onaction="create"/"delete"/"search"forces the model to encode intent in a string, defeats per-action annotations (is the whole thing destructive or not?), and produces worse errors. Four focused tools are clearer and cheaper to reason about. - Do not over-split, either. If every real task needs three of your tools in a fixed sequence, that is a sign they should be one tool. Aim for one tool per intent a user actually has.
- Return errors the model can use. Raise
ToolErrorwith a message that says how to fix the call (“No note with id 99.”). The model reads error results and retries; an opaque failure just stalls it. - Annotations are advisory, never a security boundary. A client may use
readOnlyHintto skip a prompt, but your server must still enforce its own authorization. Never rely on a hint to keep a tool from doing something.
Troubleshooting
- Annotations are
Noneon the client. They were not set on the tool. Passannotations=ToolAnnotations(...)(or a plain dict with the same keys) to the@mcp.tool(...)decorator. - A secret showed up in an error message. The server was created without
mask_error_details=True, so an unexpected exception’s text reached the client. Turn masking on, and raiseToolErrorwith a deliberately safe message for the failures you want the model to see. - A retry created a duplicate. The tool is not idempotent (like
create_note). Mark itidempotentHint=Falseso a well-behaved client will not auto-retry it, and give it an idempotency key if duplicates must be impossible. ModuleNotFoundError: No module named 'server'under pytest.tests/needs an__init__.pyso pytest puts the project root onsys.path.- A destructive tool runs without a prompt. Check
destructiveHint=TrueandreadOnlyHint=False. A missing or wrong hint is the usual cause; the client cannot prompt for what it was not told.
Recap
The tools here are trivial CRUD, but each one tells the truth about itself: a
title and four behavioral hints, a docstring aimed at the model, a verb_noun
name, and error handling that distinguishes a helpful ToolError result from a
masked internal failure and from a protocol-level rejection. That is what lets a
client auto-run a search, confirm a delete, avoid retrying a create, and surface
useful errors.
Next improvements:
- Add real authorization and confirm the read-only hints still match what each tool can do under different identities.
- Add an
openWorldHint=Truetool that calls an external API and watch how a client treats it differently. - Pair these annotations with structured output schemas so each tool advertises both its semantics and the exact shape it returns.