Learn Build MCP Servers: Extend Claude with Custom Tools Your First MCP Server: Python

Your First MCP Server: Python

Advanced 🕐 25 min Lesson 7 of 15
What you'll learn
  • Build a Python MCP server using FastMCP and the @mcp.tool() decorator
  • Use Python type hints and docstrings to define tool input schemas and descriptions automatically
  • Connect a Python MCP server to Claude Desktop using the uv command in the mcpServers config

What You Will Build

This lesson rebuilds the weather server from Lesson 06 in Python using FastMCP — the modern, decorator-based Python SDK for MCP. The server exposes the same two tools: get_alerts for fetching active weather alerts by US state, and get_forecast for retrieving a forecast by latitude and longitude.

By the end you will have a fully working Python MCP server connected to Claude Desktop, and a clear picture of how the Python experience differs from TypeScript.

Project Setup with uv

uv is the recommended way to manage Python MCP projects. It handles virtual environments, dependency installation, and running your server — all in one tool. If you do not have it yet, install it with:

curl -LsSf https://astral.sh/uv/install.sh | sh

Create and initialise your project:

uv init weather
cd weather
uv venv
uv add "mcp[cli]" httpx

The mcp[cli] extra installs FastMCP plus the mcp dev command-line tool you will use during development. httpx is the async HTTP client used to call the NWS API.

Creating the FastMCP Instance

Open weather.py (or create it in the project root) and start with the imports and the FastMCP instance:

import httpx
import sys
from mcp.server.fastmcp import FastMCP

NWS_API_BASE = "https://api.weather.gov"
USER_AGENT = "weather-app/1.0"

mcp = FastMCP("weather")

The string passed to FastMCP() is the server name — it appears in Claude Desktop's tool list. That is the entire server setup. There is no transport configuration, no explicit server.connect() call, and no stdio boilerplate. FastMCP handles all of that when you call mcp.run() at the end.

The @mcp.tool() Decorator

In the TypeScript SDK you call server.registerTool() with an explicit name, description, and Zod input schema. In FastMCP you just decorate an async function:

@mcp.tool()
async def get_alerts(state: str) -> str:
    """Get weather alerts for a US state. Input is a two-letter state code (e.g. CA, NY)."""
    ...

FastMCP derives everything it needs from the function automatically:

  • Tool name — the function name (get_alerts)
  • Input schema — the Python type hints (state: str becomes a required string parameter)
  • Description — the docstring (this is what the AI model reads when deciding whether to call the tool)

Write docstrings as instructions to the model: "Use this tool when you need X." A vague docstring leads to the model calling the wrong tool or skipping yours entirely.

The Full Weather Server

Here is the complete implementation with both tools and the shared HTTP helper:

async def make_nws_request(client: httpx.AsyncClient, url: str) -> dict | None:
    headers = {"User-Agent": USER_AGENT, "Accept": "application/geo+json"}
    try:
        response = await client.get(url, headers=headers)
        response.raise_for_status()
        return response.json()
    except Exception:
        return None

@mcp.tool()
async def get_alerts(state: str) -> str:
    """Get weather alerts for a US state. Input is a two-letter state code (e.g. CA, NY)."""
    async with httpx.AsyncClient() as client:
        data = await make_nws_request(client, f"{NWS_API_BASE}/alerts/active/area/{state.upper()}")
    if not data:
        return "Failed to retrieve alerts data"
    features = data.get("features", [])
    if not features:
        return f"No active alerts for {state.upper()}"
    alerts = []
    for f in features:
        p = f["properties"]
        alerts.append(
            f"Event: {p.get('event','Unknown')}
"
            f"Area: {p.get('areaDesc','Unknown')}
"
            f"Severity: {p.get('severity','Unknown')}
"
            f"Headline: {p.get('headline','No headline')}"
        )
    return "
---
".join(alerts)

@mcp.tool()
async def get_forecast(latitude: float, longitude: float) -> str:
    """Get weather forecast for a location. Input is latitude and longitude as decimals."""
    async with httpx.AsyncClient() as client:
        points = await make_nws_request(
            client, f"{NWS_API_BASE}/points/{latitude:.4f},{longitude:.4f}"
        )
    if not points:
        return "Failed to retrieve location data"
    forecast_url = points["properties"].get("forecast")
    if not forecast_url:
        return "No forecast URL found"
    async with httpx.AsyncClient() as client:
        forecast = await make_nws_request(client, forecast_url)
    if not forecast:
        return "Failed to retrieve forecast"
    periods = forecast["properties"]["periods"][:5]
    return "
---
".join(
        f"{p.get('name','?')}:
"
        f"{p.get('temperature','?')}°{p.get('temperatureUnit','F')}, "
        f"{p.get('windSpeed','?')}
{p.get('shortForecast','')}"
        for p in periods
    )

