Skip to main content

FastMCP Tutorial: Build Your First MCP Server in Python

Short answer

FastMCP is a Python framework for building MCP servers, and it runs on the official MCP Python SDK. Install it with uv add "fastmcp>=4.1.1", decorate each function you want to expose with @mcp.tool, and call mcp.run() at the bottom of the file. The server below has two tools in 75 lines, and it runs over stdio, so a desktop AI app can start it the same way it starts any local MCP server.

Tested withMCP spec 2026-07-28FastMCP 4.1.1Python SDK (mcp) 2.3.0MCP Inspector 2.11.0Python 3.14Checked 20 Oct 2026

What you'll have at the end​

A notes server with two tools, add_note and list_notes. This is the output from MCP Inspector after saving one note and listing the high-priority ones:

{
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "notes",
"version": "4.1.1"
},
"fastmcp": {
"wrap_result": true
}
},
"content": [
{
"type": "text",
"text": "1. [high] Ship the guide"
}
],
"structuredContent": {
"result": "1. [high] Ship the guide"
},
"isError": false
}

Your code only declares the tools. FastMCP reads the function signatures and docstrings to build the tool schemas, and the MCP Python SDK handles the protocol underneath.

Before you start

  • Python: Python 3.10 or later and uv. FastMCP 4.1.1 requires Python 3.10 or later.
  • For testing: Node.js 22.19 or later, which MCP Inspector needs.
  • Basic comfort with a terminal.
  • To connect it at the end: Claude Desktop, or any other app from MCP clients.

1. Install FastMCP​

Create a project and add FastMCP to it:

uv init notes
cd notes
uv add "fastmcp>=4.1.1"

uv add records the dependency in pyproject.toml, which ends up with a line like this:

pyproject.toml (dependencies)
dependencies = [
"fastmcp>=4.1.1",
]

uv init creates a main.py file. Delete it, and create notes.py in the next step. Check the install with:

uv run fastmcp version

The output lists the FastMCP version, the MCP SDK version and your Python version. FastMCP's docs say breaking changes can land in minor releases, so once your server works, pin the exact version (fastmcp==4.1.1) in pyproject.toml.

2. Write the server​

Create notes.py. Every tool is an ordinary Python function with type hints, and the decorator turns it into a tool.

notes.py
import json
import logging
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Annotated, Literal

from fastmcp import FastMCP
from pydantic import Field

# Logs go to stderr. Anything written to stdout corrupts the stdio transport.
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
logger = logging.getLogger("notes")

Priority = Literal["low", "normal", "high"]

mcp = FastMCP("notes")


def notes_file() -> Path:
return Path(os.environ.get("NOTES_FILE", Path.home() / ".notes-fastmcp.json"))


def load_notes() -> list[dict]:
path = notes_file()
if not path.exists():
return []
return json.loads(path.read_text())


def save_notes(notes: list[dict]) -> None:
notes_file().write_text(json.dumps(notes, indent=2))


