openmeteo-mcp
# openmeteo-mcp
A reference-quality demo MCP server: **six narrow, well-typed weather tools**
over the free, **keyless** [Open-Meteo](https://open-meteo.com/) API.
This repo exists to demonstrate what senior MCP server craft looks like —
typed input schemas with range constraints, deliberate response sizing,
TTL caching matched to upstream update cadence, error messages written for
an LLM caller, hermetic tests, and CI. It is intentionally small enough to
read in one sitting (`src/` is ~690 lines).
Built on the official [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
(v2.x, `MCPServer`), served over stdio.
## Why Open-Meteo?
- **Zero credentials.** No API key, no signup, no env vars — anyone can clone
this repo and have a working MCP server in under a minute. For a public
demo, an auth-free upstream removes the single biggest source of setup
friction and secret-handling risk.
- **Real utility, four distinct services.** Geocoding, forecast, air quality,
and a historical archive (ERA5, back to 1940) under one provider. That
variety is exactly what rewards *narrow-tool* decomposition instead of one
god-tool with mode flags.
- **Well-behaved API surface.** Stable JSON, per-field units, and a
machine-readable `reason` on errors — which this server surfaces verbatim
so callers get actionable messages instead of opaque HTTP codes.
- **Generous free tier** (10,000 calls/day) — and this server stays far below
it thanks to TTL caching.
## Tools
| Tool | Purpose | Key inputs (validated) | Hard caps | Cache TTL |
|---|---|---|---|---|
| `geocode_location` | Place name → coordinates, country, timezone, population | `name` (1–100 chars), `max_results` (1–10) | 10 results | 24 h |
| `get_current_weather` | Current conditions + plain-language sky description | `latitude` (−90…90), `longitude` (−180…180) | — | 10 min |
| `get_daily_forecast` | Daily rows: highs/lows, precipitation, wind, UV, sunrise/sunset | coords, `days` (1–10), `temperature_unit` (`celsius`\|`fahrenheit`) | 10 days | 15 min |
| `get_hourly_forecast` | Hourly rows: temp, apparent temp, precip probability/amount, wind | coords, `hours` (1–72) | 72 h | 15 min |
| `get_air_quality` | US EPA AQI + PM2.5, PM10, ozone, NO₂ | coords, `hours` (1–48) | 48 h | 30 min |
| `get_historical_weather` | Archived daily weather (≈1940 → ~5 days ago) | coords, `start_date`/`end_date` (ISO `YYYY-MM-DD`) | 366 days/call | 24 h |
Every tool is annotated `readOnlyHint = true`, `destructiveHint = false`,
`idempotentHint = true`, `openWorldHint = true`.
### Design principles
- **Narrow tools, not a god-tool.** One tool per question a user actually
asks. Clients see small schemas; the LLM can't pick a wrong "mode".
- **Validation at the schema.** Ranges (`ge`/`le`), string lengths, regex date
patterns, and enum units are declared in the tool signature, so invalid
arguments are rejected *before any network call* — and the JSON Schema is
visible to clients in `tools/list`.
- **Response size discipline.** Columnar API arrays are zipped into compact
row dicts with units attached, lists are capped (and *defensively re-sliced*
even if upstream ignores the cap), geocoding drops junk fields like
`postcodes`, and WMO weather codes are expanded inline to
`weather_description` so no second lookup is needed.
- **TTL cache tuned to data cadence.** Current observations change every
~15 min → 10 min TTL; forecasts → 15 min; place metadata and archive data
are effectively immutable → 24 h. Bounded at 256 entries with
expire-first/FIFO eviction.
- **Errors written for an LLM.** Upstream `reason` text is surfaced verbatim;
timeouts/network failures explain what happened and suggest a retry;
validation failures name the field and the constraint.
## Quickstart
```bash
git clone <this-repo> && cd mcp-demo-weather
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
# hermetic test suite (56 tests, no network)
.venv/bin/python -m pytest
# real end-to-end check: spawns the server over stdio, performs the MCP
# handshake, lists tools, and makes live calls to Open-Meteo
.venv/bin/python scripts/verify_stdio.py
```
### Claude Desktop
`~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
"mcpServers": {
"open-meteo": {
"command": "/absolute/path/to/mcp-demo-weather/.venv/bin/python",
"args": ["-m", "openmeteo_mcp.server"]
}
}
}
```
### Cursor
`~/.cursor/mcp.json` (same shape):
```json
{
"mcpServers": {
"open-meteo": {
"command": "/absolute/path/to/mcp-demo-weather/.venv/bin/python",
"args": ["-m", "openmeteo_mcp.server"]
}
}
}
```
Once published, `uvx --from git+https://github.com/<you>/mcp-demo-weather openmeteo-mcp`
also works as the command (the console script runs the same stdio server).
## Architecture
```
src/openmeteo_mcp/
├── server.py # MCPServer instance + 6 tools: schema-typed inputs,
│ # response shaping/truncation, APIError → ToolError mapping
├── client.py # async httpx wrapper: one fixed base URL per Open-Meteo
│ # service, coordinate validation, per-endpoint TTL cache
│ # wiring, error normalization
├── cache.py # TTLCache: bounded, thread-safe, lazy expiry,
│ # expire-first-then-FIFO eviction (~90 lines, no deps)
└── wmo.py # WMO 4677 weather-code → plain-language table
```
Request flow: `tools/call` → pydantic schema validation → `OpenMeteoClient`
(cache hit? return) → `httpx GET` to a fixed allowlisted host → response
validated/normalized → zipped, truncated, annotated rows → structured JSON
back to the client. Logs go to stderr only (stdout carries the protocol).
```
tests/ # hermetic: respx mocks every upstream response
scripts/verify_stdio.py # live smoke: real handshake + real tool calls
.github/workflows/ci.yml # pytest on Python 3.11/3.12/3.13
```
## Security posture
- **Read-only.** Every tool is a lookup; nothing writes, executes, or
retains user data. Annotations declare this machine-readably.
- **No credentials, no env, no config.** There is nothing to leak; the
server works with a completely empty environment.
- **No URL injection surface.** Base URLs are module constants; caller input
only ever becomes typed query parameters (floats/ints/enums/date patterns).
A tool call cannot redirect the server to another host.
- **No tool-poisoning surface.** Tool names, descriptions, and annotations
are static code — never derived from upstream responses or user input.
- **Bounded in and out.** Input ranges at the schema; output caps (72 h / 10
days / 366-day archive spans) and defensive re-slicing keep responses
context-window sized.
- **Timeboxed network.** 10 s httpx timeout, no redirects, single retry-free
attempt with clear errors — no hanging tool calls.
- **stdio discipline.** Protocol on stdout, logs on stderr; no files,
subprocesses, or shell anywhere in the codebase.
## Verification
Both of these were run against the initial commit:
```console
$ .venv/bin/python -m pytest -q
56 passed in 0.33s
$ .venv/bin/python scripts/verify_stdio.py
== initialize ==
serverInfo: open-meteo v0.1.0 | protocol: 2025-11-25
== tools/list ==
6 tools: geocode_location, get_air_quality, get_current_weather,
get_daily_forecast, get_historical_weather, get_hourly_forecast
== tools/call: geocode_location {"name": "Reykjavik"} ==
{"count": 1, "results": [{"name": "Reykjavik", "country": "Iceland",
"latitude": 64.13548, "longitude": -21.89541, "timezone": "Atlantic/Reykjavik", ...}]}
== tools/call: get_current_weather (live coordinates) ==
{"timezone": "Atlantic/Reykjavik", "current": {"temperature_2m": -0.7,
"weather_code": 0, "weather_description": "Clear sky", "is_day": true, ...}}
== tools/call: schema violation ==
isError=True
Error executing tool get_current_weather: ... Input should be less than or equal to 90 ...
VERIFY OK: handshake, tools/list, 2 live tool calls, 1 clean error path
```
CI runs the same suite on every push/PR across Python 3.11–3.13.
## License
[MIT](LICENSE) © 2026 Dylan Sproule. Weather data by [Open-Meteo](https://open-meteo.com/)
(free for non-commercial use; see their terms).
TDQS
Scored across 6 tools
Each tool targets a distinct weather data type or workflow step: geocoding, current conditions, daily forecast, hourly forecast, air quality, and historical weather. There is no meaningful overlap, and the descriptions clearly indicate when to use each tool.
All tools use snake_case and most follow a clear get_<resource> pattern. geocode_location is a minor deviation from the get_ prefix, but it remains readable and predictable in context.
Six tools are well-scoped for a weather API server, covering the main forecast and historical data needs without redundancy. Each tool has a clear purpose and earns its place.
The surface covers geocoding plus current, daily, hourly, historical, and air-quality data, which are the core operations for an Open-Meteo-style server. No obvious lifecycle gaps or dead ends are apparent for this domain.