meteo-swiss-mcp
# Swiss Weather MCP Server

[](https://pypi.org/project/swiss-weather-mcp/)
[](/LICENSE.txt)
[](https://github.com/cuolm/swiss-weather-mcp/actions/workflows/release.yaml)
[](https://github.com/cuolm/swiss-weather-mcp/actions/workflows/tests.yaml)
A **Model Context Protocol ([MCP](https://modelcontextprotocol.info/))** server that exposes Swiss weather forecast data as callable tools.
It reads the official [MeteoSwiss local forecast collection](https://opendatadocs.meteoswiss.ch/e-forecast-data/e4-local-forecast-data), caches it locally, and serves predictions such as rainfall, sunshine, temperature, wind and a worded weather summary. MeteoSwiss publishes these forecasts for **5,614 Swiss locations** (weather stations, postal code centers and points of interest), for **today and the next 8 days**, refreshed **every hour**.
It also reads the [latest measurements](https://opendatadocs.meteoswiss.ch/a-data-groundbased/a1-automatic-weather-stations) of the SwissMetNet weather stations, refreshed **every 10 minutes**, for the weather right now at a location and across Switzerland.
There is also an MCP client that can be run to test the server using the stdio transport.
> **Note:** This project is **not an official MeteoSwiss product**. All forecast and measurement data are from the
> [MeteoSwiss Open Data](https://opendata.swiss/en/organization/bundesamt-fur-meteorologie-und-klimatologie-meteoschweiz) portal. **Source: MeteoSwiss**
## Table of Contents
- [Project Structure](#project-structure)
- [Quick Start](#quick-start)
- [Installation](#installation)
- [Usage](#usage)
- [Available Tools](#available-tools)
- [Example Usage with LMStudio](#example-usage-with-lmstudio)
- [Tests](#tests)
- [Releasing](#releasing)
- [Resources](#resources)
- [License](#license)
## Project Structure
```text
swiss-weather-mcp/
├── src/swiss_weather_mcp/
│ ├── server.py # MCP server
│ ├── errors.py # The error for questions the data cannot answer
│ ├── log.py # Logging setup for the server and the client
│ ├── locations.py # Location lookup by name or postal code
│ ├── opendata.py # Downloads and timestamps from MeteoSwiss Open Data
│ ├── formatting.py # Swiss time and compass points in the answers
│ ├── other_language_place_names.csv # Place names in other languages
│ ├── forecast/
│ │ ├── source.py # MeteoSwiss forecast data source and caching
│ │ ├── parameters.py # Forecast parameter codes and pictogram meanings
│ │ └── service.py # Forecast values, units and aggregation
│ ├── measurements/
│ │ ├── source.py # SwissMetNet measurements, stations and the nearest station
│ │ ├── parameters.py # Measurement parameter codes
│ │ └── service.py # The weather measured now
│ └── client.py # MCP client (optional)
├── scripts/ # Builds other_language_place_names.csv
├── tests/swiss_weather_mcp/ # Tests
├── .github/workflows/ # CI and release pipelines
├── docs/ # Documentation
├── pyproject.toml # Project metadata and dependencies
├── uv.lock # Pinned, reproducible dependency set
└── Dockerfile
```
Caches live outside the project, under your OS's standard cache directory (see [Installation](#installation)).
## Quick Start
### 1. Installation
Install the server globally to run it anywhere on your system:
```bash
uv tool install swiss-weather-mcp
```
### 2. Execution
```bash
swiss-weather-mcp-server
```
No configuration is needed, the server reads everything it requires from MeteoSwiss.
## Installation
### As a Global CLI Tool
```bash
uv tool install swiss-weather-mcp
# With the optional MCP client, which pulls in the OpenAI SDK
uv tool install 'swiss-weather-mcp[client]'
```
### As a Library Dependency
```bash
# Using uv
uv add swiss-weather-mcp
# Using pip
pip install swiss-weather-mcp
# With the optional MCP client, which pulls in the OpenAI SDK
uv add 'swiss-weather-mcp[client]'
pip install 'swiss-weather-mcp[client]'
```
### From Source
```bash
git clone https://github.com/cuolm/swiss-weather-mcp.git
cd swiss-weather-mcp
# Using uv (Recommended)
uv sync --extra client
# Using pip
python3 -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install -e ".[client]"
```
> **Note:** `uv sync` installs the server only. `--extra client` adds the OpenAI SDK needed by
> `swiss-weather-mcp-client`.
> **Note:**
> - `llama-server` must already be running when you start `swiss-weather-mcp-client`, which talks to it
> but never starts it. The MCP server itself does not need it.
> - Caches live in your OS cache directory (via [platformdirs](https://github.com/tox-dev/platformdirs),
> e.g. `~/Library/Caches/swiss-weather-mcp` on macOS, `~/.cache/swiss-weather-mcp` on Linux),
> independent of where the server runs. Set `SWISS_WEATHER_MCP_CACHE_DIR` to put them elsewhere, or
> delete the directory to clear them. Each data source has its own folder: `locations`, `forecast`
> and `measurements`. Forecast files sit in one folder per model run, named by the run's time in
> UTC, such as `forecast/runs/202609231100`.
## Usage
### Running the Server
If installed via `uv tool install` or `pip`:
```bash
# stdio (default)
swiss-weather-mcp-server
# streamable-http
swiss-weather-mcp-server --transport=streamable-http --host=localhost --port=8050
```
If running within the source repository cloned from GitHub:
```bash
# Using uv (Recommended)
uv run swiss-weather-mcp-server
# Using pip, with the virtual environment activated
swiss-weather-mcp-server
```
Optional flags: `--help`
> **Note:** By default the server keeps only the rows for the location you asked about, a few
> kilobytes per parameter. Pass `--cache-all-locations` to keep the whole published file instead
> (about 31 MB per parameter), which makes questions about further locations need no new download.
### Running the Server with Docker
Images are built and published automatically by GitHub Actions to the project's [GitHub Container Registry](https://ghcr.io/cuolm/swiss-weather-mcp), tagged `:latest` (newest release) and by version.
1. Run the published image, mapping port 8050:
```bash
docker run -p 8050:8050 ghcr.io/cuolm/swiss-weather-mcp:latest
```
2. Access the server at:
```bash
http://localhost:8050/mcp/
```
> **Note:** The cache lives inside the container and is lost when it stops. Mount a volume to keep it
> across restarts, for example `-v swiss-weather-cache:/root/.cache/swiss-weather-mcp`.
#### Manual Build
```bash
docker build -t swiss-weather-mcp .
docker run -p 8050:8050 swiss-weather-mcp
```
### Running the MCP Client using Stdio Transport
The bundled MCP client can be used to test the server over the stdio transport. It requires the `client` extra (see [Installation](#installation)). The client starts the MCP server itself, but not the model server, which has to be running first.
1. Install [llama.cpp](https://github.com/ggml-org/llama.cpp), which provides `llama-server`:
```bash
brew install llama.cpp
```
2. Start it in its own terminal, downloading the model on first use:
```bash
llama-server --jinja --no-mmproj -hf unsloth/Qwen3.5-4B-GGUF --port 8080
```
3. Run the client against it in a second terminal:
```bash
swiss-weather-mcp-client --model=unsloth/Qwen3.5-4B-GGUF --base-url=http://localhost:8080/v1
# From a source checkout, using uv
uv run --extra client swiss-weather-mcp-client --model=unsloth/Qwen3.5-4B-GGUF --base-url=http://localhost:8080/v1
```
> **Note:** `--jinja` applies the model's chat template, without which tool calling is unsupported, and
> `--no-mmproj` skips the vision projector that `-hf` downloads alongside some models. `--base-url`
> defaults to `llama-server`'s address, and any other OpenAI compatible backend works by pointing it
> elsewhere, for example [LM Studio](https://lmstudio.ai/) or [vLLM](https://docs.vllm.ai/). Pass
> `--model` exactly as the server reports it under `/v1/models`.
## Available Tools
The forecast tools take a `location` (a name such as `"Zurich"`, or a Swiss postal code such as `"8001"`)
and a Swiss local time, or a date for `daily_forecast`. They return the value together with the location
they resolved, its altitude, the time or day it applies to, and the model run the forecast came from.
| Tool | Purpose | Example Call |
|------|---------|--------------|
| `current_date_and_time()` | Today's weekday and the Swiss time now, in the form the tools accept | `current_date_and_time()` |
| `daily_forecast(location, start_date, days)` | One row per day, 1 to 9 days: lowest and highest hourly temperature, median rainfall with its 10th and 90th percentile, daytime weather in words with an emoji | `daily_forecast("Zurich", "2026-09-26", 2)` |
| `hourly_forecast(location, start, end)` | Hour by hour, up to 24 hours, or one hour without `end`: temperature (°C) with its 10th and 90th percentile, rain chance (%) and the weather in words with an emoji | `hourly_forecast("Zurich", "2026-09-23T15:00")` |
| `rain_outlook(location, start, end)` | Rain in 3-hour blocks, up to 48 hours: chance (%), median (mm) and how much the wettest hour may bring (mm) | `rain_outlook("Zurich", "2026-09-23T12:00", "2026-09-24T00:00")` |
| `sunshine_hours(location, start, end)` | Sunshine (h) summed over a period | `sunshine_hours("Zurich", "2026-09-23T06:00", "2026-09-23T18:00")` |
| `wind(location, when)` | Mean speed and strongest gust (km/h) in the hour up to that time, how strong that gust may get (its 90th percentile), and the direction as degrees and compass point | `wind("Säntis", "2026-09-23T14:00")` |
| `total_cloud_cover(location, when)` | Estimated total cloud cover (%) plus the low, medium and high layers | `total_cloud_cover("Zurich", "2026-09-23T14:00")` |
| `freezing_level(location, when)` | Height of the 0 °C line (m above sea level) | `freezing_level("Zermatt", "2026-09-23T14:00")` |
| `current_conditions(location)` | Measured now at the nearest weather station: temperature, humidity, dew point, rain and sunshine in the last 10 minutes, wind, gust, direction and sea-level pressure, plus the temperature and pressure change over the last 3 hours and the sunshine in the last hour | `current_conditions("Zurich")` |
| `current_extremes(extreme)` | Where in Switzerland it is `warmest`, `coldest`, `windiest`, `wettest` or `sunniest` now, from the measurements of all weather stations: the first 5 stations by temperature or gust, or how many stations are sunny or rainy in Switzerland and in each canton | `current_extremes("sunniest")` |
**Time**
- Send times as Swiss clock time in ISO 8601 without an offset, e.g. `"2026-09-23T14:00"`. The server
applies summer or winter time for that date itself. An explicit offset is honoured.
- All times in the answers are ISO 8601 Swiss local time with the UTC offset, e.g.
`2026-09-23T13:00+02:00`. The `+02:00` shows the difference to UTC: `+02:00` in summer, `+01:00` in winter.
- MeteoSwiss stamps an hourly average or sum at the end of its hour, so `14:00` means 13:00 to 14:00,
and a period from `start` to `end` covers exactly the hours in between. Cloud cover and the freezing
level are values at that moment instead.
- The forecast covers today and the next 8 days.
> **Note:** Ask `current_date_and_time()` first when the question is relative, such as "tomorrow" or
> "tonight", because the tools take a real date rather than an offset.
> **Note:** A location must be one of the places MeteoSwiss publishes. Names are matched exactly,
> ignoring case, accents and periods, so `zurich` finds `Zürich` and `St Moritz` finds `St. Moritz`, but a region such as `Tessin` does not match
> and is rejected rather than guessed at. Names in the other national languages and in English, such
> as `Genf` or `Geneva` for `Genève`, work too. They come from Wikipedia lists (CC BY-SA 4.0), collected
> by `scripts/build_other_language_place_names.py` into `other_language_place_names.csv`. A city covers
> several postal code areas and resolves to the lowest one, which is not always its centre (Bern's
> lowest is 3004, its old town 3011). The answer names the point it used, and a postal code picks a
> specific district.
> **Note:** `current_conditions` uses the nearest of the about 160 SwissMetNet stations that measures
> the temperature, not the location itself. It is usually a few kilometres away, but can be up to about
> 20 km away and several hundred metres higher or lower: Braunwald is measured in Glarus, 801 m lower.
> Every answer starts with one sentence that says so, such as "Glarus (517 m) is the nearest MeteoSwiss
> station to Braunwald 8784 (1318 m), 11.8 km away and 801 m lower. It measured the following values at
> 20:50.", so the values are not mistaken for the location's own. The file of a station is about
> 6 KB and is kept for 5 minutes. A station that has not published a temperature in the last hour is
> passed over for the next nearest one, at most three stations. The changes over the last 3 hours
> and the sunshine in the last hour come from the earlier rows of that file. They are empty when the
> station has not published all the rows they need, such as shortly after midnight UTC.
> **Note:** `current_extremes` compares the newest values of all about 160 SwissMetNet stations, which
> MeteoSwiss publishes in one file of about 17 KB, kept for 5 minutes. The warmest, coldest and
> windiest stations are ranked by the temperature and by the strongest gust, and the first 5 are
> listed. For the sunniest and the wettest a ranking says little, because many stations share the
> highest value, so the answer counts stations instead: a station is sunny when the sun shone for at
> least 5 of the last 10 minutes, and rainy when it measured rain in the last 10 minutes. The counts
> are given for Switzerland and for each canton with a measuring station, always as "2 of 3
> stations", with the canton with the highest share first, because a small canton has few stations.
> A canton without a sunny or rainy station is listed with 0, so it is not mistaken for missing.
> Cantons are named in full, and Liechtenstein, whose station is Vaduz, is listed like a canton.
> A station that does not measure a value, or has not delivered it yet, is left out of both numbers.
> The wettest also lists the 5 stations with the most rain. These are the values of stations, so the
> coldest and the windiest are usually high mountain stations.
> **Note:** `daily_forecast` is by far the cheapest tool, about 8 MB for all its files against about
> 31 MB for each hourly file, so prefer it when the question is about a day rather than an hour.
> `hourly_forecast` reads five hourly files, `wind` four, and `rain_outlook` and `total_cloud_cover`
> three each. A tool downloads its files at the same time.
> **Note:** MeteoSwiss calculates many slightly different possible outcomes, not just one forecast.
> Temperature and rain are the median of these outcomes: half lie below it and half above. If the
> possible temperatures at 14:00 are 18, 19, 20, 21 and 23 °C, the median is 20 °C. It is not the most
> likely value. Medians do not add up: when showers are possible but unlikely in any single hour,
> every hourly amount is 0, while the day as a whole still has a median of several millimetres. So
> `daily_forecast` gives the rain of a day, and `rain_outlook` shows the chance and how much a wet hour
> may bring.
## Example Usage with LMStudio
### Using the streamable-http transport layer
Configure the mcp.json file in [LMStudio](https://lmstudio.ai/):
```json
{
"mcpServers": {
"swiss_weather_mcp_server": {
"url": "http://localhost:8050/mcp/"
}
}
}
```
Run the MCP server with the streamable-http transport layer:
```bash
uv run swiss-weather-mcp-server --transport=streamable-http --host=localhost --port=8050
```
### Using the stdio transport layer
Configure the mcp.json file in LMStudio. Replace `<path-to-the-project>` with your actual local path:
```json
{
"mcpServers": {
"swiss_weather_mcp_server": {
"command": "<path-to-the-project>/.venv/bin/swiss-weather-mcp-server"
}
}
}
```

## Tests
Run the tests from the project root with:
```bash
uv run pytest
# Or, with an activated virtual environment
pytest
```
Check the code style with ruff and the type hints with mypy. mypy also checks the client, so install its extra first:
```bash
uv sync --group dev --extra client
uv run ruff check
uv run mypy
```
Every push and pull request runs the tests, ruff, mypy and a Docker build check via the [Tests workflow](.github/workflows/tests.yaml).
## Releasing
Versions are derived from Git tags by `hatch-vcs` — there is no version string to bump by hand.
- Pushing a pre-release tag (e.g. `0.2.0rc1`) triggers [`release_test.yaml`](.github/workflows/release_test.yaml): tests, publish to **TestPyPI**, push a versioned image to GHCR, and create a prerelease GitHub Release.
- Pushing a final tag (e.g. `0.2.0`) triggers [`release.yaml`](.github/workflows/release.yaml): tests, publish to **PyPI**, push `:<version>` and `:latest` images to GHCR, and create a GitHub Release.
Both publish jobs use PyPI [trusted publishing](https://docs.pypi.org/trusted-publishers/) via the `pypi` / `testpypi` GitHub environments — no API tokens are stored in the repository.
## Resources
- [Meteo Swiss Open Data](https://www.meteoswiss.admin.ch/services-and-publications/service/open-data.html)
- [Local Forecast Notebook Examples](https://github.com/MeteoSwiss/opendata-localforecast-demos)
- [Automatic Weather Stations (SwissMetNet)](https://opendatadocs.meteoswiss.ch/a-data-groundbased/a1-automatic-weather-stations)
- [Model Context Protocol](https://github.com/modelcontextprotocol/python-sdk)
- [MCP Server Quickstart](https://modelcontextprotocol.info/docs/quickstart/server/)
## License
Licensed under the [Apache License 2.0](/LICENSE.txt).
TDQS
Scored across 8 tools
Each tool targets a distinct weather variable or time scale: hourly vs daily vs rain-specific, wind, freezing level, sunshine, cloud cover, plus a date helper. The descriptions explicitly cross-reference each other (e.g., 'for whole days use daily_forecast, for rain amounts rain_outlook'), eliminating ambiguity.
All tool names use clear, lowercase snake_case noun phrases that describe the data product (hourly_forecast, rain_outlook, freezing_level, total_cloud_cover). Though not verb_noun, the convention is perfectly consistent and readable, with no mixing of styles.
8 tools is well-scoped for a weather data server. Each tool covers a distinct aspect of the domain without redundancy, and the count is comfortably within the ideal 3–15 range.
The server covers temperature, precipitation, wind, cloud, sunshine, and freezing level, plus a date helper. Minor gaps exist—no explicit weather alerts or current-conditions snapshot—but hourly_forecast can approximate current conditions, so agents can work around these limitations.