Building an MCP Server in Python With FastMCP
A working MCP server in Python with two tools, run over stdio and HTTP, tested in the MCP Inspector and connected to Claude Code. Plus the naming trap: FastMCP in the official SDK is now called MCPServer, and the standalone FastMCP is a separate project.
8 min read
To build an MCP server in Python, install the official SDK with uv add "mcp[cli]", create an MCPServer, and turn plain functions into tools with the @mcp.tool() decorator: the type hints become the input schema and the docstring becomes the description. mcp.run() serves it over stdio for a local client; mcp.run(transport="streamable-http") serves it at http://127.0.0.1:8000/mcp. Test it with uv run mcp dev server.py, which opens the MCP Inspector, then add it to Claude Code with claude mcp add. The code below was run against mcp 2.2.0 and, for the last section, fastmcp 4.0.10, on Python 3.12.
This is the Python version of the build. How to design tools that assistants choose and call well is covered in how to build an MCP server, and the client side in Python is in building an MCP client in Python.
First, which FastMCP?
The name now means two things, and tutorials mix them up.
- The class in the official SDK. Version 1 of the
mcppackage hadfrom mcp.server.fastmcp import FastMCP. Version 2, the current stable line, renamed it: the SDK’s What’s new in v2 (opens in a new tab) page gives the new import asfrom mcp.server import MCPServer, and the old path now raisesModuleNotFoundError. The decorators carry over unchanged. - The standalone project.
fastmcpon PyPI, maintained by Prefect, imported asfrom fastmcp import FastMCP, currently at version 4. Its README says FastMCP 1.0 was folded into the official SDK in 2024; the standalone project kept going and adds its own features on top of the protocol, such as proxying other servers and transforming tools.
Both produce ordinary MCP servers that any client can use, and for a simple server the code is almost the same. The main example uses the official SDK, which moves with the specification and supports the 2026-07-28 revision and every earlier one. The standalone version follows at the end.
1. Set up the project
uv init tasks-mcp && cd tasks-mcp uv add "mcp[cli]" # or: pip install "mcp[cli]" uv run mcp version # check you are on 2.x
It needs Python 3.10 or newer. The cli extra adds the mcp command (mcp dev, mcp run, mcp install). Check the version before copying code from anywhere else: because pip install mcp now installs 2.x, a version 1 tutorial will fail on its first import.
2. Write a server with two tools
A small task list: one tool reads, one writes. The dictionary stands in for your product’s API. Save it as server.py.
from typing import Annotated
from pydantic import Field
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
mcp = MCPServer("tasks")
# Stands in for your product's API or database.
TASKS: dict[str, dict] = {"T-1": {"title": "Write release notes", "done": False}}
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def list_tasks(
open_only: Annotated[bool, Field(description="True to leave out finished tasks.")] = True,
) -> dict[str, str]:
"""List tasks by id. Call this before adding one, to avoid duplicates."""
return {
task_id: task["title"]
for task_id, task in TASKS.items()
if not (open_only and task["done"])
}
@mcp.tool()
def add_task(
title: Annotated[str, Field(min_length=3, max_length=120, description="What needs doing, in a few words.")],
) -> str:
"""Add a task and return its id, e.g. T-2."""
if any(t["title"].lower() == title.lower() for t in TASKS.values()):
raise ToolError(f"A task called {title!r} already exists. Call list_tasks to find its id.")
task_id = f"T-{len(TASKS) + 1}"
TASKS[task_id] = {"title": title, "done": False}
return f"Added {task_id}: {title}"
if __name__ == "__main__":
mcp.run()- The function name is the tool name, the docstring is its description, and the type hints are its input schema.
Annotatedwith a PydanticFieldadds a per-argument description and limits;min_length=3reaches the model as"minLength": 3, and the SDK validates arguments before your function runs. - The return type becomes an output schema, so clients get
structuredContentas well as text.list_tasksreturns a dictionary, which arrives as JSON. - Raising
ToolErrorreturns a result withisError: trueand your sentence, which the model reads and can act on. The SDK’s error handling guide (opens in a new tab) is firm that you should never return an error message as a normal string: it looks like success. read_only_hinttells a client the tool changes nothing, which some use to skip a confirmation. It is a hint, not a security control.- On stdio, standard output is the protocol channel. Use the
loggingmodule, which writes to standard error, rather thanprint.
3. Run it over stdio
python server.py prints nothing and waits: it is listening on standard input for a client to speak first. That is the normal state of a stdio server, and it is how Claude Code, Claude Desktop, Cursor and VS Code will run it, as a child process they start themselves. uv run mcp run server.py does the same without executing the __main__ block; it finds the module-level object called mcp, server or app.
4. Run it over HTTP
uv run mcp run server.py --transport streamable-http # or, in server.py: mcp.run(transport="streamable-http", port=8000) # clients connect to http://127.0.0.1:8000/mcp
The SDK’s guide to running a server (opens in a new tab) lists the defaults: host 127.0.0.1, port 8000, path /mcp. Transport options such as port and json_response=True go to run(), never to MCPServer(...), which raises a TypeError if you try. The same server answers clients on the 2026-07-28 revision and older ones that still start with initialize. The difference between the two transports, and why SSE is not a third option for new work, is in MCP transports. Before this leaves localhost it needs sign-in: see how to add authentication to an MCP server.
5. Test it
uv run mcp dev server.py starts the server under the MCP Inspector, which needs npx on your PATH, and opens a page where you can call each tool with a form built from its schema. For scripted checks, call the Inspector’s CLI directly. With the server above, calling add_task with a title that already exists printed the ToolError sentence with isError: true and exited with code 5. A one-letter title fails the min_length check and also comes back with isError: true. The full guide is the MCP Inspector.
npx @modelcontextprotocol/inspector --cli python server.py --method tools/list
npx @modelcontextprotocol/inspector --cli python server.py \
--method tools/call --tool-name add_task --tool-args-json '{"title":"Write release notes"}'For tests that run with the rest of your suite, hand the server object to the SDK’s Client. It connects in memory, with no process and no port, as the SDK’s testing page shows.
import anyio
from mcp import Client
from server import mcp
async def main() -> None:
async with Client(mcp) as client:
print([t.name for t in (await client.list_tools()).tools])
result = await client.call_tool("add_task", {"title": "Update the changelog"})
print(result.structured_content) # {'result': 'Added T-2: Update the changelog'}
refused = await client.call_tool("add_task", {"title": "write release notes"})
assert refused.is_error, "a duplicate should be refused"
anyio.run(main)6. Connect it to Claude Code
Everything after -- is the command Claude Code runs to start the server. The SDK’s guide to connecting a real host (opens in a new tab) recommends uv run --with, which resolves the SDK on the spot, so it works from any folder without activating an environment. Use an absolute path.
# stdio: Claude Code starts the server itself claude mcp add tasks -- uv run --with "mcp[cli]" mcp run /absolute/path/to/tasks-mcp/server.py # or, if it is already running over HTTP claude mcp add --transport http tasks http://127.0.0.1:8000/mcp
Run /mcp inside Claude Code to see it connected with two tools, then ask “list my open tasks, then add one to tag the release”. If Claude Code cannot find uv, replace it with the full path from which uv or where uv: clients start servers with a minimal PATH. For Claude Desktop, uv run mcp install server.py writes the entry into its configuration file for you.
The same server with standalone FastMCP
With uv add fastmcp, only the imports and the decorators change; the file below is the same server. @mcp.tool works without brackets, ToolError comes from fastmcp.exceptions, and annotations can be a plain dictionary. The function bodies are identical, and the same Inspector checks gave the same results.
from typing import Annotated
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from pydantic import Field
mcp = FastMCP("tasks")
TASKS: dict[str, dict] = {"T-1": {"title": "Write release notes", "done": False}}
@mcp.tool(annotations={"readOnlyHint": True})
def list_tasks(
open_only: Annotated[bool, Field(description="True to leave out finished tasks.")] = True,
) -> dict[str, str]:
"""List tasks by id. Call this before adding one, to avoid duplicates."""
return {
task_id: task["title"]
for task_id, task in TASKS.items()
if not (open_only and task["done"])
}
@mcp.tool
def add_task(
title: Annotated[str, Field(min_length=3, max_length=120, description="What needs doing, in a few words.")],
) -> str:
"""Add a task and return its id, e.g. T-2."""
if any(t["title"].lower() == title.lower() for t in TASKS.values()):
raise ToolError(f"A task called {title!r} already exists. Call list_tasks to find its id.")
task_id = f"T-{len(TASKS) + 1}"
TASKS[task_id] = {"title": title, "done": False}
return f"Added {task_id}: {title}"
if __name__ == "__main__":
mcp.run() # or: mcp.run(transport="http", port=8000)Its own command line runs and installs the server: fastmcp run server.py:mcp, and fastmcp install claude-code server.py, which calls claude mcp add for you, as the FastMCP Claude Code guide (opens in a new tab) describes. Choose the official SDK if you want the reference implementation that tracks the specification; choose standalone FastMCP if you need the extras it adds. You can move a simple server between them in minutes.
Where the work on it goes
A server grows a list quickly: tools to add, descriptions the model misreads, a refusal that needs a better sentence. fenbs, a task board where AI assistants are members with roles, is one place to keep that list, and it is itself a remote MCP server at https://fenbs.ai/api/mcp. Connect Claude Code to it with claude mcp add --transport http fenbs https://fenbs.ai/api/mcp, and the assistant that finds a problem while testing your Python server can file it as a bug with fenbs_create_item, with the Inspector command that reproduces it in the note. Every change it makes is recorded in the board’s History under its name.
Related
Testing and debugging with the Inspector: the MCP Inspector. stdio or HTTP: MCP transports. Tool design, with a TypeScript example: how to build an MCP server. Connecting fenbs to Claude Code: Claude Code integration.