The Model Context Protocol (MCP) is an open standard that lets AI clients (such as Claude Desktop, Claude Code, or your own agent) call tools, read resources, and load prompts from an external server over a well-defined wire protocol. FastMCP is a Python framework that hides the protocol plumbing behind plain decorators, so you write ordinary typed functions and FastMCP turns them into a compliant server.
In this tutorial you will build a small but realistic “notes” MCP server: it
exposes tools to create and search notes, a resource to read a single note by
URI, and a prompt that asks the model to summarize a note. You will run it over
stdio, inspect it interactively, and cover it with pytest using FastMCP’s
in-memory client.
Prerequisites
- Python 3.10 or newer (FastMCP requires 3.10+). Check with
python3 --version. - uv 0.5 or newer — the package and project manager used throughout this
tutorial. Install it from docs.astral.sh/uv,
then verify with
uv --version. - Basic familiarity with Python type hints. FastMCP uses them to generate the JSON schema each tool advertises to clients.
- Optional: an MCP client such as Claude Desktop if you want to call the server from a real model. The tests in this article do not require one.
We use uv (not pip) for every dependency and run step. If you have not used
uv before, the only commands you need appear inline in each step.
Step 1: Scaffold the project and lock down hygiene
Create the project workspace and a .gitignore before any other files, so
that generated virtual environments and caches are never committed.
Create the files
mkdir -p fastmcp-notes-server
cd fastmcp-notes-server
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
# uv
.uv/
# Tooling caches
.pytest_cache/
.ruff_cache/
.mypy_cache/
# OS / editor noise
.DS_Store
*.log
Detailed breakdown
__pycache__/and*.py[cod]keep compiled bytecode out of version control — it is regenerated on every run and is machine-specific..venv/is the virtual environmentuvcreates. It is large and fully reproducible from the lockfile, so it should never be committed..pytest_cache/is written bypytestbetween runs; the others (.ruff_cache/,.mypy_cache/) appear only if you later add those tools, but ignoring them now prevents accidental commits..DS_Store/*.logare common local artifacts on macOS and from ad-hoc logging. Creating this file first means the very next commands cannot leak build artifacts into a future commit.
Step 2: Initialize the project with uv
Turn the folder into a managed uv project. This generates a pyproject.toml
and a pinned uv.lock.
Create the file
uv init --name fastmcp-notes-server --python 3.10
uv init creates pyproject.toml, a sample main.py (which you will replace),
and a README.md. You can delete the sample file you will not use:
rm -f main.py
Add the code: pyproject.toml (generated, shown for reference)
[project]
name = "fastmcp-notes-server"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10"
dependencies = []
uv init creates an application-style project, so there is no [build-system]
block — you do not need one to run the server. You can edit the placeholder
description if you like.
Detailed breakdown
requires-python = ">=3.10"matches FastMCP’s minimum and is enforced byuvwhen it resolves dependencies, so contributors on older Pythons fail fast with a clear message.dependencies = []is empty for now; the next step fills it viauv addrather than hand-editing, which keepspyproject.tomlanduv.lockin sync.readme = "README.md"points at the fileuv initgenerated; the rest of the project layout (thetests/directory,server.py) you add by hand in later steps.
Step 3: Add the FastMCP dependency
Add FastMCP as a runtime dependency. uv add updates pyproject.toml, resolves
the dependency tree, and writes uv.lock in one step.
Install the dependency
uv add fastmcp
Detailed breakdown
uv add fastmcppulls in FastMCP and its transitive dependencies (including the officialmcpSDK andpydantic). After it runs,fastmcpappears under[project].dependenciesinpyproject.toml.- Because
uvrecords exact versions inuv.lock, anyone who clones the repo and runsuv syncgets the identical dependency set — no “works on my machine” drift. - Use
uv addfor every runtime dependency anduv add --devfor tools that are only needed during development (you will do that forpytestin Step 7).
Step 4: Write the MCP server
This is the core of the tutorial. The server defines two tools, a resource
template, and a prompt, all attached to a single FastMCP instance.
Create the file
touch server.py
Add the code: server.py
"""A minimal notes MCP server built with FastMCP."""
from fastmcp import FastMCP
# The server instance. The name is shown to clients during discovery.
mcp = FastMCP("Notes Server")
# In-memory store. Replace with a database for real use; a dict keeps the
# tutorial deterministic and dependency-free.
_NOTES: dict[int, dict[str, str]] = {}
_NEXT_ID = 1
@mcp.tool
def add_note(title: str, body: str) -> int:
"""Create a note and return its numeric id.
Args:
title: Short, human-readable label for the note.
body: The note's full text content.
"""
global _NEXT_ID
note_id = _NEXT_ID
_NOTES[note_id] = {"title": title, "body": body}
_NEXT_ID += 1
return note_id
@mcp.tool
def search_notes(query: str) -> list[dict[str, str | int]]:
"""Return notes whose title or body contains the query (case-insensitive)."""
q = query.lower()
return [
{"id": note_id, "title": note["title"]}
for note_id, note in _NOTES.items()
if q in note["title"].lower() or q in note["body"].lower()
]
@mcp.resource("notes://{note_id}")
def read_note(note_id: int) -> str:
"""Expose a single note as a readable resource addressed by its id."""
note = _NOTES.get(note_id)
if note is None:
raise ValueError(f"Note {note_id} does not exist")
return f"# {note['title']}\n\n{note['body']}"
@mcp.prompt
def summarize_note(note_id: int) -> str:
"""Generate a prompt asking the model to summarize a stored note."""
note = _NOTES.get(note_id)
if note is None:
raise ValueError(f"Note {note_id} does not exist")
return (
"Summarize the following note in two sentences:\n\n"
f"Title: {note['title']}\n\n{note['body']}"
)
if __name__ == "__main__":
# Default transport is stdio, which is what Claude Desktop and most
# local clients expect.
mcp.run()
Detailed breakdown
mcp = FastMCP("Notes Server")creates the server object. Every decorator below registers a capability on this single instance, and the name is what a client displays during discovery.@mcp.toolturns a function into a callable tool. FastMCP reads the type hints (title: str,body: str, returnint) to build the JSON schema the client sees, and the docstring becomes the tool’s description — so write clear signatures and docstrings, because the model uses them to decide how to call the tool.add_notemutates the module-level_NOTESdict and returns a new id. Usingglobal _NEXT_IDis fine for a single-process stdio server; for concurrent transports you would use a real datastore instead.search_notesreturns structured data (a list of dicts). FastMCP serializes the return value into the protocol’s structured-content format automatically, so the client receives typed results, not just a string.@mcp.resource("notes://{note_id}")registers a resource template. The{note_id}placeholder in the URI maps to the function parameter, so a client requestingnotes://1invokesread_note(note_id=1). Resources are for read-only data the model can pull into context, as opposed to tools, which perform actions.@mcp.promptregisters a reusable prompt. Returning a string yields a single user message; the client can surfacesummarize_noteas a slash command or template. RaisingValueErrorfor a missing note produces a clean protocol error instead of a stack trace.mcp.run()starts the event loop over stdio by default. This is the transport local clients launch as a subprocess. The next steps run and test exactly this entry point.
Step 5: Run and inspect the server
You now have a runnable server. There are two ways to exercise it.
Run it directly (stdio)
uv run python server.py
This starts the server and blocks, waiting for a client to speak MCP over
stdin/stdout. There is no human-readable banner — that silence is expected.
Press Ctrl+C to stop it. Running it this way confirms the file imports cleanly
and registers without errors.
Inspect it interactively
FastMCP ships a development inspector that launches a web UI so you can call tools and read resources by hand:
uv run fastmcp dev server.py
Detailed breakdown
uv runexecutes the command inside the project’s virtual environment without you having to activate it manually, guaranteeingfastmcpis on the path.python server.pytriggers theif __name__ == "__main__"block, so the server starts over stdio — the same way a client such as Claude Desktop would launch it.fastmcp devwraps the server with the MCP Inspector. Use it to verify thatadd_note,search_notes, thenotes://{note_id}resource, and thesummarize_noteprompt all appear and behave as expected before you wire the server into a real client.
Step 6: Add the test dependencies
Cover the server with automated tests. FastMCP provides an in-memory Client
that talks to your server object directly (no subprocess, no network), which
makes tests fast and deterministic.
Install the dev dependencies
uv add --dev pytest pytest-asyncio
Detailed breakdown
pytestis the test runner. Per project convention, tests live in atests/directory and use atest_prefix sopytestdiscovers them automatically.pytest-asynciois required because FastMCP’s client API is asynchronous; the test functions are coroutines and need an async-aware runner.--devrecords both under[dependency-groups].devinpyproject.toml, keeping them out of the runtime dependency set that consumers would install.
Step 7: Configure pytest for async tests
Tell pytest where the source lives and enable automatic async test handling so
you do not have to decorate every test.
Create the file
touch pytest.ini
Add the code: pytest.ini
[pytest]
pythonpath = .
asyncio_mode = auto
testpaths = tests
Detailed breakdown
pythonpath = .adds the project root tosys.path, sofrom server import mcpresolves when tests run fromtests/.asyncio_mode = autoletspytest-asynciotreat anyasync def test_...function as a coroutine test automatically, removing per-test@pytest.mark.asynciodecorators.testpaths = testsscopes discovery to thetests/directory, sopytestdoes not try to importserver.pyitself as a test module.
Step 8: Write the tests
Exercise each capability through the in-memory client, asserting on real return values.
Create the files
mkdir -p tests
touch tests/__init__.py
touch tests/test_server.py
Add the code: tests/test_server.py
"""Tests for the notes MCP server using FastMCP's in-memory client."""
import pytest
from fastmcp import Client
import server
@pytest.fixture(autouse=True)
def reset_store():
"""Reset the in-memory note store before each test for isolation."""
server._NOTES.clear()
server._NEXT_ID = 1
yield
async def test_add_and_search_note():
async with Client(server.mcp) as client:
created = await client.call_tool("add_note", {"title": "Groceries", "body": "milk, eggs"})
assert created.data == 1
found = await client.call_tool("search_notes", {"query": "eggs"})
assert found.data == [{"id": 1, "title": "Groceries"}]
async def test_read_note_resource():
async with Client(server.mcp) as client:
await client.call_tool("add_note", {"title": "Idea", "body": "Build an MCP server"})
result = await client.read_resource("notes://1")
assert "# Idea" in result[0].text
assert "Build an MCP server" in result[0].text
async def test_summarize_prompt():
async with Client(server.mcp) as client:
await client.call_tool("add_note", {"title": "Trip", "body": "Pack and book a hotel"})
result = await client.get_prompt("summarize_note", {"note_id": 1})
message_text = result.messages[0].content.text
assert "Summarize the following note" in message_text
assert "Trip" in message_text
Detailed breakdown
reset_storefixture clears the module-level dict and resets the id counter before each test. Because the store is global state, this guarantees tests do not leak notes into one another.autouse=Trueapplies it without naming it in each test.async with Client(server.mcp)connects the in-memory client directly to the server object. This runs the full MCP request/response cycle — schema validation included — without spawning a subprocess, so tests are fast and hermetic.call_tool(...).datareturns the deserialized structured result. Foradd_notethat is the integer id; forsearch_notesit is the list of dicts, which the assertion compares exactly.read_resource("notes://1")invokes the resource template; the result is a list of contents, and[0].textis the rendered Markdown theread_notefunction produced.get_prompt("summarize_note", ...)renders the prompt;messages[0]is the single user message and.content.textis its body. Asserting on the text confirms the placeholder substitution worked.
Run the tests
uv run pytest -v
You should see three passing tests. If pytest reports “no tests ran”, confirm
the files live in tests/ and start with test_.
Step 9: Add a Makefile with a help-first default
A Makefile gives contributors a discoverable set of commands. Per project
convention, running plain make must print a help screen that lists every
target.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help install dev run test
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
dev: ## Launch the MCP Inspector against the server
uv run fastmcp dev server.py
run: ## Run the server over stdio
uv run python server.py
test: ## Run the test suite
uv run pytest -v
Detailed breakdown
.DEFAULT_GOAL := helpmakes baremakerun thehelptarget, satisfying the rule thatmakewith no arguments prints a help screen.- The
helptarget scans theMakefilefor targets annotated with a## descriptioncomment and prints them in aligned, colored columns. Adding a new target with a## ...comment makes it appear automatically — no manual list to maintain. .PHONYdeclares that these targets are commands, not files, somakealways runs them even if a same-named file ever appears.install/dev/run/testwrap theuvcommands from earlier steps so contributors never have to remember the exact invocation. Every Python command goes throughuv runto stay inside the managed environment.
Validate the default target
make
Confirm the output lists help, install, dev, run, and test, each with
its description.
Step 10: Connect the server to a client (optional)
To call the server from Claude Desktop, install it into the client’s configuration:
uv run fastmcp install claude-desktop server.py
This registers the server in Claude Desktop’s config so the app launches it over
stdio on startup. Restart Claude Desktop, and the add_note and search_notes
tools, the notes://{note_id} resource, and the summarize_note prompt become
available in your conversations. Other clients (Claude Code, custom agents) accept
the same stdio command: uv run python server.py.
Troubleshooting
ModuleNotFoundError: No module named 'fastmcp'— you ranpythonoutside the managed environment. Always prefix commands withuv run, or runuv syncfirst.pytestcollects zero tests — async tests are being skipped. Confirmpytest.inicontainsasyncio_mode = autoand thatpytest-asynciois installed (uv add --dev pytest-asyncio).ImportErrorforserverin tests —pythonpath = .is missing frompytest.ini, so the root is not onsys.path.- The server “hangs” when run directly — that is correct.
uv run python server.pyblocks waiting for a client over stdio. Usefastmcp devfor interactive testing, orCtrl+Cto exit. - Claude Desktop does not show the server — fully quit and relaunch the app
after
fastmcp install, and check that the absolute path in its config points atserver.py.
Recap
You built a complete MCP server with FastMCP and Python:
- Scaffolded a
uv-managed project with hygiene (.gitignore) in place first. - Defined two tools (
add_note,search_notes), a resource template (notes://{note_id}), and a prompt (summarize_note) using plain decorated functions. - Ran the server over stdio and inspected it with
fastmcp dev. - Covered every capability with
pytestvia FastMCP’s in-memory client. - Added a help-first
Makefileso the workflow is self-documenting.
Next improvements
- Persist notes in SQLite or a JSON file so they survive restarts, replacing
the in-memory dict and its
globalcounter. - Add input validation with Pydantic models for richer schemas and better client-side error messages.
- Expose an HTTP transport with
mcp.run(transport="http")to serve remote clients instead of only local stdio. - Add structured logging and a
list_notesresource (notes://all) so clients can discover ids without searching.