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 mcp package had from 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 as from mcp.server import MCPServer, and the old path now raises ModuleNotFoundError. The decorators carry over unchanged.
  • The standalone project. fastmcp on PyPI, maintained by Prefect, imported as from 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

Terminal
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.

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. Annotated with a Pydantic Field adds a per-argument description and limits; min_length=3 reaches the model as "minLength": 3, and the SDK validates arguments before your function runs.
  • The return type becomes an output schema, so clients get structuredContent as well as text. list_tasks returns a dictionary, which arrives as JSON.
  • Raising ToolError returns a result with isError: true and 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_hint tells 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 logging module, which writes to standard error, rather than print.

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

Terminal
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.

Terminal
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.

check_server.py
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.

Terminal
# 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.

server.py (fastmcp 4)
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.

Questions people ask.

Is FastMCP the same as the official MCP Python SDK?

Not any more. FastMCP 1.0 was folded into the official SDK, where the class was called FastMCP until version 2 renamed it MCPServer. The standalone fastmcp package on PyPI is a separate project maintained by Prefect, now at version 4, with extra features. Both build standard MCP servers.

Why do I get ModuleNotFoundError: No module named mcp.server.fastmcp?

You have version 2 of the mcp package and code written for version 1. Change the import to from mcp.server import MCPServer and create MCPServer("name") instead of FastMCP("name"), or pin mcp below 2 until you migrate.

Which Python version does the MCP SDK need?

Python 3.10 or newer, for both the official mcp package and standalone fastmcp. The example in this guide was run on Python 3.12.

Do I need uv to build an MCP server in Python?

No. pip install "mcp[cli]" works. uv is recommended because uv run --with lets a client such as Claude Code start your server from any folder without a virtual environment to activate.

Start with one thing.

There is nothing to set up first. Write one line and you’ve started.