MCP Weather Workshop
by kriwet8
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