Notice that get_forecast uses float type hints for latitude and longitude. FastMCP turns these into JSON Schema number fields automatically — the model can pass 37.7749 directly without any string parsing on your end.

Logging: Always Use stderr

MCP servers communicate over stdout using JSON-RPC messages. If your code prints anything else to stdout — a debug message, a stray print() call — the client will fail to parse the response and the server will appear broken.

Always send log output to stderr:

print("Debug: connecting to NWS API", file=sys.stderr)

Claude Desktop captures stderr from each server process and writes it to a log file. You can read it to diagnose issues without disrupting the JSON-RPC stream.

Running the Server

Add the entry point at the bottom of weather.py:

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

During development, use the MCP Inspector:

uv run mcp dev weather.py

This opens a browser-based tool where you can call your tools directly without involving Claude Desktop. It is the fastest way to verify your server is working before wiring it up to a client.

To run the server directly:

uv run weather.py

Connecting to Claude Desktop

Open your Claude Desktop config file (~/Library/Application Support/Claude/claude_desktop_config.json on macOS) and add your server:

{
  "mcpServers": {
    "weather": {
      "command": "uv",
      "args": [
        "run",
        "--with", "mcp",
        "mcp", "run",
        "/ABSOLUTE/PATH/TO/weather.py"
      ]
    }
  }
}

A few important points about this config:

  • The path to weather.py must be absolute. Claude Desktop does not know your working directory.
  • If the uv command is not found, use its absolute path: /Users/yourname/.cargo/bin/uv or wherever which uv reports.
  • The --with mcp flag ensures the mcp package is available even if your project's virtual environment is not activated.

Restart Claude Desktop after saving. If the server connects successfully, you will see a hammer icon in the chat interface showing the available tools.

Python vs TypeScript: Key Differences

Having built the same server in both languages, the differences come down to these practical points:

  • Schema definition: TypeScript uses Zod (explicit, verbose, powerful). Python uses type hints (concise, derived automatically).
  • Setup boilerplate: TypeScript requires creating a server, defining transport, and calling server.connect(transport). FastMCP needs only FastMCP("name") and mcp.run().
  • Running: TypeScript compiles to JS first (npm run build). Python runs directly (uv run weather.py).
  • Claude Desktop config: TypeScript uses "command": "node" pointing to the compiled JS. Python uses "command": "uv" with the run args shown above.

Both approaches produce identical MCP protocol messages on the wire. Claude Desktop cannot tell the difference — it speaks JSON-RPC regardless of what language generated it.

What to Try Next

Once your weather server is running in Claude Desktop, try these extensions:

  1. Add a third tool: get_radar that returns a radar image URL for a given station code.
  2. Change the state parameter type to Literal["CA", "NY", "TX", ...] to restrict it to known state codes — FastMCP will turn this into a JSON Schema enum.
  3. Add print("Server started", file=sys.stderr) at the top of the file, restart Claude Desktop, and verify the message appears in ~/Library/Logs/Claude/mcp-server-weather.log.

Verified: This Code Works

This server was tested end-to-end on Python 3.13 / uv 0.11.18 / mcp 1.27.2 on macOS. The setup commands (uv init, uv venv, uv add "mcp[cli]" httpx), the @mcp.tool() decorators, and mcp.run() all worked exactly as documented.

get_alerts (Texas): Returned 5 live alerts including a Flood Warning (Severe) in McMullen County, a Flood Watch across far west Texas, and Flood Advisories near Houston — all from separate NWS forecast offices, confirming the state-based API lookup works correctly.

Protocol verification: The JSON-RPC initialization handshake and tools/list response both matched the MCP specification. Both tools appeared with correct input schemas derived automatically from Python type hints.

Note on schemas: Basic type hints produce a schema without per-field descriptions. To add descriptions like "Two-letter US state code (e.g. CA, NY)" to each parameter, use Annotated[str, Field(description="...")] — covered in Lesson 9.

Key takeaways
  • FastMCP's @mcp.tool() decorator turns any Python async function into an MCP tool — type hints become the input schema, the docstring becomes the description
  • Install with `uv add 'mcp[cli]'` and run with `uv run weather.py` or `mcp dev weather.py` for development
  • The Claude Desktop config uses 'command': 'uv' with args ['run', '--with', 'mcp', 'mcp', 'run', '/path/to/server.py'] for Python servers
  • Never print to stdout in an MCP server — use print(..., file=sys.stderr) for all logging; stdout is reserved for JSON-RPC messages
  • Python's FastMCP is more concise than the TypeScript SDK: no explicit transport setup or server.connect() needed — mcp.run() handles all of that