Building an MCP server in Python using FastMCP

Kashish Hora

Kashish Hora

Co-founder of AgentCat

Try out AgentCat

First, the thing that trips people up: there are two FastMCPs

If you search "fastmcp" you will land on two different things, and until the mcp 2.0.0 release on 2026-07-28 they even shared a class name — a leading reason Python MCP tutorials don't run. Get this straight before you pip install anything.

1. The in-SDK MCPServer ships inside the official MCP Python SDK (the mcp package, currently 2.0.0; the final 1.x release is 1.29.0). You import it as:

from mcp.server.mcpserver import MCPServer

Before the 2026-07-28 rename, this same class was called FastMCP and lived at mcp.server.fastmcp. On mcp<2 that's still the spelling you'll see — but that old import path is removed, not deprecated, in mcp 2.0.0, so a plain pip install mcp today gives you MCPServer, not FastMCP. The decorator API (@mcp.tool() and friends) carries over unchanged across the rename.

This is the ergonomic layer Anthropic adopted into the official SDK. It is stable and fine for a basic server, but it doesn't carry the full standalone feature set (below).

2. The standalone fastmcp package is jlowin's independent project (pip install fastmcp, currently 3.4.5, docs at gofastmcp.com). You import it as:

from fastmcp import FastMCP

This project keeps the FastMCP name — it's a separate codebase with its own versioning, unaffected by the official SDK's rename. It's also where active development happens: on top of the decorator API the in-SDK version also has, it adds server composition, middleware, auth providers, a built-in test client, OpenAPI generation, and first-class deployment tooling. (It's pinned to mcp<2.0 under the hood — see the version note below — so it doesn't pick up whatever mcp 2.0.0 itself adds.)

The recommendation: unless you have a hard constraint to depend only on the official mcp package, use the standalone fastmcp. It has better docs and a broader feature set than the in-SDK API — auth, middleware, OpenAPI generation, deployment tooling — and it's what most tutorials and templates target. The rest of this guide uses the standalone package and flags the few places the in-SDK API differs.

One thing to weigh in that choice: fastmcp 3.4.5 pins mcp<2.0, so it ships the legacy (pre-2026-07-28) SDK line — which happens to be what essentially every shipping client still speaks today. The in-SDK MCPServer on mcp 2.0.0 is the line that targets the 2026-07-28 revision, but client support for it is still rolling out, so the legacy-line recommendation above is also the interoperable choice right now, not just the ergonomic one.

One subtle trap once you've chosen: the two have different decorator and transport spellings. Standalone uses a bare @mcp.tool and transport="http"; the in-SDK version uses @mcp.tool() and transport="streamable-http". Copy a snippet from the wrong set of docs into the wrong package and it won't behave as written.

In-SDK MCPServerStandalone fastmcp
Installpip install mcppip install fastmcp
Importfrom mcp.server.mcpserver import MCPServerfrom fastmcp import FastMCP
Tool decorator@mcp.tool()@mcp.tool
HTTP transport argtransport="streamable-http"transport="http"
Version (Jul 2026)mcp 2.0.0 (final 1.x: 1.29.0)fastmcp 3.4.5
In-memory test Clientmcp.client.Client(server) as of mcp 2.0.0 (1.x had no equivalent)✓, and the more established option
Middleware / auth / OpenAPInot in the in-SDK set

Before the 2026-07-28 rename (mcp<2, e.g. 1.29.0), the in-SDK row reads: class FastMCP, imported from mcp.server.fastmcp. Same decorator API either way.

