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:

AnnotationMeaningExample
titleHuman-friendly display name“Delete note”
readOnlyHintDoes not modify any statea search or a lookup
destructiveHintMay perform destructive updates (only meaningful when not read-only)delete, overwrite
idempotentHintRepeating the call with the same arguments changes nothing furtherdelete by id, set a value
openWorldHintInteracts with systems outside the servera 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 pytest suite that asserts the annotations, idempotency, error masking, and the tool-error-versus-protocol-error distinction.
  • A make wrapper with a help screen.

Prerequisites

  • macOS 13+ with Homebrew (brew.sh).
  • uv 0.5+brew install uv; verify uv --version.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • 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

  • fastmcp is the only runtime dependency; it provides ToolAnnotations (via the mcp types) and the ToolError exception used below.
  • pytest-asyncio runs 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

  • Note is a Pydantic model, so a tool returning Note or list[Note] gets a structured output schema for free (see Return Structured Output from a FastMCP Server).
  • reset() re-seeds the store to notes with ids 1, 2, 3 and sets the next id to 4. 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 returns False instead of raising.

Step 4: Declare the tools with annotations

The four tools sit at the two ends of the annotation matrix:

  • search_notes and get_note are read-only and idempotent.
  • create_note writes and is not idempotent (a new id every call).
  • delete_note writes, 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=True on the server changes how unexpected exceptions are reported. A raw ValueError becomes a generic Error calling tool '<name>' instead of shipping its message (and any secrets in it) to the client. Messages you raise on purpose with ToolError are always shown, so this is safe to leave on in production.
  • ToolAnnotations carries the five hints. Set them honestly: a readOnlyHint that lies removes a confirmation prompt the user needed. Leaving a hint unset (its default None) means “unknown,” which a cautious client treats as the unsafe case — so state the ones you know.
  • search_notes / get_note are readOnlyHint=True, idempotentHint=True. These are the calls a client can run without asking.
  • get_note raises ToolError when the id is missing. That returns a result with isError set and your message intact, so the model reads “No note with id 99.” and can correct itself, rather than seeing a crash.
  • create_note is idempotentHint=False: calling it twice with the same arguments makes two notes. A client must not silently retry it on a timeout.
  • delete_note is destructiveHint=True, idempotentHint=True. Destructive earns a confirmation prompt; idempotent means a retry after a dropped response is safe. The DeleteResult.deleted flag reports whether anything was actually removed.
  • openWorldHint=False everywhere, because every tool operates only on our own store. Set it True on 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 at None, so the printout shows only the hints a tool actually declares.
  • raise_on_error=False on the get_note(99) call returns the error result instead of raising, so the client can inspect res.is_error and read the message — exactly what a model does when it decides whether to retry.
  • The two delete_note calls show idempotency directly: the first returns deleted=True, the retry deleted=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 = auto runs async def test_* without a marker.
  • tests/__init__.py makes tests/ a package, which puts the project root on sys.path so from server import mcp resolves.

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_store is autouse, so every test starts from ids 1, 2, 3.
  • test_delete_is_idempotent encodes the whole point of the idempotent hint: the second call is a successful no-op, not an error.
  • test_unexpected_exception_is_masked proves masking hides the ValueError text (including a fake secret) while still reporting an error.
  • test_missing_argument_is_a_protocol_error shows the other failure mode: an invalid call is rejected at the protocol layer and raises, because the tool body never runs. That is different from get_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 := help makes a bare make print the help screen. Recipe bodies must be tab-indented or make errors.

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_note and delete_note read as actions. A bare notes or data tells 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 on action="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 ToolError with 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 readOnlyHint to 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 None on the client. They were not set on the tool. Pass annotations=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 raise ToolError with 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 it idempotentHint=False so 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__.py so pytest puts the project root on sys.path.
  • A destructive tool runs without a prompt. Check destructiveHint=True and readOnlyHint=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=True tool 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.