Skip to content
Loading

Build an MCP Server with Python in 15 Minutes

Build an MCP Server with Python in 15 Minutes hero image

This tutorial walks through building a working MCP weather server in Python. By the end you will have two tools, get_alerts and get_forecast, registered in Claude for Desktop and callable from chat. The data comes from the free National Weather Service API, no API key needed.

Requirements: Python 3.10+, MCP SDK 1.2.0+, Claude for Desktop.


Step 1: Set Up the Project with uv

The MCP team recommends uv for Python project management. Install it first, then scaffold the project:

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

# Create project and enter it
uv init weather
cd weather

# Create and activate a virtual environment
uv venv
source .venv/bin/activate  # Windows: .venv\Scripts\activate

# Install dependencies
uv add "mcp[cli]" httpx

# Create the server file
touch weather.py

mcp[cli] includes FastMCP and the STDIO transport. httpx handles async HTTP calls to the NWS API.


Step 2: Initialize the Server

Open weather.py and add the imports and server instance:

from typing import Any

import httpx
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("weather")

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

FastMCP("weather") sets the server name displayed in MCP client UIs. FastMCP also inspects your decorated functions at startup, reading type hints and docstrings to auto-generate the JSON Schema tool definitions. You never write a schema by hand.


Step 3: Add HTTP Helper Functions

Add these two functions below the constants. They handle the NWS API calls and keep the tool functions focused on logic:

async def make_nws_request(url: str) -> dict[str, Any] | None:
    """Make a request to the NWS API with proper error handling."""
    headers = {"User-Agent": USER_AGENT, "Accept": "application/geo+json"}
    async with httpx.AsyncClient() as client:
        try:
            response = await client.get(url, headers=headers, timeout=30.0)
            response.raise_for_status()
            return response.json()
        except Exception:
            return None


def format_alert(feature: dict) -> str:
    """Format an alert feature into a readable string."""
    props = feature["properties"]
    return f"""
Event: {props.get("event", "Unknown")}
Area: {props.get("areaDesc", "Unknown")}
Severity: {props.get("severity", "Unknown")}
Description: {props.get("description", "No description available")}
Instructions: {props.get("instruction", "No specific instructions provided")}
"""

make_nws_request returns None on any error rather than raising, so tools can return a human-readable fallback message instead of crashing the session.


Step 4: Implement the Tools

Decorate each async function with @mcp.tool(). The docstring is what Claude reads when deciding whether and how to call the tool, so write it like API documentation, not an inline comment.

@mcp.tool()
async def get_alerts(state: str) -> str:
    """Get weather alerts for a US state.

    Args:
        state: Two-letter US state code (e.g. CA, NY)
    """
    url = f"{NWS_API_BASE}/alerts/active/area/{state}"
    data = await make_nws_request(url)

    if not data or "features" not in data:
        return "Unable to fetch alerts or no alerts found."

    if not data["features"]:
        return "No active alerts for this state."

    alerts = [format_alert(feature) for feature in data["features"]]
    return "\n---\n".join(alerts)


@mcp.tool()
async def get_forecast(latitude: float, longitude: float) -> str:
    """Get weather forecast for a location.

    Args:
        latitude: Latitude of the location
        longitude: Longitude of the location
    """
    points_url = f"{NWS_API_BASE}/points/{latitude},{longitude}"
    points_data = await make_nws_request(points_url)

    if not points_data:
        return "Unable to fetch forecast data for this location."

    forecast_url = points_data["properties"]["forecast"]
    forecast_data = await make_nws_request(forecast_url)

    if not forecast_data:
        return "Unable to fetch detailed forecast."

    periods = forecast_data["properties"]["periods"]
    forecasts = []
    for period in periods[:5]:
        forecast = f"""
{period["name"]}:
Temperature: {period["temperature"]}°{period["temperatureUnit"]}
Wind: {period["windSpeed"]} {period["windDirection"]}
Forecast: {period["detailedForecast"]}
"""
        forecasts.append(forecast)

    return "\n---\n".join(forecasts)

get_forecast makes two requests: first to /points/{lat},{lng} to resolve the NWS grid square for that location, then to the returned forecast URL for the actual periods. The NWS API does not accept coordinates directly on the forecast endpoint, so the resolution step is required.


Step 5: Add the Entry Point

Append the main function and the __main__ guard at the bottom of weather.py:

def main():
    mcp.run(transport="stdio")


if __name__ == "__main__":
    main()

transport="stdio" tells FastMCP to communicate over stdin/stdout using JSON-RPC 2.0. This is the standard transport for local MCP servers.

Important: Never call print() inside a STDIO server. Anything written to stdout is parsed as a JSON-RPC message and will corrupt the session. Use print(..., file=sys.stderr) or the logging module instead, both write to stderr safely.


Step 6: Verify the Server Starts

Run the server directly to confirm there are no import or syntax errors:

uv run weather.py

The process will block, waiting for a client connection over stdin. That is correct behavior. Stop it with Ctrl+C and proceed.


Step 7: Register with Claude for Desktop

Open the Claude for Desktop config file in your editor:

# macOS
code ~/Library/Application\ Support/Claude/claude_desktop_config.json

# Windows
code $env:AppData\Claude\claude_desktop_config.json

Add the server under the mcpServers key. The path must be absolute:

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

If uv is not on Claude's $PATH (common on macOS), replace "uv" with the full executable path. Get it with:

which uv

Save the file and fully quit and relaunch Claude for Desktop. A hammer icon in the chat input bar confirms the tools are loaded. You can now ask Claude things like "Are there any weather alerts in California?" or "What is the forecast for 40.7128, -74.0060?" and it will call your server in real time.