Releases: malkreide/zh-education-mcp
Release list
v0.2.7
[0.2.7] - 2026-08-03
Behoben
-
Der DNS-Retry war nur gegen Mocks belegt. Die Unit-Tests faelschen
Aufloeser und Antwort; sie zeigen, dass die Schleife tut, was sie soll,
aber nicht, dass der Aufruf am Ende echte Daten bringt. Genau diese Luecke
hat den Fehler ueberhaupt erst durchgelassen — gemeldet hat ihn am
3. August 2026 ein Live-Lauf, nicht die Suite.Ein Live-Test faelscht jetzt nur noch den ersten Aufloesungsversuch und
laesst alles danach echt: echtes DNS beim zweiten Versuch, echte Verbindung,
echte BISTA-Antwort, echte Backoff-Wartezeit. Ein zweiter Live-Test prueft
die Egress-Blocklist (SEC-005) gegen die echte Antwort des echten Hosts
statt gegen eine erfundene.Zwei Nebenbefunde, beide behoben: Die DNS-Stub-Fixture in
tests/test_retry_policy.pysetzt fuer Live-Tests aus (ein «Live»-Test
gegen einen gestubbten Aufloeser prueft das Gegenteil seines Namens), und
der gepoolte HTTP-Client wird vor und nach jedem Live-Test frisch gesetzt:
Seine offenen Verbindungen gehoeren dem Event-Loop, in dem sie entstanden,
und pytest-asyncio gibt jedem Test einen eigenen. Der zweite Live-Test
scheiterte deshalb mit «Event loop is closed» — an einem Fehler des
Testaufbaus, der wie ein Ausfall der Quelle aussieht. Unit-Tests merken
davon nichts, weil respx die Transport-Schicht ersetzt; latent war es,
bis ein zweiter Live-Test dazukam. -
Ein DNS-Zucken beendete den Tool-Aufruf sofort — und wurde als
Egress-Verstoss gemeldet._resolve_and_validatewarf fuer zwei
grundverschiedene Lagen denselbenPermissionError: fuer den
Policy-Verstoss (Host loest auf eine interne IP auf, SEC-005) und fuer
das Scheitern vonsocket.getaddrinfo(«Temporary failure in name
resolution»). Die Retry-Schleife ueberspringtPermissionErrorbewusst —
richtig fuer die Politik, falsch fuer den Ausfall. Bei einem Live-Lauf am
3. August 2026 scheiterten drei Tool-Aufrufe hintereinander genau so; der
vierte ging durch.Die Lagen tragen jetzt eigene Typen,
EgressBlockedund
UpstreamUnresolvable, beide weiterhin aufPermissionErrorals
gemeinsamer Basis — bestehendeexcept-Klauseln und Tests behalten damit
ihre Bedeutung, statt still ins Leere zu laufen.UpstreamUnresolvablewird wiederholt, unter demselben Budget,
derselben Versuchszahl und derselben Backoff-Kurve wie jeder andere
Ausfall;EgressBlockedwird weiterhin nie wiederholt. Damit das Budget
fuer diesen Pfad auch haelt, laeuft die Aufloesung im Thread-Pool statt im
Event-Loop:getaddrinfoist synchron, und was den Loop blockiert, kann
die Wanduhr-Deadline nicht schneiden — aus vier Versuchen waeren sonst vier
Blockaden ueber das Budget hinaus geworden._handle_errortrennt die Meldungen: Ein Aufloeser-Ausfall verweist nicht
mehr auf die Egress-Konfiguration, wo dabei nichts zu finden ist, sondern
sagt, dass es voruebergehend ist und ein erneuter Versuch die richtige
Handlung. Die sanitisierte Form bleibt (OBS-002, keinestr(e)-Leaks). -
Vier von sechs Datensaetzen waren gegen die echte API kaputt: BISTA hat die
Schreibweise der Kopfzeile gewechselt. Der Code lasr["Schulgemeinde"],
die Quelle liefertschulgemeinde. Der Zugriff ergab keinen Treffer,
sondern ein leeres Ergebnis mit der Meldung «Schulgemeinde nicht gefunden» —
ein Ausfall, der wie eine Antwort aussieht.Stand 3. August 2026 liefern vier der sechs genutzten Datensaetze klein,
zwei gross, und zwei mischen innerhalb einer Kopfzeile
(gebiet_Bezeichnung,staatsangehoerigkeit_ISO2_Code). Die Schluesselnamen
werden deshalb beim Parsen normalisiert, statt eine Schreibweise zu waehlen,
die schon zweimal gewechselt hat.Die Unit-Tests haben davon nichts gesehen: Ihre Fixtures pinnen die alte
Kopfzeile, also blieben sie gruen. Gemeldet hat es allein der Live-Test — den
CI per-m "not live"ausschliesst. -
Ein Fuenftel der Zeilen liess die Tools abstuerzen. BISTA unterdrueckt
kleine Fallzahlen aus Datenschutzgruenden und schreibt statt einer Zahl
1 bis 5; dazu kommenNULLund leere Zellen. Betroffen waren 18.6 % der
Sek-I- und 18.1 % der Staatsangehoerigkeits-Zeilen.int("1 bis 5")wirft,
und der Aufrufer sah davon nur «unerwarteter interner Fehler».Solche Werte als 0 zu zaehlen waere die schlechtere Antwort gewesen: Die
Summe bliebe plausibel, waere still zu tief und durch nichts als falsch
erkennbar. Sie werden jetzt aus den Summen ausgenommen, in der Tabelle als
1 bis 5gezeigt, und jede betroffene Ausgabe traegt einen Hinweis, wie
viele Zeilen fehlen und dass die echten Werte hoeher liegen (FID-003). -
_latest_yearsuchte inJahrstattjahrund lieferte darum «Keine
Jahresdaten verfuegbar» — derselbe Drift, eine Ebene tiefer. -
Staatsangehoerigkeit_ISO2_Codewurde nie gefunden, weil die Spalte
gemischt geschrieben ist. Die ISO2-Spalte blieb dauerhaft auf «—». -
Die
_no_sleep-Fixture griff weiter, als sie durfte. Sie patchte
http_client.asyncio.sleep— das sieht lokal aus, trifft aber das Modul
asynciound damit jeden Import im Prozess. Jeder Test, der
asyncio.sleep(0)benutzt, um dem Event-Loop das Wort zu geben, haette
danach still nichts mehr geprueft: Er laeuft weiter und misst nichts.In diesem Repo gibt es derzeit keinen solchen Test, der Schaden war also
latent. Insrgssr-mcpist derselbe Griff in derselben Kampagne
zugeschnappt und hat eine Parallelitaets-Pruefung entschaerft.Der Backoff laeuft jetzt ueber den Modul-Alias
http_client._sleep, und ein
Test haelt fest, dassasyncio.sleepintakt bleibt.
Hinzugefuegt
-
Retry-Politik gegenueber BISTA (ARCH-014). Bisher gab es keine: Ein
einzelner Netzwerkfehler, ein Timeout oder ein 503 beendete den Tool-Aufruf,
obwohl der naechste Versuch Sekunden spaeter geklappt haette. Genau so fielen
am 1. August inswiss-efv-mcpvier Live-Tests wegen eines voruebergehenden
Ausfalls der Quelle.Wiederholt werden Netzwerkfehler, Timeouts, 5xx und 429 — vier Versuche. Ein
4xx ausser 429 ist eine Aussage ueber die Anfrage und keine ueber den Moment
und scheitert weiterhin sofort.PermissionErroraus dem Egress-Guard ist
eine Policy-Entscheidung und wird nie wiederholt: Vier Mal dieselbe verbotene
Anfrage zu stellen macht sie nicht erlaubter. -
Retry-Afterwird gelesen und schlaegt die eigene Backoff-Kurve. Bei 429
und 503 sagt die Quelle im Header, wann sie wieder mag — als Sekundenzahl
oder als HTTP-Datum; beide Formen kommen vor, beide werden gelesen
(RFC 9110 §10.2.3). Ein unbrauchbarer Header fuehrt zurueck auf die Kurve
statt zum Absturz — auf dem Fehlerpfad ist das der Unterschied zwischen einer
Verzoegerung und einem zweiten Fehler. -
Backoff ist gestreut (Jitter). Eine reine
2**attempt-Kurve ist
deterministisch: Faellt BISTA aus, waehrend mehrere Clients es abfragen,
laufen deren Retries im Gleichtakt, und die Last kommt als Welle zurueck —
genau wenn die Quelle sich erholt. Exponentielle Wartezeiten landen in
[0.5x, 1.5x]; auf einemRetry-Afterist die Streuung einseitig
([1.0x, 1.25x]), weil frueher wiederzukommen die Missachtung derselben
Angabe waere, die man gerade liest. Deckel von 20 s auf jede Einzelwartezeit,
angewandt nach dem Jittern — die andere Reihenfolge macht den Deckel zu
gar keiner Schranke. -
Gesamtbudget von 25 s ueber den ganzen Aufruf. Eine Versuchszahl ist
keine Grenze: Vier Versuche a 30 s Timeout plus Backoff sind ueber zwei
Minuten, und die Zahl4sagt das nirgends. Entscheidender ist, dass die
massgebliche Grenze gar nicht uns gehoert — der Aufrufer hat sein eigenes
Timeout, und jenseits davon hoert niemand mehr zu. Der Anker ist gemessen:
Das Python-MCP-SDK setztMCP_DEFAULT_TIMEOUT = 30.0.Das Budget haengt an einer
asyncio.timeout-Deadline, nicht am
httpx-Timeout: httpx begrenzt pro Operation, und sein Read-Timeout beginnt
mit jedem Chunk von vorn — eine langsam troepfelnde Antwort wuerde das Budget
sonst ueberdauern, ohne dass ein einzelner Read ablaeuft.
Behoben
- Ein aufgebrauchtes Gesamtbudget las sich als «unerwarteter interner
Fehler». Es wirft den builtinTimeoutError,_handle_errorkannte aber
nurhttpx.TimeoutException. Fuer den Aufrufer ist beides dasselbe: Es hat
zu lange gedauert.
v0.2.6
[0.2.6] - 2026-08-02
Behoben
-
structloghatte keine Obergrenze, und der Index fuehrt bereits einen Major
oberhalb der Untergrenze. Deklariert warstructlog>=24.1.0; auf PyPI liegt
26.1.0. Das Artefakt aendert sich nicht — die Antwort des Resolvers auf
die naechste frische Installation schon, und genau so wurde
swiss-energy-mcp0.3.3 uninstallierbar, alsmcp2.0.0 das Modul entfernt
hat, das es importierte.Neu
structlog>=24.1.0,<27. Die Grenze ist gemessen, nicht geraten: dieses Paket installiert
und importiert heute gegenstructlog 26.1.0, die Obergrenze laesst also zu,
was nachweislich funktioniert, und stoppt nur den naechsten, unbekannten
Major. -
starlettehatte keine Obergrenze, und der Index fuehrt bereits einen Major
oberhalb der Untergrenze. Deklariert warstarlette>=0.37.0; auf PyPI liegt
1.3.1. Das Artefakt aendert sich nicht — die Antwort des Resolvers auf
die naechste frische Installation schon, und genau so wurde
swiss-energy-mcp0.3.3 uninstallierbar, alsmcp2.0.0 das Modul entfernt
hat, das es importierte.Neu
starlette>=0.37.0,<2. Die Grenze ist gemessen, nicht geraten: dieses Paket installiert
und importiert heute gegenstarlette 1.3.1, die Obergrenze laesst also zu,
was nachweislich funktioniert, und stoppt nur den naechsten, unbekannten
Major.
Ein Abhaengigkeitsbereich erreicht die Nutzenden nur ueber ein neues
Release, daher der Versions-Bump. Am Code aendert sich nichts.
v0.2.5
[0.2.5] - 2026-08-02
Behoben
-
Der Server startete gar nicht mehr.
zh-education-mcpohne Argumente
brach sofort ab:ValueError: "Settings" object has no field "host"main()setztemcp.settings.hostundmcp.settings.port— ein Rest der
1.x-API, den die Migration auf das mcp-SDK 2.x uebersehen hat. Unter 2.x
kenntMCPServer.settingsnur nochdebug,log_level,
warn_on_duplicate_*,dependencies,lifespanundauth; pydantic wirft
beim Zuweisen eines unbekannten Feldes.Weil die beiden Zeilen vor der Transport-Weiche standen, war auch stdio
betroffen, nicht nur die HTTP-Transporte — also der Standardfall, mit dem
Claude Desktop den Server startet.Die Zeilen sind ersatzlos entfernt:
_run_httpbekommthostundportals
Argumente und reicht sie an die App und anuvicorn.runweiter. Ueber die
Settings brauchte sie ohnehin niemand. -
Kein Test hat je
main()aufgerufen. Es gab Import-Tests, aber
importieren ist nicht starten, und genau dieser Unterschied war der Fehler.
tests/test_entrypoint.pyprueft jetzt, dassmain()den stdio-Transport
erreicht, dass--host/--portbis zum HTTP-Start durchkommen, und dass das
SDK weiterhin keinhost-Feld in den Settings hat — sollte es zurueckkehren,
schlaegt der Test fehl und jemand entscheidet bewusst, statt dass die alte
Zeile still wieder einzieht.Gegengeprueft: mit den entfernten Zeilen zurueck im Code schlagen zwei der
drei Tests fehl.Aufgefallen ist der Ausfall beim ersten portfolioweiten Lauf einer Sonde, die
das installierte Konsolen-Skript startet statt es nur zu importieren.
Release v0.2.0 — Production Hardening
zh-education-mcp v0.2.0 — Production Hardening
Erstes Production-Hardening-Release nach einem vollständigen MCP-Best-Practice-Audit.
Der Server ist jetzt für den dualen Betrieb (lokal stdio + Cloud HTTP) gehärtet.
Audit-Verifikation: 42/42 anwendbare Checks bestanden · 0 offene Findings · production-ready
(Skill v1.0.0, Catalog-Hash 091f446b, Run 2026-05-30T072745-Z-zh-education-mcp).
✨ Highlights
Architektur & SDK
- ENV-basierte Konfiguration (
MCP_TRANSPORT/MCP_HOST/MCP_PORT/MCP_CORS_ORIGINS/MCP_STATELESS_HTTP) lifespan-verwalteter, gepoolter HTTP-Client (Connection-Pooling statt Client-pro-Call)- Modularer Aufbau:
server.py-Monolith in 9 fokussierte Module aufgeteilt - Strukturierter Response-Envelope (
source/provenance/match_type/count) + CC-BY-Quellen-Fusszeile - Zwei read-only Resources (
zh-edu://datenquellen,zh-edu://lizenz) neben den 8 Tools - Context-Injektion mit Progress-Reports und client-seitigem Logging
Cloud & Scale
- CORS-Middleware für Browser-Clients (exponiert
Mcp-Session-Id) - Stateless HTTP → horizontal skalierbar ohne Sticky Sessions
- Multi-Stage-
Dockerfile(non-root,HEALTHCHECK) +docker-compose.ymlmit Resource-Limits /health-Endpoint für Load-Balancer- und Container-Probes
Security
- Egress-Allow-List (
frozenset+ httpx-Hook), HTTPS-Zwang, Redirect-Schutz - DNS-Auflösung + IP-Blocklist-Validierung vor jedem Egress (Anti-Rebinding, blockt Metadata-IPs)
- Strikte Input-Validierung (
strict=True,extra="forbid") auf allen Tool-Modellen - Sanitisierte Fehler: Originalfehler nur ins stderr-Log, Client erhält generische Meldung
- Execution-Errors als
isError:true(ToolError) - Loopback-Default-Binding (
127.0.0.1);0.0.0.0nur explizit im Container
Observability
- Strukturiertes JSON-Logging auf stderr (
structlog) - Optionales OpenTelemetry-Tracing pro Tool-Call (
[otel]-Extra,MCP_OTEL_ENABLED)
Dokumentation
- Neu:
docs/deployment.md,docs/network-egress.md,docs/security.md,
docs/secret-management.md,docs/roadmap.md,docs/accepted-risks.md,CONTRIBUTING.de.md - README EN+DE: korrigierter Cloud-Endpoint (
/mcp), MCP-Protokoll-Sektion, Phase-1-Deklaration - Dependabot für monatliche Dependency-Updates
⚠️ Bewusste Grenzen (kein Finding)
- SEC-005 / Socket-Pinning: Implementiert ist DNS-Auflösung + IP-Blocklist-Validierung.
Echtes Socket-Level-Pinning (Connect zur exakt validierten IP) bleibt ein dokumentiertes
Restrisiko (kleines TOCTOU-Fenster, durch Single-Host-Allow-List gering) — siehedocs/network-egress.md. - SEC-014 / SEC-015 (Tool-Allow-Listing, Tool-Poisoning-Detection) gehören auf die
MCP-Gateway-Ebene und sind als accepted-risk dokumentiert (docs/accepted-risks.md).
Keine Breaking Changes
Bestehende Tool-Namen und -Signaturen sind unverändert; der stdio-Default-Betrieb funktioniert
wie zuvor. Cloud-Betrieb wird ausschließlich explizit über ENV-Vars aktiviert.
Full Changelog: v0.1.0...v0.2.0
v0.1.0 — Initial Release
feat: add server implementation and align repo with portfolio standards - Create server.py with 8 BISTA tools, 24h cache, and dual transport - Fix pyproject.toml (indentation, mcp[cli] dep, Python 3.13, ruff config) - Fix test_server.py (encoding, typos, corrupted live test, cache fixture) - Rewrite READMEs to match swiss-cultural-heritage-mcp format (EN + DE) - Add claude_desktop_config.json - Fix CI: add Python 3.13, PYTHONPATH, lint tests - Fix publish: release trigger, two-job build+publish with artifact upload Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>