@mcp.tool
def add_note(
text: Annotated[
str,
Field(description="The note to save, in plain text", min_length=1, max_length=500),
],
priority: Annotated[
Priority, Field(description="How urgent the note is")
] = "normal",
) -> str:
"""Save a short note for the user."""
notes = load_notes()
notes.append(
{
"id": len(notes) + 1,
"text": text,
"priority": priority,
"created": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
)
save_notes(notes)
logger.info("Saved note %d", len(notes))
return f"Saved note {len(notes)}."


@mcp.tool(annotations={"readOnlyHint": True})
def list_notes(priority: Priority | None = None) -> str:
"""List saved notes, oldest first.

Args:
priority: Show only notes with this priority. Leave out to show every note.
"""
notes = [n for n in load_notes() if priority is None or n["priority"] == priority]
if not notes:
return "No notes match."
return "\n".join(f"{n['id']}. [{n['priority']}] {n['text']}" for n in notes)


if __name__ == "__main__":
mcp.run()

A few things in this file do the work:

  • FastMCP("notes") names the server. Clients show that name in their list of servers.
  • @mcp.tool registers the function. The function name becomes the tool name, and the docstring becomes the tool's description. The bare form works, and @mcp.tool() works too.
  • Annotated[str, Field(description=...)] sets the description of one parameter. FastMCP also reads a docstring's Args: section, as list_notes does. An explicit Field description wins over a docstring entry for the same parameter.
  • min_length and max_length are checked before your function runs. A Literal type becomes an enum in the schema the model sees.
  • annotations={"readOnlyHint": True} marks list_notes as a tool that doesn't modify its environment. It's a hint about the tool, not a permission check. Tool annotations covers the other hints.
  • mcp.run() starts the server on stdio. Passing transport="http" would serve the same tools over HTTP instead.
Keep stdout clean

Over stdio, standard output carries the protocol messages, so a stray print() puts text that isn't a protocol message into that stream, and the client may report an error or disconnect. FastMCP writes its logs and startup banner to stderr, and this file sends its own logs there too. Use logging, not print(), in your code.

3. Test it in Python​

FastMCP's Client can connect to a server object in the same process, with no subprocess and no network. Save this as check_inmemory.py next to notes.py:

check_inmemory.py
import asyncio
import os
import tempfile
from pathlib import Path

from fastmcp import Client
from fastmcp.exceptions import ToolError

from notes import mcp


async def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
os.environ["NOTES_FILE"] = str(Path(tmp) / "notes.json")

async with Client(mcp) as client:
tool_names = sorted(tool.name for tool in await client.list_tools())
assert tool_names == ["add_note", "list_notes"], tool_names

saved = await client.call_tool(
"add_note", {"text": "Ship the guide", "priority": "high"}
)
assert saved.data == "Saved note 1.", saved.data

listed = await client.call_tool("list_notes", {"priority": "high"})
assert "[high] Ship the guide" in listed.data, listed.data

empty = await client.call_tool("list_notes", {"priority": "low"})
assert empty.data == "No notes match.", empty.data

try:
await client.call_tool("add_note", {"text": ""})
except ToolError:
pass
else:
raise AssertionError("empty note was accepted")


if __name__ == "__main__":
asyncio.run(main())

It uses a temporary notes file, so your own notes are untouched. Run it with:

uv run python check_inmemory.py

The log lines it prints are expected. The check passes when the command exits with status 0 and no AssertionError.

Then check the server over stdio with MCP Inspector, once per protocol era. FastMCP 4 serves the current 2026-07-28 protocol and the older handshake from one server, so both runs should list the same two tools:

npx @modelcontextprotocol/inspector --cli uv run notes.py --protocol-era modern --method tools/list
npx @modelcontextprotocol/inspector --cli uv run notes.py --protocol-era legacy --method tools/list

The output is a JSON object with a tools array. Each tool has its inputSchema, so look for text and priority under add_note, and the docstring text under list_notes.

Save a note, then list the high-priority ones:

npx @modelcontextprotocol/inspector --cli uv run notes.py --protocol-era modern \
--method tools/call --tool-name add_note \
--tool-arg text="Ship the guide" --tool-arg priority=high

npx @modelcontextprotocol/inspector --cli uv run notes.py --protocol-era modern \
--method tools/call --tool-name list_notes --tool-arg priority=high

On a new notes file, the first call returns Saved note 1., and the second returns the note, as shown at the top of this guide. Your notes are saved to ~/.notes-fastmcp.json. To use another file, set NOTES_FILE before you start the server.

tip

Leave out --cli to open the Inspector's web interface, where you can pick a tool and fill in its parameters with a form. See Test with MCP Inspector.

4. Connect it to an AI app​

Claude Desktop starts local servers from its config file. Open that file, using the location for your system in Connect to Claude Desktop, and add the server under mcpServers. Use the absolute path to your notes folder:

claude_desktop_config.json
{
"mcpServers": {
"notes": {
"command": "uv",
"args": [
"--directory",
"/ABSOLUTE/PATH/TO/notes",
"run",
"notes.py"
]
}
}
}

Save the file, quit Claude Desktop completely and reopen it. Then ask it to "save a high priority note: ship the guide", and in a new chat ask "what notes do I have?". Claude asks for your permission before it calls a tool, unless you've already allowed it.

FastMCP can also write this entry for you. Run uv run fastmcp install claude-desktop notes.py from the project folder. It edits claude_desktop_config.json directly and adds your server under mcpServers, with a uv run command that starts it. The file must already contain JSON, so if it's missing or empty, create it with {} first. Expect the new entry to include some keys set to null, such as cwd and timeout. You can leave them as they are.

To confirm what FastMCP sees before you connect anything, run:

uv run fastmcp inspect notes.py

The output lists the server name, its version and the number of tools. Here, Tools shows 2.

What FastMCP adds over the official SDK​

FastMCP 4 is built on the official MCP Python SDK, version 2. The protocol is the same, and the SDK's own high-level server class started as FastMCP 1.0. The FastMCP docs list what the framework adds on top:

  • A client and a test story. The Client in step 3 calls tools in-process. The FastMCP docs also cover pytest fixtures for the same pattern.
  • Authentication providers. One auth= argument takes a provider, such as JWTVerifier, RemoteAuthProvider or OAuthProxy, plus named providers for services like GitHub and Google.
  • Composition and proxying. Mount one server inside another, or put a FastMCP server in front of an existing MCP server.
  • Middleware and tool transformation. Run code around every request, and rename or reshape tools without changing the function behind them.
  • OpenAPI integration. Generate a server from an existing API specification.
  • A CLI. fastmcp run, fastmcp inspect and fastmcp install start, inspect and register servers.

Pick the official SDK when your server is a handful of tools, resources and prompts, and you want the reference implementation with the fewest extras. Pick FastMCP when you want the client, the auth providers, composition or the CLI, or when a team is standardizing on one framework. The two APIs look alike, so moving a server from one to the other is mostly renaming. The build an MCP server guide uses the official SDK for the same kind of server, so you can compare the two side by side.

Common problems​

1 validation error for call[add_note] with String should have at least 1 character. The model sent an empty text, and the min_length=1 check rejected it before add_note ran. This is the intended behavior. If it keeps happening, make the text description say what goes in the field.

Input should be 'low', 'normal' or 'high'. The priority value isn't one of the three allowed values. The Literal type makes those values an enum in the schema, so the model sees the list. Clients that let the model pick arguments will usually choose a valid value once the description is clear.

ModuleNotFoundError: No module named 'fastmcp'. The script ran with a Python that doesn't have FastMCP installed, usually a system Python instead of the project's environment. Start it with uv run from the project folder, or use the --directory form in the Claude Desktop config.

The server works in MCP Inspector but disconnects in an app. Something is writing to stdout. Look for print() calls in your code and in the libraries it imports, and switch them to logging. Fix MCP connection errors covers the rest of the causes.

FAQ​

Is FastMCP the same as the official MCP Python SDK?

No, but they are related. FastMCP is a separate, actively maintained framework built on the official SDK. The protocol is the same for both, and FastMCP adds a client, authentication providers, composition, middleware and a CLI on top.

Which FastMCP version should I install?

Install the latest release from PyPI and pin it once your server works. This guide was tested with FastMCP 4.1.1 and Python 3.14. Breaking changes can land in minor releases, so a pinned version is safer than a range.

Do I need to host a FastMCP server online?

No. A server that runs over stdio, like the one in this guide, runs on your own computer, and the AI app starts it for you. You only need hosting if other people or web-based apps need to reach it. See Deploy a remote MCP server.

Can FastMCP servers work with clients that use the older protocol?

Yes. FastMCP 4 serves the 2026-07-28 protocol and the older handshake from one server, so a client on either protocol can connect. The two Inspector runs in step 3 check both.