zh-education-mcp
> π¨π **Part of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide)**
# π zh-education-mcp

[](https://opensource.org/licenses/MIT)
[](https://www.python.org/downloads/)
[](https://modelcontextprotocol.io/)
[](https://github.com/malkreide/zh-education-mcp)

> MCP server for education statistics of the Canton and City of Zurich (BISTA)
[π©πͺ Deutsche Version](README.de.md)
---
## Overview
`zh-education-mcp` connects AI assistants to the **Bildungsstatistik Kanton ZΓΌrich (BISTA)** β the official education statistics of the Canton of Zurich. It provides structured access to pupil numbers, school district trends, secondary school profiles, nationality breakdowns, and gymnasium graduation rates.
| Source | Data | API |
|--------|------|-----|
| **BISTA Kanton ZΓΌrich** | Learner statistics (Volksschule, Mittelschulen, MaturitΓ€t) | REST/CSV |
All data is fetched from the **BISTA public API** (`bista.zh.ch/basicapi/ogd/`) β no API key required. Data is updated annually on 15 September (reference date).
**Anchor demo query:** *"How has the number of pupils in school district Letzi developed over the last 5 years?"*
---
## Demo
<p align="center">
<img src="assets/demo.png" alt="zh-education-mcp demo: Claude queries BISTA data" width="720">
</p>
---
## Features
- π **8 tools** for education data across all school levels
- π **School district trends** β pupil numbers for all Schulkreise (Letzi, Glattal, Schwamendingen, Oerlikon, Uto, Waidberg, ZΓΌrichberg) from 2000 to present
- π« **Secondary school profiles** β breakdown by requirement type (Sek A/B/C, Mittelschule, special classes)
- π **Nationality structure** β top nationalities of pupils per school community
- π **Gymnasium graduation rates** β MaturitΓ€tsquote by municipality, district, and canton
- π **Canton-wide overview** β all learners by school level, type, gender, and nationality
- π **Residence-based trends** β pupil counts by place of residence (Bezirk / Gemeinde)
- ποΈ **Mittelschulen** β Gymnasium, FMS, HMS statistics
- π **No API key required** β all data under CC BY 4.0
- βοΈ **Dual transport** β stdio (Claude Desktop) + Streamable HTTP (cloud)
---
## Prerequisites
- Python 3.11+
- [uv](https://github.com/astral-sh/uv) (recommended) or pip
---
## Installation
```bash
# Clone the repository
git clone https://github.com/malkreide/zh-education-mcp.git
cd zh-education-mcp
# Install
pip install -e .
# or with uv:
uv pip install -e .
```
Or with `uvx` (no permanent installation):
```bash
uvx zh-education-mcp
```
---
## Quickstart
```bash
# stdio (for Claude Desktop)
python -m zh_education_mcp.server
# Streamable HTTP (port 8000)
python -m zh_education_mcp.server --http --port 8000
```
Try it immediately in Claude Desktop:
> *"Wie hat sich die Lernendenzahl im Schulkreis Letzi entwickelt?"*
> *"Zeige die MaturitΓ€tsquote der Stadt ZΓΌrich"*
> *"Welche NationalitΓ€ten sind in Adliswil am hΓ€ufigsten?"*
β [More use cases by audience](EXAMPLES.md) β
---
## Configuration
### Claude Desktop
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
```json
{
"mcpServers": {
"zh-education": {
"command": "python",
"args": ["-m", "zh_education_mcp.server"]
}
}
}
```
Or with `uvx`:
```json
{
"mcpServers": {
"zh-education": {
"command": "uvx",
"args": ["zh-education-mcp"]
}
}
}
```
**Config file locations:**
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
### Cloud Deployment (Streamable HTTP for browser access)
For use via **claude.ai in the browser** (e.g. on managed workstations without local software):
**Render.com (recommended):**
1. Push/fork the repository to GitHub
2. On [render.com](https://render.com): New Web Service β runtime **Docker** (uses the provided `Dockerfile`)
3. Set env vars: `MCP_TRANSPORT=streamable-http`, `MCP_HOST=0.0.0.0`, `MCP_PORT=8000`, and `MCP_CORS_ORIGINS=https://claude.ai`
4. In claude.ai under Settings β MCP Servers, add: `https://your-app.onrender.com/mcp`
> π‘ *"stdio for the developer laptop, Streamable HTTP for the browser."*
Health probe: `GET /health`. Full deployment guide (container, load balancing, CORS,
resource limits): [`docs/deployment.md`](docs/deployment.md).
---
## Available Tools
| Tool | Description |
|------|-------------|
| `zh_edu_list_schulgemeinden` | List all school communities / Schulkreise in Canton Zurich |
| `zh_edu_schulkreis_trend` | Pupil trend by Schulkreis (2000βpresent) |
| `zh_edu_overview` | Canton-wide learner overview by school level |
| `zh_edu_sek1_profil` | Secondary I profile (Sek A/B/C breakdown) |
| `zh_edu_staatsangehoerigkeiten` | Nationality structure of pupils per school community |
| `zh_edu_maturitaetsquote` | Gymnasium graduation rates by municipality / district |
| `zh_edu_wohnort_trend` | Residence-based learner trend (Bezirk / Gemeinde) |
| `zh_edu_mittelschulen` | Secondary school statistics (Gymnasium, FMS, HMS) |
### Example Use Cases
| Query | Tool |
|-------|------|
| *"List all Schulkreise in Zurich"* | `zh_edu_list_schulgemeinden` |
| *"Pupil trend in Letzi over 5 years"* | `zh_edu_schulkreis_trend` |
| *"How many Sek A vs Sek B in Winterthur?"* | `zh_edu_sek1_profil` |
| *"Top nationalities in ZΓΌrich-Letzi"* | `zh_edu_staatsangehoerigkeiten` |
| *"MaturitΓ€tsquote of Stadt ZΓΌrich"* | `zh_edu_maturitaetsquote` |
---
## Architecture
```
βββββββββββββββββββ ββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββ
β Claude / AI ββββββΆβ zh-education-mcp ββββββΆβ BISTA Kanton ZΓΌrich β
β (MCP Host) βββββββ (MCP Server) βββββββ REST/CSV (Public API) β
βββββββββββββββββββ β β ββββββββββββββββββββββββββββ
β 8 Tools β
β Stdio | Streamable HTTP β
β 24h Cache β
β No authentication required β
ββββββββββββββββββββββββββββββββ
```
### Data Source Characteristics
| Source | Protocol | Coverage | Auth | Update |
|--------|----------|----------|------|--------|
| BISTA Kanton ZH | REST/CSV | Learner statistics 2000βpresent | None | Annual (15 Sep) |
---
## Project Structure
```
zh-education-mcp/
βββ src/zh_education_mcp/
β βββ __init__.py # Package
β βββ config.py # ENV settings (MCP_*)
β βββ constants.py # API base, endpoints, timeouts
β βββ logging_setup.py # structured stderr logging
β βββ provenance.py # response envelope, license attribution
β βββ http_client.py # egress guard, connection pool, lifespan
β βββ data.py # cache, CSV fetch, filters, error handling
β βββ models.py # Pydantic input models
β βββ tools.py # FastMCP instance, 8 tools, 2 resources
β βββ server.py # thin composition layer + entrypoint
βββ tests/
β βββ test_server.py # Unit tests (mocked HTTP with respx)
βββ docs/ # deployment, security, egress, roadmap, β¦
βββ Dockerfile # multi-stage, non-root, healthcheck
βββ docker-compose.yml # resource limits, read-only rootfs
βββ .github/workflows/ci.yml # GitHub Actions (Python 3.11/3.12/3.13)
βββ .github/dependabot.yml # monthly dependency updates
βββ pyproject.toml
βββ CHANGELOG.md
βββ CONTRIBUTING.md # + CONTRIBUTING.de.md
βββ SECURITY.md # + SECURITY.de.md
βββ LICENSE
βββ README.md # This file (English)
βββ README.de.md # German version
```
---
## Known Limitations
- **Annual updates only:** BISTA data is updated once per year (reference date: 15 September). The 24h in-memory cache matches this cycle.
- **CSV-based API:** The BISTA API returns CSV data; large datasets may take a moment to parse.
- **School community names:** Names must match exactly (use `zh_edu_list_schulgemeinden` to find valid names).
---
## Safety & Limits
| Topic | Details |
|-------|---------|
| **No personal data** | BISTA statistics are aggregated β no individual pupil data is exposed or accessible. All figures are anonymized at the school community level. |
| **Read-only** | All tools are read-only (`readOnlyHint: true`). The server cannot modify, delete, or write any data. |
| **No authentication** | The BISTA API is fully public. No API keys, tokens, or credentials are stored or transmitted. |
| **Rate limits** | The BISTA API has no documented rate limit, but the server uses a 24h in-memory cache to minimize requests. Please use responsibly. |
| **Data license** | All data is published under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) by the Canton of Zurich. Attribution: *Bildungsstatistik Kanton ZΓΌrich (BISTA)*. |
| **Terms of Service** | Usage is subject to the [BISTA terms of use](https://pub.bista.zh.ch). The MCP server is an independent open-source project and is not affiliated with the Canton of Zurich. |
| **AI output disclaimer** | Statistics are passed through as-is from the BISTA API. AI-generated interpretations or summaries should be verified against the [official BISTA portal](https://pub.bista.zh.ch). |
---
## Testing
```bash
# Unit tests (no API calls)
PYTHONPATH=src pytest tests/ -m "not live"
# Integration tests (live API calls)
pytest tests/ -m "live"
```
The live suite also runs **on a schedule**: every Monday 05:23 UTC, plus on
demand ([`live-tests.yml`](.github/workflows/live-tests.yml)). A red run opens
an `upstream`-labelled issue; a green one closes it. `-m "not live"` is not a
place where tests run, and these three are the only tests here that can
contradict a wrong assumption about BISTA β see
[CONTRIBUTING](CONTRIBUTING.md#the-live-suite-when-it-runs-and-who-sees-a-red-result).
---
## MCP Protocol Version
This server speaks **two protocol eras** over the same endpoint. The client's
first request on a connection decides which one applies; a later claim from the
other era is refused.
| Era | Revision | Who reaches it |
|---|---|---|
| `initialize` handshake | `2024-11-05` β¦ **`2025-11-25`** | What today's clients speak. The server answers with the revision asked for, or with the `2025-11-25` ceiling when the request asks for something newer. |
| Per-request envelope | **`2026-07-28`** | A request carrying the `2026-07-28` `_meta` envelope opens a modern connection. |
Both revisions are pinned in
[`tests/test_protocol_version.py`](tests/test_protocol_version.py) and asserted
against the installed SDK, so a Dependabot bump of `mcp` cannot move either one
silently. The handshake ceiling is measured against a live `initialize` through
the assembled ASGI stack, not read off a constant name.
Note that the SDK's `LATEST_PROTOCOL_VERSION` is an alias for the **modern**
era, not for the handshake era β pinning against it alone would leave the era
that current clients actually negotiate free to drift.
**Update policy.** When the gate fails, do not edit the constant blindly: read
the spec changelog between the two revisions, verify the server still behaves,
then move the constant, this section, `README.de.md` and
[`CHANGELOG.md`](CHANGELOG.md) together.
**Project phase:** Phase 1 β *read-only* (all tools `readOnlyHint: true`). See
[docs/roadmap.md](docs/roadmap.md).
---
## Release
Publishing is driven by a GitHub release (`release: published` β
[`publish.yml`](.github/workflows/publish.yml)): build β gate β PyPI β MCP Registry.
The gate ([`check_release_artifacts.py`](scripts/check_release_artifacts.py)) runs
**before** the upload and inspects the built wheel, not the sources:
- exactly one `mcp-name:` marker (an HTML comment, see the bottom of this file)
in the wheel METADATA β the MCP Registry proves PyPI ownership with it β and it
must match `server.json` `name`
- `server.json` `description` at most 100 characters; the Registry answers `422`,
and only *after* the PyPI upload has already succeeded
- `server.json` version equal to `pyproject.toml` version, and the git tag equal
to the version actually built
It sits ahead of the upload because nothing is correctable behind it: a PyPI
version is immutable, so a fix costs a version bump.
**If you edit this README:** the marker at the very bottom is shipped as part of
the package description (`readme = "README.md"` in `pyproject.toml`), and removing
it fails the gate. `README.de.md` carries no marker and is not published. The
version badge above is checked on every pull request by
[`check_version_sync.py`](scripts/check_version_sync.py) against `pyproject.toml`
and `server.json`.
---
## Changelog
See [CHANGELOG.md](CHANGELOG.md)
---
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) Β· [π©πͺ Beitragen](CONTRIBUTING.de.md)
---
## Security
See [SECURITY.md](SECURITY.md) Β· [π©πͺ Sicherheit](SECURITY.de.md)
---
## License
MIT License β see [LICENSE](LICENSE)
---
## Author
Hayal Oezkan Β· [malkreide](https://github.com/malkreide)
---
## Credits & Related Projects
- **BISTA:** [pub.bista.zh.ch](https://pub.bista.zh.ch) β Bildungsstatistik Kanton ZΓΌrich (CC BY 4.0)
- **Protocol:** [Model Context Protocol](https://modelcontextprotocol.io/) β Anthropic / Linux Foundation
- **Related:** [zurich-opendata-mcp](https://github.com/malkreide/zurich-opendata-mcp) β Zurich city open data (parking, weather, parliament)
- **Related:** [swiss-cultural-heritage-mcp](https://github.com/malkreide/swiss-cultural-heritage-mcp) β Swiss cultural heritage data
- **Related:** [fedlex-mcp](https://github.com/malkreide/fedlex-mcp) β Swiss federal law
- **Portfolio:** [Swiss Public Data MCP Portfolio](https://github.com/malkreide)
<!-- mcp-name: io.github.malkreide/zh-education-mcp -->
<!-- BEGIN GENERATED: install -->
## Installation
Run via [`uv`](https://docs.astral.sh/uv/)'s `uvx` β no clone or manual install needed. Add to your MCP client config (`mcpServers` for Claude Desktop, Cursor and Windsurf; use a top-level `servers` key for VS Code in `.vscode/mcp.json`):
```json
{
"mcpServers": {
"zh-education-mcp": {
"command": "uvx",
"args": [
"zh-education-mcp"
]
}
}
}
```
<!-- END GENERATED: install -->
TDQS
Scored across 8 tools
Each tool targets a unique aspect of Zurich education data: listing districts, trends per district, canton-wide overview, Sek1 profiles, nationalities, maturity rates, residence trends, and secondary school statistics. The two trend tools are clearly differentiated by school location vs. residence.
All tools consistently use the zh_edu_ prefix and snake_case, with descriptive names that reflect their purpose (e.g., list_schulgemeinden, maturitaetsquote). The mix of German and English terms is predictable and does not hinder usability.
Eight tools is well within the ideal range for an education statistics server. Each tool covers a distinct facet without redundancy, making the set feel appropriately scoped.
The set covers listing, trends, profiles, demographics, and specific school types comprehensively. Minor gaps exist, such as a lack of tools for raw data export or per-school detailed statistics, but the core analytics needs are well met.