> πŸ‡¨πŸ‡­ **Part of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide)** # πŸ“Š zh-education-mcp ![Version](https://img.shields.io/badge/version-0.3.0-blue) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) [![MCP](https://img.shields.io/badge/MCP-Model%20Context%20Protocol-purple)](https://modelcontextprotocol.io/) [![No Auth Required](https://img.shields.io/badge/auth-none%20required-brightgreen)](https://github.com/malkreide/zh-education-mcp) ![CI](https://github.com/malkreide/zh-education-mcp/actions/workflows/ci.yml/badge.svg) > 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

zh-education-mcp demo: Claude queries BISTA data

--- ## 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 β”‚ β”œβ”€β”€ identity.py # server identity for `_meta` / `server/discover` β”‚ β”œβ”€β”€ 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. ### What the server itself contributes on the modern era Speaking the revision is the SDK's job. What a *server* has to supply is listed here, and measured in [`tests/test_modern_protocol.py`](tests/test_modern_protocol.py) against real requests through the assembled ASGI stack rather than against constants: | What `2026-07-28` requires | How this server supplies it | |---|---| | `serverInfo` stamp on **every** result (spec #3002) | Name, title, version, description and website β€” from the package metadata, see [`identity.py`](src/zh_education_mcp/identity.py) | | `server/discover` ("Servers **MUST** implement") | Answers with `supportedVersions`, `capabilities` and `instructions` | | `instructions` for the model | Reference date, suppressed small counts, attribution duty, read-only scope β€” deliberately the things no tool description carries | | `resultType` on every result | Set by the SDK, asserted here against the wire | | Envelope and header validation (`Mcp-Method`, `Mcp-Name`, `Mcp-Protocol-Version`) | A contradicting header is rejected with `-32020`, a missing envelope with `-32602` | | Freshness hints (SEP-2549) | Every cacheable **listing** method carries `ttlMs` 300,000 / `cacheScope` `public`; `resources/read` deliberately does not | The identity is **not** maintained in `src/`: version, description and website come from the distribution metadata and therefore from `pyproject.toml` β€” the one source [`check_version_sync.py`](scripts/check_version_sync.py) permits. Before this revision the server stamped `version: ""` onto every modern-era result. **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) ## 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" ] } } } ```