weather-mcp
# weather-mcp
[](https://github.com/duanefields/weather-mcp/actions/workflows/ci.yml)
An MCP server for weather, over [Open-Meteo](https://open-meteo.com). Forecasts
anywhere in the world, hour by hour when the day matters, and observed history
for the trips that are still too far out to forecast.
No API key. Open-Meteo's free tier needs no credential at all for
non-commercial use, so there is nothing to store and nothing to leak. It runs
over stdio locally, or over authenticated HTTP for a remote client such as a
Claude connector.
Built for one question in particular: *I have a trip — what should I expect?*
## Tools
| Tool | What it does |
| :--- | :--- |
| `search_locations` | A name → the places it could mean, with coordinates, region and timezone. The disambiguator. |
| `get_forecast` | Daily outlook for a place and date range: highs, lows, rain chance, wind. Up to 16 days. |
| `get_hourly_forecast` | One day, hour by hour, with contiguous rain windows called out. |
| `get_typical_weather` | What the weather actually did on these dates in past years. History, for trips past the horizon. |
| `find_best_days` | Ranks the days ahead by how pleasant it will be outside. For picking a day to hike. |
`GET /health` is served unauthenticated alongside them, for monitoring.
## The three things worth knowing
### Places are ambiguous and forecasts are not self-checking
"Springfield" is five real US cities. "Portland" is two large ones. A forecast
for the wrong one looks exactly like a forecast for the right one — there is no
signal in the numbers that anything went wrong.
So every tool takes a place as ordinary text, resolves it, and **names the place
it resolved to in every reply**, along with the other candidates it matched:
```
Forecast for Boston, Massachusetts, United States (also matched: Boston,
New York, United States; Boston, Georgia, United States)
```
`search_locations` exists for the cases where that is not enough and the model
should ask. It is not a required first step.
### The horizon is 16 days, and that is the data, not a setting
`api.open-meteo.com` refuses anything further out:
```
{"reason":"Parameter 'start_date' is out of allowed range from
2026-06-06 to 2026-09-22","error":true}
```
Which is a problem, because trips get planned months ahead and the weather
question arrives with the flights. `get_typical_weather` answers that case from
the Historical Weather API — the same calendar window in each of the last ten
complete years, as observed:
```
Typical weather for 10-14 to 10-18 in Boston, Massachusetts, United States
This is what actually happened in 2016–2025 (10 years), not a forecast.
- Average high 64.2°F, average low 48.1°F
- Warmest 79.3°F, coldest 36.9°F
- Rain during this window in 7 of 10 years
```
This is history and the tools say so in the first line, in the docstrings, and
in the structured payload (`"kind": "history"`). It answers *what to pack*, not
*what will happen*.
The Climate API would answer far-future dates directly, and is deliberately not
used: it returns downscaled CMIP6 climate **projections**, numbers that read
exactly like a forecast and are not one.
### Timezones belong to the destination, not the server
A forecast for "the day of" is meaningless without a local day boundary, and the
relevant boundary is the destination's. This matters most for the case the
server was built for: asking about a destination from a laptop that is somewhere
else entirely, which is the normal state of affairs when the question is about a
trip.
Every request goes out with `timezone=auto`, so the API anchors the days to the
place being asked about. When no dates are given, the range is expressed as a
day *count* rather than computed dates, so "the next week" starts on today at
the destination. The server's own clock is never consulted for this.
### Picking a day is not the same as reading a forecast
"What's a good day for a hike in the next two weeks" is arithmetic over three
hundred hourly readings, and doing it by eye off a daily forecast goes wrong in
two specific ways. `find_best_days` exists for it, and makes two decisions:
**It scores the hours you would actually be outside** — 8am to 6pm by default,
adjustable — rather than the whole day. A daily high is one afternoon moment,
and a daily rain chance includes the hours you were asleep.
**It scores feels-like, not temperature.** Open-Meteo's `apparent_temperature`
already folds in humidity, wind chill and sun, which is exactly the difference
between 85°F in Austin and 85°F in Denver. Reconstructing that from dry-bulb
temperature plus a humidity correction would be rebuilding, worse, something the
API already did.
The penalty for heat is quadratic, which is not decoration. With a linear one, a
real Austin day running 87°F to 104°F feels-like scored 79 and came back "good
for a hike" — ten pleasant morning degrees had averaged out a dangerous
afternoon. Squaring the deviation before averaging drops it to 16, which is the
right answer. Both calibration points are pinned in `tests/test_comfort.py`.
The ranking is a stated preference, not a measurement, so every day comes back
with the numbers behind it and days scored *poor* are named as days to avoid
rather than padding out a top five:
```
Best days to be outside in Austin
Scored over 08:00–18:00 America/Chicago, comfortable between 50°F and 78°F feels-like.
1. 2026-09-18 — excellent (89/100)
feels like 72°F–86°F, overcast, 11% chance of rain, wind to 8 mph, 35% humidity
2. 2026-09-17 — good (82/100)
feels like 79°F–89°F, overcast, 10% chance of rain (0.03 inch), wind to 13 mph, 64% humidity
Worth avoiding: 2026-09-07 (feels like 104°F), 2026-09-08 (feels like 105°F)
```
## Setup
Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/).
```bash
uv sync
cp .env.example .env # then set your home location
```
```bash
uv run weather-mcp # stdio
WEATHER_MCP_TRANSPORT=http uv run weather-mcp # http on 127.0.0.1:18791
```
To use it from Claude Code over stdio:
```bash
claude mcp add weather -- uv --directory /path/to/weather-mcp run weather-mcp
```
## Configuration
Everything is environment variables; nothing is read from `.env` by the server
itself, which only documents them.
| Variable | Default | Meaning |
| :--- | :--- | :--- |
| `WEATHER_MCP_HOME` | — | Home location as `latitude,longitude`. Used when a tool is called with no place. |
| `WEATHER_MCP_HOME_NAME` | `Home` | What to call it in replies. |
| `WEATHER_MCP_HOME_TIMEZONE` | resolved | IANA zone for the home label. Optional; forecasts resolve it from the coordinates regardless. |
| `WEATHER_MCP_TEMPERATURE_UNIT` | `fahrenheit` | `fahrenheit` or `celsius`. |
| `WEATHER_MCP_WIND_SPEED_UNIT` | `mph` | `mph`, `kmh`, `ms` or `kn`. |
| `WEATHER_MCP_PRECIPITATION_UNIT` | `inch` | `inch` or `mm`. |
| `WEATHER_MCP_TRANSPORT` | `stdio` | `stdio` or `http`. |
| `WEATHER_MCP_HOST` | `127.0.0.1` | HTTP bind address. |
| `WEATHER_MCP_PORT` | `18791` | HTTP bind port. |
| `WEATHER_MCP_STATELESS` | `true` | `false` restores per-client sessions. |
| `WEATHER_MCP_AUTH` | `none` | `none` or `password`. HTTP only. |
| `WEATHER_MCP_PASSWORD` | — | Shared password. Required when `AUTH=password`. |
| `WEATHER_MCP_BASE_URL` | — | Public URL; becomes the OAuth issuer. Required when `AUTH=password`. |
| `WEATHER_MCP_STATE_DIR` | `~/.weather-mcp` | Where OAuth state is persisted. |
**`WEATHER_MCP_HOME` is coordinates, not a name.** A name would be re-geocoded
on every call, and re-*resolved* — so a shift in how Open-Meteo ranks "Austin"
could quietly move home from Texas to Minnesota. Run `search_locations` once and
paste the numbers.
## Authentication
A remote MCP client has one input field: a URL. There is nowhere to put an API
key. So `WEATHER_MCP_AUTH=password` starts a self-contained OAuth 2.1
authorization server whose only credential is one shared password — the client
discovers it, registers itself, and gets redirected to a password form.
Dynamic client registration, PKCE, discovery metadata and the `401` challenge
come from FastMCP and the MCP SDK. This project adds the login screen and the
credential check. Registered clients and tokens persist across restarts;
authorization codes and in-flight logins are deliberately memory-only.
The data served is public weather, so an open port leaks nothing about the
operator. What it does hand out is the **call budget** this server runs on —
10,000 requests a day, shared with every real tool call, on a tier with no
uptime guarantee. That is the reason to authenticate.
Bind to localhost and put a tunnel or reverse proxy in front of it. See
[docs/deployment-macos.md](docs/deployment-macos.md).
## Notes on Open-Meteo
Findings that shaped the implementation, each verified against the live API:
- **Geocoding is included**, at `geocoding-api.open-meteo.com`, on the same
free terms. A separate geocoding service is not needed. It returns the IANA
timezone alongside the coordinates, which is the part that makes local day
boundaries correct.
- **There is no reverse geocoding.** Passing `latitude`/`longitude` to the
geocoding endpoint is rejected outright. Coordinates → place name is not
available at any price, so this server never attempts it.
- **Errors arrive as `200 OK` about as often as `4xx`**, with
`{"error": true, "reason": "..."}` in the body. The body is therefore parsed
before the status is judged. The `reason` strings are unusually good and are
passed through to the model unedited — they say exactly which parameter was
wrong and what range was allowed.
- **Units are per family.** `temperature_unit=fahrenheit` does *not* make
precipitation come back in inches; it stays in millimeters until
`precipitation_unit` is set too. Replies read the units back off the response
rather than echoing the request, so a number can never be labeled with a unit
it is not in.
- **Pollen is Europe-only.** Berlin returns real grass and birch counts; every
US coordinate returns `null` for every hour. This is why the Air Quality API
is not wrapped — the half of it that works in the US is not worth a tool.
- **The archive is current to about yesterday**, not lagged by weeks as the
"historical" framing suggests. `get_typical_weather` still uses only complete
past years, so a partly-observed current year can never skew an average.
## Development
```bash
uv sync --extra test
uv run pytest
uvx ruff@0.16.4 check src tests
```
The suite is entirely offline — no test reaches Open-Meteo. Fixtures reproduce
the exact payload shapes the API returns, including its column-array layout.
## License
MIT
TDQS
Scored across 5 tools
Each tool has a clearly distinct purpose: location lookup, daily forecast, hourly forecast, historical typical weather, and best-day ranking. No overlapping or ambiguous functionalities.
All tools follow a consistent verb_noun snake_case pattern (search_locations, get_forecast, get_hourly_forecast, get_typical_weather, find_best_days), making the API predictable and intuitive.
Five tools is well-scoped for a weather service: it covers location search, current/daily forecasts, hourly details, historical norms, and planning recommendations without unnecessary bloat.
The tool set provides comprehensive weather coverage: disambiguation (search_locations), forward-looking forecasts (get_forecast, get_hourly_forecast), historical context (get_typical_weather), and decision support (find_best_days). No critical gaps identified.