io.github.malkreide/swiss-holidays-mcp
Swiss school & public holidays, 26 cantons, by Schulart (OpenHolidays + Nager.Date)
Open source Open in the app JSON README (API)
About
Swiss school & public holidays, 26 cantons, by Schulart (OpenHolidays + Nager.Date)
Details
- Kind
- MCP servers
- Topic
- No topic detected
- Publisher
- malkreide
- Origin
- official
- Category
- ferramentas
- Transport
- local
- Version
- 0.6.0
- Open pull requests
- 1
- Last push
- 2026-09-07T07:18:47Z
- Repository state
- ativo
- Language
- Python
- License
- MIT
- Added
- 2026-08-29 04:00:27
- Updated
- 2026-08-29 04:00:27
- Origin id
io.github.malkreide/swiss-holidays-mcp
README
> π¨π **Part of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide)**
>
> This is a **private project**. It is independent of any employer or institutional affiliation and represents no official position of any authority.
# π
swiss-holidays-mcp
[](https://opensource.org/licenses/MIT)
[](https://www.python.org/downloads/)
[](https://modelcontextprotocol.io/)
[](https://github.com/malkreide/swiss-holidays-mcp/actions)
[](https://github.com/malkreide/swiss-holidays-mcp)
[](https://www.openholidaysapi.org/)
> A **Swiss holiday calendar** for AI agents β **public holidays, school holidays and long weekends** for all 26 cantons, with cross-cantonal comparison. School holidays are differentiated by *Schulart* (school type), which matters more than it first appears. No API key required.
[π©πͺ Deutsche Version](README.de.md)
---
## Overview
**swiss-holidays-mcp** is a Swiss holiday calendar for AI assistants like Claude β **public holidays, school holidays and long weekends** for all 26 cantons, no API keys required. Public holidays are cantonal (Berchtoldstag, Fronleichnam & co. differ by canton, not just the federal minimum). School holidays are set cantonally, sometimes at district level, and β in six cantons β **separately per school type**. A single federal calendar does not exist; anyone planning across cantonal borders is otherwise reduced to opening 26 PDF pages.
The server covers two thematic clusters: **public holidays / long weekends** and **school holidays** (with *Schulart* differentiation). Each cluster maps to a group of purpose-built tools that translate raw agency data into clean, provenance-tagged JSON responses. All data comes from the [OpenHolidays API](https://www.openholidaysapi.org/) (CC BY 4.0) and [Nager.Date](https://date.nager.at/) (MIT).
> **Mnemonic:** *A duplicate in Swiss school data is usually a school type in disguise.* The underlying API publishes the *same* holiday period several times when a canton differentiates by school type. That looks like duplicated data and invites naive de-duplication β which would destroy exactly the distinction a school authority needs.
**Anchor demo query:** *"In which weeks of 2026 are the compulsory schools of Zurich, Zug and Aargau simultaneously on holiday β and how many overlapping days does each pair share?"*
β This exercises `find_common_free_window`, `compare_school_holidays` and `list_school_types` in a single conversation, and answers a question that recurs every planning cycle in inter-cantonal coordination.
β [More use cases by audience](EXAMPLES.md) β
### Demo

---
## Features
- π« **School holidays** β periods per canton and date range, differentiated by *Schulart* (`VS` / `MS` / `BS` / `EO`)
- π **Public holidays** β cantonal holiday sets, not just the federal minimum (Berchtoldstag & friends)
- π **Date check** β is a given date a school or public holiday in a canton?
- π **Cross-cantonal comparison** β pairwise overlap matrix of holiday days between cantons
- πͺ **Common free windows** β date ranges where all listed cantons are simultaneously on holiday
- π **Long weekends & bridge days** β computed from federal public holidays (Nager.Date)
- ποΈ **Local & municipal holidays** β district- and municipality-level specifics such as Zurich's SechselΓ€uten and Knabenschiessen, with a `scope` marker so they are never mistaken for canton-wide
- π **iCal / ICS export** β a canton's holidays for a year as a ready-to-import `.ics` calendar
- π **Holiday feed resource** β `holidays://<canton>/<year>` MCP resource with a Markdown summary
- π **"Is today a holiday?"** β one-call convenience for the everyday question
- π©Ί **Source health** β reachability and latency of both upstreams, always evaluable
- π **No authentication required** β both data sources are publicly accessible
- βοΈ **Dual transport** β stdio for Claude Desktop, Streamable HTTP/SSE for cloud deployment
- π§Ύ **Provenance on every response** β `live_api` | `cached` | `degraded`, never a silent empty list
---
## Data Sources
| Source | Data | Licence |
|---|---|---|
| [OpenHolidays API](https://www.openholidaysapi.org/) | Cantons, *Schularten*, school holidays, public holidays | CC BY 4.0 |
| [Nager.Date](https://date.nager.at/) | Long weekends and required bridge days | MIT |
Both sources are publicly accessible, no authentication required.
**Attribution required:** OpenHolidays (CC BY 4.0) and Nager.Date must be cited as the source when using their data.
---
## Tools
| Tool | Purpose | Data Source |
|---|---|---|
| `list_cantons` | The 26 cantons with ISO codes and official languages | OpenHolidays |
| `list_school_types` | *Schulart* groups per canton (`CH-ZH-VS` etc.) | OpenHolidays |
| `get_school_holidays` | School holidays for one canton and date range | OpenHolidays |
| `get_public_holidays` | Public holidays for one canton and year | OpenHolidays |
| `get_local_holidays` | Public holidays for one municipality or district, incl. local specifics | OpenHolidays |
| `check_date` | Is a given date a school or public holiday? | OpenHolidays |
| `compare_school_holidays` | Pairwise overlap matrix across cantons | OpenHolidays |
| `find_common_free_window` | Windows where all listed cantons are on holiday | OpenHolidays |
| `next_school_holidays` | The next upcoming holiday periods | OpenHolidays |
| `get_long_weekends` | Long weekends and required bridge days | Nager.Date |
| `export_holidays_ics` | A canton's holidays for a year as an iCalendar (`.ics`) document | OpenHolidays |
| `is_holiday_today` | Is today a school or public holiday in a canton? | OpenHolidays |
| `source_status` | Reachability and latency of both upstreams | Built-in |
### Resources
| Resource URI | Content |
|---|---|
| `holidays://{canton}/{year}` | Markdown summary of all public + school holidays, e.g. `holidays://CH-ZH/2026` |
All tools carry the full annotation set β `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true` (they reach an external API). No tool writes anywhere. Inputs are schema-validated (canton codes against the 26 known cantons, dates as `YYYY-MM-DD`, `year` bounded, `language`/`school_type` whitelisted).
### Example Use Cases
| Query | Tool |
|---|---|
| *"Which cantons are there, and what are their codes?"* | `list_cantons` |
| *"Show Zurich's compulsory-school holidays for spring 2026"* | `get_school_holidays` |
| *"Is 3 April 2026 a public holiday in Ticino?"* | `check_date` |
| *"Do Zurich and Zug school holidays overlap this year?"* | `compare_school_holidays` |
| *"When can all of ZH, ZG, AG plan a joint week off school?"* | `find_common_free_window` |
| *"What are the next holidays for Basel-Stadt schools?"* | `next_school_holidays` |
| *"Which long weekends does 2026 have, and which bridge days do they need?"* | `get_long_weekends` |
| *"Which local holidays does the city of Zurich keep that the rest of the canton doesn't?"* | `get_local_holidays` |
| *"Export Zurich's 2026 holidays as an .ics calendar I can import"* | `export_holidays_ics` |
| *"Is today a holiday in Aargau?"* | `is_holiday_today` |
---
## π‘οΈ Safety & Limits
| Aspect | Details |
|--------|---------|
| **Access** | Read-only (`readOnlyHint: true`) β the server cannot modify or delete any data |
| **Personal data** | No personal data β all sources are aggregated, public holiday calendars |
| **Caching** | 12-hour in-memory TTL (holiday tables change a handful of times per year) |
| **Retry** | Exponential backoff 2s / 4s / 8s; 4xx except 429 are not retried |
| **Timeout** | 20 seconds per API call (8 seconds for health probes) |
| **Authentication** | No API keys required β both upstreams are publicly accessible |
| **Degradation** | Upstream failure yields a `degraded` envelope with an explanatory `note`, never a silent empty list |
| **Terms of Service** | Subject to the ToS of the respective data sources: [OpenHolidays](https://www.openholidaysapi.org/), [Nager.Date](https://date.nager.at/) |
---
## Architecture
This server uses **Architecture A (live API only, with in-memory cache)**.
```
ββββββββββββββββββββββββββββ
Claude / any ββββΆβ swiss-holidays-mcp β
MCP host β (MCPServer Β· 13 tools) β
ββββββββββ¬ββββββββββββββββββ
β retry 2s/4s/8s Β· 12h cache
ββββββββββ΄ββββββββββ
βΌ βΌ
OpenHolidays API Nager.Date
(CC BY 4.0) (MIT)
cantons Β· Schularten long weekends
school + public bridge days
```
**Rationale (verified live on 2026-07-19):**
- All ten documented OpenHolidays endpoints answered HTTP 200 with plausible payloads; `/Subdivisions?countryIsoCode=CH` returns exactly 26 cantons, matching the official count.
- No public bulk dump could be verified at build time (`openpotato/openholidays.data` raw access returned 404), so Architecture B was not available.
- Holiday tables change a handful of times per year, so a 12-hour in-memory TTL removes almost all upstream load without risking staleness.
**Consequences:**
- Every response carries `provenance` (`live_api` | `cached` | `degraded`).
- Upstream failure yields a `degraded` envelope with an explanatory `note`, never a silent empty list.
- `source_status` always returns an evaluable health report.
---
## Live-probe findings (2026-07-19)
| Endpoint | HTTP | Status | Records | Note |
|---|---|---|---|---|
| `/Countries` | 200 | β
works | 36 | |
| `/Subdivisions?countryIsoCode=CH` | 200 | β
works | 26 | matches official canton count |
| `/Groups?countryIsoCode=CH` | 200 | β
works | 11 | *Schulart* groups, only 6 cantons |
| `/PublicHolidays` (CH, 2026) | 200 | β
works | 39 | cantonal scope included |
| `/SchoolHolidays` (CH, 2026) | 200 | β
works | 193 | 183 distinct after school-type split |
| `/SchoolHolidaysByDate` | 200 | β
works | β | |
| `/SchoolHolidays?countryIsoCode=XX` | 200 | β οΈ silently empty | 0 | invalid country β error |
| `/Subdivisions?languageIsoCode=ZZ` | 200 | β οΈ silent EN fallback | 26 | invalid language β error |
| `/SchoolHolidays` without date range | 400 | β
correct error | β | RFC 9110 problem+json |
| Nager `/PublicHolidays/2026/CH` | 200 | β
works | 33 | 29 rows carry `counties` |
| Nager `/LongWeekend/2026/CH` | 200 | β
works | 3 | |
| Nager `/PublicHolidays/2026/XX` | 404 | β
correct error | β | stricter than OpenHolidays |
### Known findings
1. **Apparent duplicates are school types.** Zurich returns *FrΓΌhlingsferien 2026* twice: once for `CH-ZH-VS` (Volksschulen, tagged `Recommended`) and once for `CH-ZH-BS` + `CH-ZH-MS` (Berufsfach- and Mittelschulen). Use the `school_type` parameter (`VS` / `MS` / `BS` / `EO`) rather than de-duplicating.
2. **Only six cantons differentiate** by school type (AI, AR, BE, GR, SO, ZH). Elsewhere `groups` is absent and one table covers everything. The filter therefore treats an absent `groups` field as "applies to all".
3. **Subdivision codes mix levels.** Records may carry `CH-AI-AP` or `CH-BE-TH-BL`. Always match on the `CH-XX` prefix, never on string equality.
4. **An empty list is not an answer.** An unknown country or canton code yields HTTP 200 with `[]`. This server sets an explanatory `note` so that "no holidays" and "bad filter" stay distinguishable.
---
## Prerequisites
- Python 3.10 or higher
- [uv](https://docs.astral.sh/uv/) / uvx (recommended) or pip
- Internet access (both APIs are publicly available)
---
## Installation
Run via [`uv`](https://docs.astral.sh/uv/)'s `uvx` β no clone or manual install needed:
```bash
uvx swiss-holidays-mcp
```
### Development
```bash
git clone https://github.com/malkreide/swiss-holidays-mcp
cd swiss-holidays-mcp
pip install -e ".[dev]"
```
---
## Configuration
### Claude Desktop
Add to `claude_desktop_config.json`:
**macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
**Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
```json
{
"mcpServers": {
"swiss-holidays": {
"command": "uvx",
"args": ["swiss-holidays-mcp"]
}
}
}
```
Restart Claude Desktop β the server starts automatically on first use.
### Cloud Deployment (SSE / Streamable HTTP for browser access)
For use via **claude.ai in the browser** (e.g. on managed workstations without local software):
```bash
MCP_TRANSPORT=sse PORT=8000 python -m swiss_holidays_mcp
```
The SDK exposes SSE at `/sse`, not `/mcp`.
| Variable | Default | Description |
|---|---|---|
| `MCP_TRANSPORT` | `stdio` | Transport: `stdio`, `sse`, `streamable-http` (aka `http`) |
| `PORT` / `MCP_PORT` | `8000` | Port for HTTP transports |
| `MCP_HOST` | `127.0.0.1` | Bind address for HTTP transports. Loopback by default; `0.0.0.0` is opt-in and logs a warning β run behind an authenticating reverse proxy. |
| `MCP_CORS_ORIGINS` | *(empty)* | Comma-separated extra CORS origins for browser clients (audit SDK-004). Loopback origins are always allowed; add the public origin your UI is served from, e.g. `https://ui.example.ch`. Never `*`. |
The HTTP transports attach an explicit CORS layer that exposes the
`Mcp-Session-Id` header, so a browser MCP client can read the session id and
make follow-up requests. The allow-list is never a wildcard.
Running **more than one HTTP instance** behind a load balancer requires sticky
sessions keyed on `Mcp-Session-Id` β see [`docs/scaling.md`](docs/scaling.md)
for nginx/Traefik/Kubernetes examples. A single instance (the common case) needs
no affinity configuration.
> π‘ *"stdio for the developer laptop, SSE for the browser."*
---
## Project Structure
```
swiss-holidays-mcp/
βββ src/
β βββ swiss_holidays_mcp/
β βββ __init__.py # Package init
β βββ __main__.py # Entry point: stdio / SSE / Streamable HTTP
β βββ server.py # MCPServer: lifespan, 13 tools, 1 resource, op_* logic
β βββ client.py # Shared HTTP client: retry, 12h cache, egress guard
β βββ guard.py # Egress / SSRF guard (HTTPS + allow-list + IP blocklist)
β βββ pinning.py # DNS-pinning transport (TOCTOU-free connect, SEC-005)
β βββ ical.py # RFC 5545 iCalendar (.ics) writer
β βββ settings.py # Pydantic-Settings config (loopback default)
β βββ logging_setup.py # Structured logging to stderr
β βββ constants.py # Canton codes, Schulart suffixes, API bases, allow-list
β βββ models.py # Pydantic v2 response envelopes
βββ tests/
β βββ conftest.py # respx fixtures
β βββ test_tools.py # Tool unit tests (mocked, no network)
β βββ test_resilience.py # Degradation / retry / cache behaviour
β βββ test_live.py # Live smoke tests (marker: live)
βββ docs/ # roadmap.md, security.md, network-egress.md
βββ deploy/ # Network-layer egress manifests (Cilium / NetworkPolicy)
βββ audits/ # mcp-audit run artifacts
βββ Dockerfile # Non-root multi-stage container
βββ .github/
β βββ dependabot.yml # Weekly dependency / action update PRs
β βββ workflows/ # ci.yml, live-tests.yml, publish.yml
βββ pyproject.toml
βββ CHANGELOG.md
βββ CONTRIBUTING.md # Contributing guide (English)
βββ CONTRIBUTING.de.md # Contributing guide (German)
βββ SECURITY.md # Security policy (English)
βββ SECURITY.de.md # Security policy (German)
βββ EXAMPLES.md # Use cases by audience
βββ server.json # MCP registry manifest
βββ LICENSE
βββ README.md # This file (English)
βββ README.de.md # German version
```
**On the single-file `server.py` (audit ARCH-011).** The 13 tools deliberately
live in one module rather than a `tools/` package. Each tool is a thin, uniform
wrapper (`@mcp.tool` β `@_safe_tool` β `op_*`) over a transport-agnostic `op_*`
operation, and every operation shares the same small set of helpers
(`_to_period`, `_matches_school_type`, `_require_known_canton`, β¦) and the one
`HolidayClient`. Splitting these across files would scatter that shared core and
duplicate imports for no isolation benefit β the file is uniformly sectioned
(aliases β helpers β `op_*` logic β tool wrappers β resource) and every `op_*`
is unit-tested directly without a transport. A `tools/` split is the planned
step **only** if Phase 2 pushes the tool count materially higher.
---
## Lifecycle Phase
This server is in **Phase 1 (read-only)** β all tools read-only, no auth, no side
effects. The 13-tool budget (of the 15β20 recommended maximum) still leaves
headroom. Local and municipal specifics β including Zurich's SechselΓ€uten and
Knabenschiessen β are covered directly from OpenHolidays via `get_local_holidays`
(a live probe showed they are published upstream at Gemeinde level), so no
separate city data source is required for them.
---
## MCP Primitives & Protocol Version
- **Primitives β Tools + Resources.** The 13 tools are idempotent,
side-effect-free `GET`s. A **Resource** exposes a stable URI feed
(`holidays://<canton>/<year>`) so clients can read a canton's calendar as
cacheable context without a tool call. There are no recurring templated
workflows, so **Prompts** are not used (revisited if that changes).
- **MCP protocol version β two eras.** `mcp` 2.x serves both over the same
server, and the client's first request on a connection decides which applies:
the `initialize` handshake caps at **`2025-11-25`**, the per-request envelope
reaches **`2026-07-28`**.
`source_status` surfaces one of them in its `mcp_protocol_version` field β a
single string cannot name both β and it surfaces the **handshake ceiling**,
because that is what a client reaching this server over `initialize` actually
negotiated. Measured, not inferred from a constant name: a client asking the
handshake for `2026-07-28` gets `2025-11-25` back.
`MCP_PROTOCOL_VERSION` is derived from the SDK's `LATEST_HANDSHAKE_VERSION`
rather than written down, so it cannot drift the way it once did β it stood
at `2025-06-18` for two revisions while every call reported it as fact.
[`tests/test_protocol_version.py`](tests/test_protocol_version.py) holds both
eras against the SDK and checks the delivered field against the SDK too, not
against the constant it came from.
The wire version is negotiated by the pinned `mcp` SDK (`mcp>=2.0.0,<3`).
- **Update policy.** SDK and dependency bumps land via Dependabot (weekly);
protocol-version or tool-definition changes are recorded in
[`CHANGELOG.md`](CHANGELOG.md) with a version bump.
## Data classification
All data is **Γffentlich / Public Open Data** β aggregated holiday calendars,
no personal data (DSG/DSGVO). This is the highest classification the server
handles; the full model is in [`docs/security.md`](docs/security.md).
## Known Limitations
- **Unofficial source.** OpenHolidays aggregates cantonal publications. For legally binding dates, the cantonal authority remains authoritative. Every response says so.
- **Municipal coverage depends on the upstream.** OpenHolidays does carry district- and municipality-level public holidays (e.g. SechselΓ€uten, Knabenschiessen at `CH-ZH-ZH-ZH`), exposed through `get_local_holidays`. Completeness at Gemeinde level is only as good as the upstream data, which varies by canton. Municipal *school* holidays are not separately modelled.
- **Nager long weekends ignore cantonal holidays.** They are computed from nationwide holidays only.
- **No historical depth guarantee.** Coverage of years before roughly 2020 is uneven.
---
## Testing
```bash
# Unit tests (no network required β respx-mocked)
PYTHONPATH=src pytest tests/ -m "not live"
# Live smoke tests (hits the real upstream APIs)
PYTHONPATH=src pytest tests/ -m "live"
# Linting
ruff check src/ tests/ scripts/
ruff format --check src/ tests/ scripts/
```
---
## Contributing
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) (English) Β· [CONTRIBUTING.de.md](CONTRIBUTING.de.md) (German) for guidelines on reporting bugs, setting up the development environment, code style and test requirements.
This project follows the conventions of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide).
---
## Security
To report a vulnerability, please follow the responsible disclosure process in [SECURITY.md](SECURITY.md) (English) Β· [SECURITY.de.md](SECURITY.de.md) (German). The server is read-only and requires no API key; see the *Safety & Limits* section above for the security model.
---
## Changelog
See [CHANGELOG.md](CHANGELOG.md)
---
## Deployment for Swiss Public Administration
If you self-host this server for a Swiss school authority or municipal use case:
- **Data residency:** the query patterns themselves (which cantons a civil servant compares) may reveal ongoing planning and are best kept on Swiss or trusted infrastructure.
- **Upstream calls** go to OpenHolidays (EU-hosted OGD project) and Nager.Date. No personal data leaves your environment; only holiday calendars are requested.
- **Logging:** logs are written to stderr; configure your IT retention policy accordingly.
- **HTTP transport** should run behind a reverse proxy with authentication and per-IP rate limits β the server has no built-in authentication.
---
## License
MIT License β see [LICENSE](LICENSE)
Source data is subject to the terms of OpenHolidays (CC BY 4.0) and Nager.Date (MIT); attribution to these sources is required when using their data.
---
## Author
Hayal Oezkan Β· [github.com/malkreide](https://github.com/malkreide)
---
## Credits & Related Projects
- **Data:** [OpenHolidays API](https://www.openholidaysapi.org/) (CC BY 4.0) Β· [Nager.Date](https://date.nager.at/) (MIT)
- **Protocol:** [Model Context Protocol](https://modelcontextprotocol.io/) β Anthropic / Linux Foundation
- **Built following** the `mcp-data-source-probe` methodology: *live probe before design, dump fallback before API dependency, retry before defeatism.*
- **Portfolio:** [Swiss Public Data MCP Portfolio](https://github.com/malkreide)
| Server | Description |
|--------|-------------|
| [`zh-education-mcp`](https://github.com/malkreide/zh-education-mcp) | Canton of Zurich education data |
| [`zurich-opendata-mcp`](https://github.com/malkreide/zurich-opendata-mcp) | City of Zurich Open Data |
| [`swiss-statistics-mcp`](https://github.com/malkreide/swiss-statistics-mcp) | BFS STAT-TAB β Swiss federal statistics |
| [`swisstopo-mcp`](https://github.com/malkreide/swisstopo-mcp) | Swiss federal geodata (swisstopo) |
MIT licensed. Public money, public code.
<!-- mcp-name: io.github.malkreide/swiss-holidays-mcp -->