{
  "markdown": "> 🇨🇭 **Part of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide)**\n\n# 📊 zh-education-mcp\n\n![Version](https://img.shields.io/badge/version-0.2.7-blue)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)\n[![MCP](https://img.shields.io/badge/MCP-Model%20Context%20Protocol-purple)](https://modelcontextprotocol.io/)\n[![No Auth Required](https://img.shields.io/badge/auth-none%20required-brightgreen)](https://github.com/malkreide/zh-education-mcp)\n![CI](https://github.com/malkreide/zh-education-mcp/actions/workflows/ci.yml/badge.svg)\n\n> MCP server for education statistics of the Canton and City of Zurich (BISTA)\n\n[🇩🇪 Deutsche Version](README.de.md)\n\n---\n\n## Overview\n\n`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.\n\n| Source | Data | API |\n|--------|------|-----|\n| **BISTA Kanton Zürich** | Learner statistics (Volksschule, Mittelschulen, Maturität) | REST/CSV |\n\nAll 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).\n\n**Anchor demo query:** *\"How has the number of pupils in school district Letzi developed over the last 5 years?\"*\n\n---\n\n## Demo\n\n<p align=\"center\">\n  <img src=\"assets/demo.png\" alt=\"zh-education-mcp demo: Claude queries BISTA data\" width=\"720\">\n</p>\n\n---\n\n## Features\n\n- 📊 **8 tools** for education data across all school levels\n- 🔍 **School district trends** — pupil numbers for all Schulkreise (Letzi, Glattal, Schwamendingen, Oerlikon, Uto, Waidberg, Zürichberg) from 2000 to present\n- 🏫 **Secondary school profiles** — breakdown by requirement type (Sek A/B/C, Mittelschule, special classes)\n- 🌐 **Nationality structure** — top nationalities of pupils per school community\n- 🎓 **Gymnasium graduation rates** — Maturitätsquote by municipality, district, and canton\n- 📈 **Canton-wide overview** — all learners by school level, type, gender, and nationality\n- 🏠 **Residence-based trends** — pupil counts by place of residence (Bezirk / Gemeinde)\n- 🏛️ **Mittelschulen** — Gymnasium, FMS, HMS statistics\n- 🔓 **No API key required** — all data under CC BY 4.0\n- ☁️ **Dual transport** — stdio (Claude Desktop) + Streamable HTTP (cloud)\n\n---\n\n## Prerequisites\n\n- Python 3.11+\n- [uv](https://github.com/astral-sh/uv) (recommended) or pip\n\n---\n\n## Installation\n\n```bash\n# Clone the repository\ngit clone https://github.com/malkreide/zh-education-mcp.git\ncd zh-education-mcp\n\n# Install\npip install -e .\n# or with uv:\nuv pip install -e .\n```\n\nOr with `uvx` (no permanent installation):\n\n```bash\nuvx zh-education-mcp\n```\n\n---\n\n## Quickstart\n\n```bash\n# stdio (for Claude Desktop)\npython -m zh_education_mcp.server\n\n# Streamable HTTP (port 8000)\npython -m zh_education_mcp.server --http --port 8000\n```\n\nTry it immediately in Claude Desktop:\n\n> *\"Wie hat sich die Lernendenzahl im Schulkreis Letzi entwickelt?\"*\n> *\"Zeige die Maturitätsquote der Stadt Zürich\"*\n> *\"Welche Nationalitäten sind in Adliswil am häufigsten?\"*\n\n→ [More use cases by audience](EXAMPLES.md) →\n\n---\n\n## Configuration\n\n### Claude Desktop\n\nEdit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\\Claude\\claude_desktop_config.json` (Windows):\n\n```json\n{\n  \"mcpServers\": {\n    \"zh-education\": {\n      \"command\": \"python\",\n      \"args\": [\"-m\", \"zh_education_mcp.server\"]\n    }\n  }\n}\n```\n\nOr with `uvx`:\n\n```json\n{\n  \"mcpServers\": {\n    \"zh-education\": {\n      \"command\": \"uvx\",\n      \"args\": [\"zh-education-mcp\"]\n    }\n  }\n}\n```\n\n**Config file locations:**\n- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`\n- Windows: `%APPDATA%\\Claude\\claude_desktop_config.json`\n\n### Cloud Deployment (Streamable HTTP for browser access)\n\nFor use via **claude.ai in the browser** (e.g. on managed workstations without local software):\n\n**Render.com (recommended):**\n1. Push/fork the repository to GitHub\n2. On [render.com](https://render.com): New Web Service → runtime **Docker** (uses the provided `Dockerfile`)\n3. Set env vars: `MCP_TRANSPORT=streamable-http`, `MCP_HOST=0.0.0.0`, `MCP_PORT=8000`, and `MCP_CORS_ORIGINS=https://claude.ai`\n4. In claude.ai under Settings → MCP Servers, add: `https://your-app.onrender.com/mcp`\n\n> 💡 *\"stdio for the developer laptop, Streamable HTTP for the browser.\"*\n\nHealth probe: `GET /health`. Full deployment guide (container, load balancing, CORS,\nresource limits): [`docs/deployment.md`](docs/deployment.md).\n\n---\n\n## Available Tools\n\n| Tool | Description |\n|------|-------------|\n| `zh_edu_list_schulgemeinden` | List all school communities / Schulkreise in Canton Zurich |\n| `zh_edu_schulkreis_trend` | Pupil trend by Schulkreis (2000–present) |\n| `zh_edu_overview` | Canton-wide learner overview by school level |\n| `zh_edu_sek1_profil` | Secondary I profile (Sek A/B/C breakdown) |\n| `zh_edu_staatsangehoerigkeiten` | Nationality structure of pupils per school community |\n| `zh_edu_maturitaetsquote` | Gymnasium graduation rates by municipality / district |\n| `zh_edu_wohnort_trend` | Residence-based learner trend (Bezirk / Gemeinde) |\n| `zh_edu_mittelschulen` | Secondary school statistics (Gymnasium, FMS, HMS) |\n\n### Example Use Cases\n\n| Query | Tool |\n|-------|------|\n| *\"List all Schulkreise in Zurich\"* | `zh_edu_list_schulgemeinden` |\n| *\"Pupil trend in Letzi over 5 years\"* | `zh_edu_schulkreis_trend` |\n| *\"How many Sek A vs Sek B in Winterthur?\"* | `zh_edu_sek1_profil` |\n| *\"Top nationalities in Zürich-Letzi\"* | `zh_edu_staatsangehoerigkeiten` |\n| *\"Maturitätsquote of Stadt Zürich\"* | `zh_edu_maturitaetsquote` |\n\n---\n\n## Architecture\n\n```\n┌─────────────────┐     ┌──────────────────────────────┐     ┌──────────────────────────┐\n│   Claude / AI   │────▶│  zh-education-mcp            │────▶│  BISTA Kanton Zürich     │\n│   (MCP Host)    │◀────│  (MCP Server)                │◀────│  REST/CSV (Public API)   │\n└─────────────────┘     │                              │     └──────────────────────────┘\n                        │  8 Tools                     │\n                        │  Stdio | Streamable HTTP     │\n                        │  24h Cache                   │\n                        │  No authentication required  │\n                        └──────────────────────────────┘\n```\n\n### Data Source Characteristics\n\n| Source | Protocol | Coverage | Auth | Update |\n|--------|----------|----------|------|--------|\n| BISTA Kanton ZH | REST/CSV | Learner statistics 2000–present | None | Annual (15 Sep) |\n\n---\n\n## Project Structure\n\n```\nzh-education-mcp/\n├── src/zh_education_mcp/\n│   ├── __init__.py              # Package\n│   ├── config.py               # ENV settings (MCP_*)\n│   ├── constants.py            # API base, endpoints, timeouts\n│   ├── logging_setup.py        # structured stderr logging\n│   ├── provenance.py           # response envelope, license attribution\n│   ├── http_client.py          # egress guard, connection pool, lifespan\n│   ├── data.py                 # cache, CSV fetch, filters, error handling\n│   ├── models.py               # Pydantic input models\n│   ├── tools.py                # FastMCP instance, 8 tools, 2 resources\n│   └── server.py               # thin composition layer + entrypoint\n├── tests/\n│   └── test_server.py           # Unit tests (mocked HTTP with respx)\n├── docs/                        # deployment, security, egress, roadmap, …\n├── Dockerfile                   # multi-stage, non-root, healthcheck\n├── docker-compose.yml           # resource limits, read-only rootfs\n├── .github/workflows/ci.yml     # GitHub Actions (Python 3.11/3.12/3.13)\n├── .github/dependabot.yml       # monthly dependency updates\n├── pyproject.toml\n├── CHANGELOG.md\n├── CONTRIBUTING.md              # + CONTRIBUTING.de.md\n├── SECURITY.md                  # + SECURITY.de.md\n├── LICENSE\n├── README.md                    # This file (English)\n└── README.de.md                 # German version\n```\n\n---\n\n## Known Limitations\n\n- **Annual updates only:** BISTA data is updated once per year (reference date: 15 September). The 24h in-memory cache matches this cycle.\n- **CSV-based API:** The BISTA API returns CSV data; large datasets may take a moment to parse.\n- **School community names:** Names must match exactly (use `zh_edu_list_schulgemeinden` to find valid names).\n\n---\n\n## Safety & Limits\n\n| Topic | Details |\n|-------|---------|\n| **No personal data** | BISTA statistics are aggregated — no individual pupil data is exposed or accessible. All figures are anonymized at the school community level. |\n| **Read-only** | All tools are read-only (`readOnlyHint: true`). The server cannot modify, delete, or write any data. |\n| **No authentication** | The BISTA API is fully public. No API keys, tokens, or credentials are stored or transmitted. |\n| **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. |\n| **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)*. |\n| **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. |\n| **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). |\n\n---\n\n## Testing\n\n```bash\n# Unit tests (no API calls)\nPYTHONPATH=src pytest tests/ -m \"not live\"\n\n# Integration tests (live API calls)\npytest tests/ -m \"live\"\n```\n\nThe live suite also runs **on a schedule**: every Monday 05:23 UTC, plus on\ndemand ([`live-tests.yml`](.github/workflows/live-tests.yml)). A red run opens\nan `upstream`-labelled issue; a green one closes it. `-m \"not live\"` is not a\nplace where tests run, and these three are the only tests here that can\ncontradict a wrong assumption about BISTA — see\n[CONTRIBUTING](CONTRIBUTING.md#the-live-suite-when-it-runs-and-who-sees-a-red-result).\n\n---\n\n## MCP Protocol Version\n\nThis server speaks **two protocol eras** over the same endpoint. The client's\nfirst request on a connection decides which one applies; a later claim from the\nother era is refused.\n\n| Era | Revision | Who reaches it |\n|---|---|---|\n| `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. |\n| Per-request envelope | **`2026-07-28`** | A request carrying the `2026-07-28` `_meta` envelope opens a modern connection. |\n\nBoth revisions are pinned in\n[`tests/test_protocol_version.py`](tests/test_protocol_version.py) and asserted\nagainst the installed SDK, so a Dependabot bump of `mcp` cannot move either one\nsilently. The handshake ceiling is measured against a live `initialize` through\nthe assembled ASGI stack, not read off a constant name.\n\nNote that the SDK's `LATEST_PROTOCOL_VERSION` is an alias for the **modern**\nera, not for the handshake era — pinning against it alone would leave the era\nthat current clients actually negotiate free to drift.\n\n**Update policy.** When the gate fails, do not edit the constant blindly: read\nthe spec changelog between the two revisions, verify the server still behaves,\nthen move the constant, this section, `README.de.md` and\n[`CHANGELOG.md`](CHANGELOG.md) together.\n\n**Project phase:** Phase 1 — *read-only* (all tools `readOnlyHint: true`). See\n[docs/roadmap.md](docs/roadmap.md).\n\n---\n\n## Release\n\nPublishing is driven by a GitHub release (`release: published` →\n[`publish.yml`](.github/workflows/publish.yml)): build → gate → PyPI → MCP Registry.\n\nThe gate ([`check_release_artifacts.py`](scripts/check_release_artifacts.py)) runs\n**before** the upload and inspects the built wheel, not the sources:\n\n- exactly one `mcp-name:` marker (an HTML comment, see the bottom of this file)\n  in the wheel METADATA — the MCP Registry proves PyPI ownership with it — and it\n  must match `server.json` `name`\n- `server.json` `description` at most 100 characters; the Registry answers `422`,\n  and only *after* the PyPI upload has already succeeded\n- `server.json` version equal to `pyproject.toml` version, and the git tag equal\n  to the version actually built\n\nIt sits ahead of the upload because nothing is correctable behind it: a PyPI\nversion is immutable, so a fix costs a version bump.\n\n**If you edit this README:** the marker at the very bottom is shipped as part of\nthe package description (`readme = \"README.md\"` in `pyproject.toml`), and removing\nit fails the gate. `README.de.md` carries no marker and is not published. The\nversion badge above is checked on every pull request by\n[`check_version_sync.py`](scripts/check_version_sync.py) against `pyproject.toml`\nand `server.json`.\n\n---\n\n## Changelog\n\nSee [CHANGELOG.md](CHANGELOG.md)\n\n---\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md) · [🇩🇪 Beitragen](CONTRIBUTING.de.md)\n\n---\n\n## Security\n\nSee [SECURITY.md](SECURITY.md) · [🇩🇪 Sicherheit](SECURITY.de.md)\n\n---\n\n## License\n\nMIT License — see [LICENSE](LICENSE)\n\n---\n\n## Author\n\nHayal Oezkan · [malkreide](https://github.com/malkreide)\n\n---\n\n## Credits & Related Projects\n\n- **BISTA:** [pub.bista.zh.ch](https://pub.bista.zh.ch) — Bildungsstatistik Kanton Zürich (CC BY 4.0)\n- **Protocol:** [Model Context Protocol](https://modelcontextprotocol.io/) — Anthropic / Linux Foundation\n- **Related:** [zurich-opendata-mcp](https://github.com/malkreide/zurich-opendata-mcp) — Zurich city open data (parking, weather, parliament)\n- **Related:** [swiss-cultural-heritage-mcp](https://github.com/malkreide/swiss-cultural-heritage-mcp) — Swiss cultural heritage data\n- **Related:** [fedlex-mcp](https://github.com/malkreide/fedlex-mcp) — Swiss federal law\n- **Portfolio:** [Swiss Public Data MCP Portfolio](https://github.com/malkreide)\n\n<!-- mcp-name: io.github.malkreide/zh-education-mcp -->\n\n<!-- BEGIN GENERATED: install -->\n## Installation\n\nRun 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`):\n\n```json\n{\n  \"mcpServers\": {\n    \"zh-education-mcp\": {\n      \"command\": \"uvx\",\n      \"args\": [\n        \"zh-education-mcp\"\n      ]\n    }\n  }\n}\n```\n<!-- END GENERATED: install -->\n",
  "bytes": 14891,
  "sha": "c83f09430121f8380b7b279581be64d3f6f4b5824f134ce4a352b9d42f0c9955",
  "repo_slug": "malkreide/zh-education-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_malkreide_zh_education_mcp_898213d1/readme"
}