Skip to main content
Glama
README.md
# MCP Weather Workshop

Build your own MCP server, step by step. By the end you'll have a server that exposes tools to an AI agent — including one that calls a real weather API using your own API key, without ever exposing that key to the model.

Each level adds **one** new idea. Build them in order, and run each one before moving on — seeing it work is what makes the concept stick.

## What you'll build

A small MCP server with a weather tool. Along the way you'll learn what tools, resources, and transports are, and how to hand an agent a capability backed by a secret API key while keeping that secret inside your server.

## Prerequisites

**1. Install `uv`** — it manages Python and your dependencies for you.

macOS / Linux:

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

Windows (PowerShell):

```powershell
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
```

Restart your terminal afterwards.

**2. Node.js** — the MCP Inspector (the UI we'll use to test the server) runs through `npx`, so you need Node installed. Get it from [nodejs.org](https://nodejs.org) and check with `node --version`.

**3. A free WeatherAPI.com key** — needed from Level 3 onward. Sign up at [weatherapi.com](https://www.weatherapi.com) (free tier: 1,000,000 calls/month) and copy your key.

## Quick start

```bash
# 1. Get the project (or click "Use this template" on GitHub, then clone your copy)
git clone <your-repo-url>
cd mcp-weather-workshop

# 2. Install dependencies (uv creates the virtual environment automatically)
uv sync

# 3. Add your weather key
cp .env.example .env                      # then paste your key into .env
export WEATHER_API_KEY="b3e980f7ed364003965112544262506"    # Windows PowerShell: $env:WEATHER_API_KEY="b3e980f7ed364003965112544262506"

# 4. Run the starter server in the Inspector
uv run mcp dev server.py
```

`server.py` starts at Level 1 — edit it as you go. Complete versions of every level are in `solutions/` if you want to check your work.

### Using the Inspector UI

`uv run mcp dev server.py` starts two things: your server, and a small web app — the **MCP Inspector** — that connects to it. Watch the terminal for a line like `🔍 MCP Inspector is up and running at http://localhost:6274` and open that URL (it usually opens automatically). If it shows a session token, the terminal also prints a ready-to-use link with the token already in it — open that one. Then:

1. **Connect.** In the left sidebar the transport is **STDIO** and the command/args are already filled in. Click **Connect** — the status dot turns green once the handshake succeeds.
2. **List the tools.** Open the **Tools** tab and click **List Tools**. Every `@mcp.tool()` in your server appears here. That's *discovery*: the agent learns what's available purely from the names, descriptions, and argument types you wrote.
3. **Call one.** Click a tool (e.g. `add`), fill in its arguments in the form on the right, and press **Run Tool**. The result shows in the panel below. That's *invocation*.
4. **Iterate.** After you edit `server.py`, click **Restart** (or stop the command with `Ctrl+C` and re-run it), then **List Tools** → **Run Tool** again to see what changed.

The Inspector also has **Resources** and **Prompts** tabs — you'll use Resources in Level 6. You'll repeat this connect → list → call loop after every level; seeing it work is what makes each concept stick.

---

## Level 1 — one tool

The smallest possible server: a `FastMCP` instance and a single tool. A tool is just a normal function with a decorator. The function's **type hints and docstring** are what the model reads to decide whether and how to call it.

```python
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("weather-workshop")

@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b

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

Run `uv run mcp dev server.py`, then call `add` in the Inspector.

## Level 2 — more tools, richer inputs

A server can expose many tools, and the model picks the right one based on the descriptions. Parameters can be other types and can have defaults.

```python
@mcp.tool()
def greet(name: str, formal: bool = False) -> str:
    """Greet a person. Set formal=True for a formal greeting."""
    return f"Good day, {name}." if formal else f"Hi {name}!"
```

(Add this alongside `add`.) The model only knows what the names and descriptions say — which is why clear descriptions matter.

## Level 3 — call a real API

Now the tool calls an external service. Two new things: the function is `async`, and it uses `httpx` to make the request. The API needs a key — for now we'll paste it straight into the code. (This is the wrong way to handle a secret; the next level fixes it.)

```python
import httpx
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("weather-workshop")

API_KEY = "paste-your-key-here"   # bad practice on purpose — fixed in Level 4

@mcp.tool()
async def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            "https://api.weatherapi.com/v1/current.json",
            params={"q": city, "key": API_KEY},
        )
        data = resp.json()
    current = data["current"]
    return f"{city}: {current['temp_c']} C, {current['condition']['text']}"

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

Paste your real key, run it, and call `get_weather` with a city like `Oslo`.

## Level 4 — keep the key out of the code

Hardcoding a secret is a habit worth breaking: it leaks the moment you commit it, and anyone reading the code can see it. Move it to an environment variable instead. The only change is where the key comes from:

```python
import os

API_KEY = os.environ["WEATHER_API_KEY"]   # the key lives here, in the server
```

Now the key lives outside the code, never gets committed, and — most importantly — **the model never sees it.** The agent calls `get_weather("Oslo")` and gets back a sentence; the secret stays inside your server. This is the whole reason to wrap an API in an MCP server: you grant the capability without handing over the credential, and you can rotate the key in one place.

Set the key before running:

```bash
export WEATHER_API_KEY="your-key-here"     # PowerShell: $env:WEATHER_API_KEY="your-key-here"
```

## Level 5 — return structured data

So far the tool returned a string. Instead, return a typed object. Because the return type is a Pydantic model, the framework generates an output schema automatically and returns clean, predictable fields — easy to consume in code or render in a UI.

```python
from pydantic import BaseModel

class WeatherReport(BaseModel):
    city: str
    temp_c: float
    condition: str

@mcp.tool()
async def get_weather(city: str) -> WeatherReport:
    """Get the current weather for a city."""
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            "https://api.weatherapi.com/v1/current.json",
            params={"q": city, "key": API_KEY},
        )
        data = resp.json()
    current = data["current"]
    return WeatherReport(
        city=city,
        temp_c=current["temp_c"],
        condition=current["condition"]["text"],
    )
```

The model still gets a readable result, but a frontend now receives structured fields it can render directly.

## Level 6 — make it robust

Three production touches at once: fail clearly if the key is missing, handle API errors so the agent gets a useful message instead of a crash, and log to **stderr** (on the default transport, anything printed to stdout corrupts the protocol). This level also adds a **resource** — read-only data the agent can load on demand.

```python
import os
import sys
import logging
import httpx
from mcp.server.fastmcp import FastMCP

logging.basicConfig(level=logging.INFO, stream=sys.stderr)   # stderr, never stdout
log = logging.getLogger("weather")

mcp = FastMCP("weather-workshop")

API_KEY = os.environ.get("WEATHER_API_KEY")
if not API_KEY:
    raise RuntimeError("WEATHER_API_KEY is not set")

@mcp.tool()
async def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    try:
        async with httpx.AsyncClient(timeout=10) as client:
            resp = await client.get(
                "https://api.weatherapi.com/v1/current.json",
                params={"q": city, "key": API_KEY},
            )
            resp.raise_for_status()
    except httpx.HTTPError as exc:
        log.warning("weather lookup failed for %s: %s", city, exc)
        return f"Sorry, I couldn't fetch the weather for {city} right now."
    current = resp.json()["current"]
    return f"{city}: {current['temp_c']} C, {current['condition']['text']}"

@mcp.resource("cities://examples")
def example_cities() -> str:
    """A few cities to try with get_weather."""
    return "Oslo\nBergen\nTrondheim\nLondon"

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

`print()` here would silently break the server — that's the classic first bug. And returning a friendly error string means the model can relay the problem instead of the whole call failing.

## Level 7 — connect it to a real agent

Two ways to connect.

**Locally (stdio)** — the agent launches your script as a subprocess. Point its config at the command and inject the key through the environment. To wire it into **Claude Desktop**:

1. Open the config file (create it if it doesn't exist):
   - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
   - **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
2. Add the server, using an **absolute path** to this project so `uv` runs in the right place. Copy `claude_desktop_config.example.json` as a starting point:

   ```json
   {
     "mcpServers": {
       "weather": {
         "command": "uv",
         "args": ["--directory", "/absolute/path/to/mcp-weather-workshop", "run", "server.py"],
         "env": { "WEATHER_API_KEY": "your-key-here" }
       }
     }
   }
   ```
3. **Fully quit and reopen** Claude Desktop (a window close isn't enough — it has to restart).
4. In a new chat, ask *"What's the weather in Oslo?"*. You'll see a tool-use indicator as the agent calls `get_weather`, then the answer. If the server doesn't appear, check Claude Desktop's MCP logs (macOS: `~/Library/Logs/Claude/`).

**Over the network (HTTP)** — run it as a service and point the agent at the URL. Use this to share one server with many agents:

```python
if __name__ == "__main__":
    mcp.run(transport="streamable-http")   # http://localhost:8000/mcp
```

Either way, the key is supplied to the server process and never passes through the model.

## Level 8 — test it from code

Connecting a real agent is satisfying, but for a fast feedback loop you want to drive the server yourself — no Inspector, no desktop app. That's just an **MCP client**, and you can write one in a few lines. `solutions/level_8.py` launches your `server.py` over stdio and runs the exact handshake every client does:

```python
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

server = StdioServerParameters(
    command="uv", args=["run", "server.py"], env={**os.environ},
)

async with stdio_client(server) as (read, write):
    async with ClientSession(read, write) as session:
        await session.initialize()                  # the MCP handshake
        await session.list_tools()                  # discovery
        await session.call_tool("get_weather", {"city": "Oslo"})   # invocation
```

Run it (with `server.py` at Level 3+ and your key set):

```bash
uv run python solutions/level_8.py
```

You'll see the tool list followed by the live weather for Oslo. This is the same `initialize → list_tools → call_tool` sequence the Inspector and Claude Desktop perform — now it's yours to script in tests or any other Python program.

---

## Running in Docker

Prefer not to install `uv` and Python on your machine? Build the workshop into an image and run everything inside a container — all you need is Docker.

```bash
docker build -t mcp-weather-workshop .
```

**Check it end-to-end (no UI).** Run the Level 8 test client inside the container; it launches the server and calls the weather tool in one process:

```bash
docker run --rm -e WEATHER_API_KEY="$WEATHER_API_KEY" \
  mcp-weather-workshop uv run python solutions/level_8.py
# PowerShell: -e WEATHER_API_KEY="$env:WEATHER_API_KEY"
```

**Use the container as the server for a real client.** A client speaks stdio to a *command* — and that command can be `docker run`. Point the Inspector or Claude Desktop at the image instead of a local script; the `-i` flag keeps stdin open for the protocol:

```json
{
  "mcpServers": {
    "weather": {
      "command": "docker",
      "args": ["run", "--rm", "-i", "-e", "WEATHER_API_KEY", "mcp-weather-workshop"],
      "env": { "WEATHER_API_KEY": "your-key-here" }
    }
  }
}
```

(In the Inspector, set the command to `docker`, the args to `run --rm -i -e WEATHER_API_KEY mcp-weather-workshop`, and add a `WEATHER_API_KEY` environment variable.)

To edit `server.py` and re-run without rebuilding, bind-mount your code: add `-v "$(pwd)":/app` to the `docker run` command. The image keeps its dependencies in `/opt/venv` (outside `/app`), so the mount won't shadow them. The key is never baked into the image — `.dockerignore` keeps `.env` out of the build, and `-e` passes it at run time, the same keep-the-secret-in-the-server principle as Level 4.

## Where to go next

- Add a second real tool so the model has to choose between them.
- Try a deliberately vague tool description and watch the model misuse it — then fix it. It's the most memorable lesson of the day.
- Explore the other endpoints WeatherAPI offers (forecast, astronomy, air quality) and expose them as new tools.

## Project layout

- `server.py` — your working file (starts at Level 1)
- `solutions/level_1.py` … `level_6.py` — complete reference server for each level
- `solutions/level_8.py` — a small MCP client that tests your server from code (Level 8)
- `.env.example` — copy to `.env` and add your key
- `claude_desktop_config.example.json` — config for connecting a local agent (Level 7)
- `Dockerfile` / `.dockerignore` — run the workshop in a container (see "Running in Docker")

TDQS

A3.6/5.0

Scored across 1 tool

Disambiguation5/5

Only one tool exists, so there is no risk of confusion between tools.

Naming Consistency5/5

With a single tool, naming consistency is not an issue; the name 'add' is simple and clear.

Tool Count1/5

The server is named 'Weather Workshop' but has only one trivial arithmetic tool, which is completely mismatched to its apparent purpose.

Completeness1/5

The server's domain implies weather-related functionality, yet the single tool is an unrelated addition operation, leaving the entire weather domain uncovered.

Maintenance

ActivityInactive
ResponsivenessNo issues