MCP Weather Workshop
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@MCP Weather Workshopwhat's the weather in Tokyo?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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.
Related MCP server: Weather Service MCP
Prerequisites
1. Install uv — it manages Python and your dependencies for you.
macOS / Linux:
curl -LsSf https://astral.sh/uv/install.sh | shWindows (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 and check with node --version.
3. A free WeatherAPI.com key — needed from Level 3 onward. Sign up at weatherapi.com (free tier: 1,000,000 calls/month) and copy your key.
Quick start
# 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.pyserver.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:
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.
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.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.Iterate. After you edit
server.py, click Restart (or stop the command withCtrl+Cand 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.
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.
@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.)
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:
import os
API_KEY = os.environ["WEATHER_API_KEY"] # the key lives here, in the serverNow 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:
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.
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.
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:
Open the config file (create it if it doesn't exist):
macOS:
~/Library/Application Support/Claude/claude_desktop_config.jsonWindows:
%APPDATA%\Claude\claude_desktop_config.json
Add the server, using an absolute path to this project so
uvruns in the right place. Copyclaude_desktop_config.example.jsonas a starting point:{ "mcpServers": { "weather": { "command": "uv", "args": ["--directory", "/absolute/path/to/mcp-weather-workshop", "run", "server.py"], "env": { "WEATHER_API_KEY": "your-key-here" } } } }Fully quit and reopen Claude Desktop (a window close isn't enough — it has to restart).
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:
if __name__ == "__main__":
mcp.run(transport="streamable-http") # http://localhost:8000/mcpEither 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:
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"}) # invocationRun it (with server.py at Level 3+ and your key set):
uv run python solutions/level_8.pyYou'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.
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:
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:
{
"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 levelsolutions/level_8.py— a small MCP client that tests your server from code (Level 8).env.example— copy to.envand add your keyclaude_desktop_config.example.json— config for connecting a local agent (Level 7)Dockerfile/.dockerignore— run the workshop in a container (see "Running in Docker")
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Tools
- addA
Latest Blog Posts
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/kriwet8/mcp-weather-workshop'
If you have feedback or need assistance with the MCP directory API, please join our Discord server