Back to the catalog

io.github.malkreide/zh-education-mcp

Canton and City of Zurich education data: schools, statistics, infrastructure

Open source Open in the app JSON README (API)

About

Canton and City of Zurich education data: schools, statistics, infrastructure

Details

Kind
MCP servers
Topic
Government & public data
Publisher
malkreide
Origin
official
Category
ferramentas
Transport
local
Version
0.2.7
Open pull requests
1
Last push
2026-09-01T21:28:54Z
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/zh-education-mcp

README

> πŸ‡¨πŸ‡­ **Part of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide)**

# πŸ“Š zh-education-mcp

![Version](https://img.shields.io/badge/version-0.2.7-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

<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 -->

More