MCP Weather Workshop
A step-by-step workshop for building an MCP server that provides weather tools to AI agents, enabling secure API key usage.
README
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:
curl -LsSf https://astral.sh/uv/install.sh | sh
Windows (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.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:
- 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 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:
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.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
- macOS:
-
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/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:
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):
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.
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")
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。