Prerequisites

  • Python 3.10 or higher. Both fastmcp and the mcp SDK require >=3.10. (gofastmcp.com installation, PyPI: fastmcp)
  • Comfort with type hints. FastMCP reads your annotations to build the tool's input schema, so they are load-bearing, not decoration.
  • uv is recommended (FastMCP's own tooling shells out to it), but pip works.

Installation

# pip
$pip install fastmcp
 
# or uv (recommended)
$uv add fastmcp

Verify it, and note what the version command prints. Alongside the FastMCP version it reports the bundled mcp library version, handy when a client rejects your server over a version mismatch:

$fastmcp version

A real server, not add(a, b)

Here is a small but genuine server: it looks up timezone-aware current time for a city and exposes the list of supported cities as a resource. It needs nothing beyond fastmcp and the standard library, and it's already stdio-safe: logging goes to stderr and the unknown-city case raises ToolError (both explained below).

# server.py
import json
import logging
import sys
from datetime import datetime
from zoneinfo import ZoneInfo

from fastmcp import FastMCP
from fastmcp.exceptions import ToolError

# stdout is the stdio protocol channel — route all logging to stderr (see below).
logging.basicConfig(
    level=logging.INFO,
    stream=sys.stderr,
    format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)

mcp = FastMCP("Time Server")

# A tiny curated map so the LLM doesn't have to guess IANA names.
CITY_TZ = {
    "new york": "America/New_York",
    "london": "Europe/London",
    "tokyo": "Asia/Tokyo",
    "sydney": "Australia/Sydney",
}


@mcp.tool
def current_time(city: str) -> dict:
    """Return the current local time for a supported city."""
    key = city.strip().lower()
    tz_name = CITY_TZ.get(key)
    if tz_name is None:
        # ToolError's message is always surfaced to the client/model (see below).
        raise ToolError(
            f"Unknown city {city!r}. Supported: {', '.join(sorted(CITY_TZ))}."
        )
    now = datetime.now(ZoneInfo(tz_name))
    return {
        "city": city,
        "timezone": tz_name,
        "iso8601": now.isoformat(),
        "weekday": now.strftime("%A"),
    }


@mcp.resource("timezones://supported")
def supported_cities() -> str:
    """The cities this server knows about."""
    # Resource functions return str/bytes — serialize structured data yourself.
    return json.dumps(sorted(CITY_TZ))


if __name__ == "__main__":
    mcp.run()  # stdio transport by default

The @mcp.tool decorator turns the function signature into an MCP tool: parameter names and type hints become the JSON Schema clients use to call it, and the docstring becomes the tool description the model reads. (gofastmcp.com quickstart, tools)

Decorator spelling, since this is the most copy-pasted line: the standalone package accepts a bare @mcp.tool. The in-SDK MCPServer expects @mcp.tool() with parentheses — unchanged across the FastMCP→MCPServer rename. (python-sdk README)

Running it: stdio for local, Streamable HTTP for remote

Two transports are worth using in 2026. stdio is the default and is what local clients (Claude Desktop, Cursor, Claude Code) launch. For remote/networked servers the transport is Streamable HTTP.

If you find a tutorial telling you to use transport="sse", stop. The two-endpoint HTTP+SSE transport has been deprecated since the 2025-03-26 spec revision; Streamable HTTP (a single endpoint, usually /mcp) replaced it. FastMCP still ships an sse transport for back-compat with old clients, but it's legacy and you should not reach for it on new work. (gofastmcp.com running the server)

if __name__ == "__main__":
    # Local (default):
    mcp.run()

    # Remote over Streamable HTTP — note transport="http", NOT "sse":
    # mcp.run(transport="http", host="127.0.0.1", port=8000)

With HTTP, the server is reachable at http://localhost:8000/mcp/ (the default path is /mcp/; override it with path="/api/mcp/"). (gofastmcp.com HTTP deployment)

You can also launch from the CLI, which imports your server object rather than executing the if __name__ == "__main__" block, so you point it at file:object:

$fastmcp run server.py:mcp # stdio
$fastmcp run server.py:mcp --transport http --port 8000 # Streamable HTTP

In-SDK difference: if you're on the in-SDK MCPServer (from mcp.server.mcpserver import MCPServer; before the 2026-07-28 rename this was FastMCP at mcp.server.fastmcp), the HTTP transport argument is spelled transport="streamable-http", not "http". For production HTTP deployments, pass stateless_http=True and json_response=True to run() or streamable_http_app() — e.g. mcp.run(transport="streamable-http", stateless_http=True, json_response=True), or mcp.streamable_http_app(stateless_http=True, json_response=True) when mounting into an existing ASGI app. On mcp<2 these were FastMCP(...) constructor arguments; mcp 2.0.0 moved all transport parameters off the constructor. (python-sdk docs/migration.md)

The silent stdio killer: never print()

Over stdio, stdout is the protocol channel. Every byte written there must be valid JSON-RPC. A single stray print() (or a chatty library that logs to stdout on import) injects a non-JSON line into the stream, and the client drops the connection with an unhelpful generic error like "MCP server disconnected." It works perfectly when you run the script by hand and fails the instant a client attaches, which is what makes it so maddening to diagnose.

The fix is to send all human-readable output to stderr, never stdout:

import logging
import sys

logging.basicConfig(
    level=logging.INFO,
    stream=sys.stderr,  # critical: NOT stdout
    format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger("time-server")

Inside a tool, the MCP Context (next section) also has logging methods that route messages to the client as structured log notifications instead of touching stdout at all — the stderr rule above still holds regardless of which one you reach for. Sending output to stderr is the same in every language SDK; it's a property of the transport, not of FastMCP. (Debugging MCP stdio transports)

One caveat on Context logging specifically: MCP's Logging feature is deprecated as of the 2026-07-28 revision (SEP-2577) — logging/setLevel is gone, a server must not emit log notifications unless the client opted in per request, and the spec's own migration path is stderr for stdio plus OpenTelemetry for observability. ctx.info/ctx.debug/etc. still work today (fastmcp's mcp<2.0 pin means they will for a while), but stderr is the channel to actually depend on.

Error handling: surfacing ToolError to the model

FastMCP distinguishes between errors you want the model to see and internal failures you want to hide. Raise ToolError for the former: its message is always delivered to the client regardless of the mask_error_details setting. Any other exception is treated as internal; with mask_error_details=True the client sees a generic message instead of your stack trace.

from fastmcp import FastMCP
from fastmcp.exceptions import ToolError

mcp = FastMCP("Secure Server", mask_error_details=True)


@mcp.tool
def divide(a: float, b: float) -> float:
    """Divide a by b."""
    if b == 0:
        # Deliberately surfaced to the client/model:
        raise ToolError("b must not be zero.")
    return a / b

ToolError lives at fastmcp.exceptions.ToolError, and mask_error_details is a real FastMCP(...) constructor argument. (gofastmcp.com tools, server)

Watch out: rate_limit and dependencies are not valid FastMCP() constructor arguments. Rate limiting is middleware; dependencies are declared via the CLI's --with flags or fastmcp.json.

Using Context for logging, progress, and resource access

Add a parameter type-hinted as Context to any tool and FastMCP injects it at call time. It's how a tool talks back to the client mid-execution: structured logs, progress updates, and reading other resources. In the standalone package these methods are async, so the tool must be async and you must await them:

from fastmcp import FastMCP, Context

mcp = FastMCP("Indexer")


@mcp.tool
async def index_documents(paths: list[str], ctx: Context) -> dict:
    """Index a batch of documents, reporting progress as it goes."""
    indexed = 0
    for i, path in enumerate(paths):
        await ctx.info(f"Indexing {path}")
        # ... do the work ...
        indexed += 1
        await ctx.report_progress(progress=i + 1, total=len(paths))
    return {"indexed": indexed}

Context is imported from fastmcp, and ctx.info / ctx.debug / ctx.warning / ctx.error plus ctx.report_progress(progress, total) are the documented methods. (gofastmcp.com tools)

A note on the related sampling feature (a tool asking the client's LLM to generate text): sampling is client-driven. The server requests a completion and the client decides which model runs it. You do not, and cannot reliably, pin a model id like "claude-3-5-sonnet-..." server-side. Older tutorials that hardcode a model in a sampling call misrepresent how the sampling contract works.

Testing without a client app

The standalone package's Client connects directly to your server object with no subprocess, no transport, and no network, so tool tests run in milliseconds under pytest. As of mcp 2.0.0 the in-SDK line has an equivalent (mcp.client.Client(server), replacing v1's create_connected_server_and_client_session), but the standalone Client predates it, is the more battle-tested option, and is the one used below.

# test_server.py
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError
from server import mcp


@pytest.mark.asyncio
async def test_current_time():
    async with Client(mcp) as client:
        result = await client.call_tool("current_time", {"city": "Tokyo"})
        # result.data is the deserialized return value of the tool:
        assert result.data["timezone"] == "Asia/Tokyo"
        assert "iso8601" in result.data


@pytest.mark.asyncio
async def test_unknown_city_errors():
    async with Client(mcp) as client:
        # FastMCP surfaces a raised ToolError to the caller as a ToolError.
        with pytest.raises(ToolError, match="Unknown city"):
            await client.call_tool("current_time", {"city": "Atlantis"})

Client is importable straight from fastmcp, you pass the server object directly, and the call result exposes the tool's return value on .data. You'll need pytest-asyncio installed. (gofastmcp.com testing)

Run it:

$ fastmcp version
FastMCP version:   3.4.5
MCP version:       1.29.0
Python version:    3.12.3

$ pytest -q
..                                                                       [100%]
2 passed in 0.33s

That MCP version line is the bundled mcp package, not the protocol revision — and it's 1.x by design: fastmcp 3.4.5 pins mcp<2.0, so pip install fastmcp can never resolve mcp 2.0.0. If your output shows a different 1.x number than 1.29.0, that's still normal; just don't expect to ever see 2.x here on this line of fastmcp.

Installing into a client

For local clients, FastMCP's CLI writes the config for you, with no hand-editing JSON. It looks for a server object named mcp, server, or app, and --with adds dependencies the launched process needs:

$fastmcp install claude-desktop server.py
$fastmcp install claude-code server.py --with httpx
$fastmcp install cursor server.py

(gofastmcp.com install, Claude Code integration)

If you'd rather configure Claude Desktop by hand, the entry uses uv run so the server launches inside an environment that has fastmcp available:

{
  "mcpServers": {
    "time-server": {
      "command": "uv",
      "args": ["run", "--with", "fastmcp", "fastmcp", "run", "/abs/path/to/server.py"]
    }
  }
}

The config file lives at ~/Library/Application Support/Claude/claude_desktop_config.json on macOS and %APPDATA%\Claude\claude_desktop_config.json on Windows. (gofastmcp.com Claude Desktop)

Which FastMCP, one more time

The decision in one line: pick the standalone fastmcp unless a dependency policy forces you onto the official mcp SDK alone. Whichever you choose, use Streamable HTTP (not SSE) for remote servers, keep stdout clean on stdio, and let your type hints carry the schema.

Once it's running against real clients the questions turn operational: which tools actually get called, what arguments clients send, where calls error or stall over stdio vs. HTTP. That visibility is the gap AgentCat fills for production MCP servers.