[0.6.0] – 2026-08-03
Dieses Release beginnt mit einem geröteten Nacht-Job und endet bei fünf stillen
Datenfehlern. Der ConnectTimeout, an dem test_slf_snow scheiterte, war ein
Netzausfall — kein Befund. Beim Schärfen der Tests, die ihn hätten einordnen
sollen, kam heraus, dass die Zusicherungen der Live-Suite unter pytest nie etwas
zum Scheitern brachten: sie druckten ein ❌ und zählten hoch. Was danach sichtbar
wurde, betrifft die Nutzenden direkt — eine Datensatzsuche, die für jede
Anfrage nichts fand, ein Kantonsfilter, der hartkodierte Beispielstationen als
Suchergebnis ausgab, und ein Rug-Pull-Schutz, der die Parameter der Tools nie
gesehen hatte.
Die 21 Tools bleiben unverändert, keine Eingabe fällt weg. Geändert hat sich,
was drei von ihnen antworten — und dass hydrodaten.admin.ch gar nicht mehr
kontaktiert wird; die Egress-Allow-List und die Beispiel-Netzwerkpolicy sind
entsprechend gekürzt. Wer die Policy gespiegelt hat, kann den Host dort
streichen.
Fixed
-
env_bafu_datasetsfand nichts — für jede Suche, seit jeher. Der CKAN-Filter
lautetefq=organization:bafu. Diesen Slug gibt es auf opendata.swiss nicht
(organization_show?id=bafuliefert kein JSON); die Organisation heisst
bundesamt-fur-umwelt-bafuund trägt 362 Datensätze. CKAN meldet einen
unbekannten Organisationsfilter nicht als Fehler, sondern antwortet mit
count: 0— die Suche sah aus wie ein leeres, gültiges Ergebnis. Live geprüft:
q='Luftqualität'liefert mit dem falschen Slug 0, mit dem echten 5 Treffer.Dieselbe Zeile stand in
fetch_nabel_data.env_nabel_currentzeigte deshalb
nie den Block „Verfügbare Datensätze auf opendata.swiss"; er erscheint jetzt.Betroffen war auch der Portal-Link:
opendata.swiss/de/organization/bafu
zeigt auf eine Organisation, die es nicht gibt — 7 Vorkommen in der
Tool-Ausgabe, alle nachgezogen. Slug und Link kommen neu aus je einer
Konstante (OPENDATA_BAFU_ORG,OPENDATA_BAFU_URL), damit es keine zweite
Stelle gibt, die man vergessen kann.4 neue Tests: der Slug selbst, der ausgehende
fqbeider Aufrufer (nur er
beweist, dass der Wert auch ankommt) und ein Scan übersrc/, der den toten
Portal-Link nicht zurückkommen lässt. Mutationsgetestet: setzt man den Slug
aufbafuzurück, fallen drei davon.Gefunden hat das erst der geschärfte Live-Test aus #59 — die alte Zusicherung
prüfte nur, ob „opendata.swiss" irgendwo im Text steht, was auch „0 Treffer"
erfüllt. -
env_hydro_stationsbeantwortete jede Kantonsabfrage mit fünf hartkodierten
Beispielstationen. Den Kantons-Code lieferte allein
hydrodaten.admin.ch/lhg/az/json/mobile_stations.json; dieser Endpoint ist
stillgelegt und antwortet mit 404 — für zwei Nachbar-Endpoints unter/lhg/az/
war das im Code bereits vermerkt, für diesen nicht. Der Kanton-Pfad ging per
Konstruktion dorthin, lief ins 404 und landete im Fallback. Fürcanton='ZH'
kamen drei Stationen mit plausiblen Namen zurück; nichts daran war als
eingebettete Beispielliste zu erkennen.Neu sagt das Tool ab: es nennt die stillgelegte Quelle, hält fest, dass nicht
gesucht wurde, und verweist aufwater_bodyund die vollständige Liste. Kein
Request geht dafür mehr raus.fetch_hydro_stationsist entfernt — LINDAS
trägt die Stationsliste (233 Stationen), führt aber kein Kantons-Attribut.Die Absage behauptet bewusst nichts über den übergebenen Wert:
cantonist
nicht gegen die 26 Kantone validiert, einXXkommt durch, und ein Satz wie
«dort gibt es Messstationen» wäre dann schlicht falsch.Mitgezogen sind die Feld-Beschreibung im Input-Schema und beide READMEs.
MCP-Clients lesen das Schema, nicht den Docstring des Tools; stünde dort weiter
«Kantonskürzel zum Filtern», würden Modelle den Parameter wählen und eine
Absage ernten. -
Der Ausfall-Fallback von
env_hydro_stationsignorierte
response_format. Er baute Markdown und gab es zurück, auch wenn der
Aufrufer die Envelope angefordert hatte — ein Client, der JSON parst, bekam
ausgerechnet im Störungsfall Text, an demjson.loadsscheitert. Der Fallback
liefert jetzt beide Formate; im JSON stehtprovenance: "fallback"und eine
note, die die Liste als eingebettete Auswahl statt als Suchergebnis
ausweist. Im Markdown steht dasselbe in der Überschrift der Tabelle.5 neue bzw. umgeschriebene Tests, darunter der tragende Fall „Kantonsabfrage
setzt keinen einzigen Request ab" — nur er unterscheidet die Absage von einem
Fallback, der bloss anders formuliert ist. -
env_hydro_currentfragte bei jeder unbekannten Station einen
stillgelegten Endpoint. Fand LINDAS die Stationsnummer nicht — oder fiel es
aus —, ging ein Fallback-Request an
hydrodaten.admin.ch/lhg/az/json/{id}.json. Dieser Pfad ist seit langem
vollständig stillgelegt (404); für drei Nachbar-Endpoints stand das bereits im
Code. Der Request konnte nichts liefern, kostete aber einen Roundtrip und
färbte die Fehlermeldung mit einem HTTP 404, der nichts über die Station
aussagte.Der Fallback ist entfernt, und die beiden Fälle sind jetzt unterscheidbar:
fällt LINDAS aus, meldet das Tool „nicht abrufbar" samt Ursache; antwortet
LINDAS ohne Treffer, meldet es genau das — mit Verweis auf
env_hydro_stationsfür gültige Nummern. Der Transportfehler bleibt in der
Exception-Kette, damit der Live-Hook aus #58 einen Netzausfall weiterhin von
einem Befund unterscheiden kann.Damit kontaktiert der Server
hydrodaten.admin.chnirgends mehr: der letzte
Fetcher auf/lhg/az/(fetch_hydro_station_data) ist weg, die Basis-URLs
dieses Pfads sind aus dem Modul entfernt, und der Host ist — wie zuvor
naturgefahren.ch — aus der Egress-Allow-List genommen (SEC-021). Als
Text-Link in der Tool-Ausgabe bleibt die Domain erhalten.3 neue bzw. umgeschriebene Tests, darunter der tragende Fall „der stillgelegte
REST-Pfad wird nicht mehr angefasst" (call_count == 0) und der Nachweis, dass
der Egress-Guard den Host jetzt blockt. -
env_flood_warningsliess einen gesetzten Kanton wie einen angewendeten
Filter aussehen. LINDAS führt keinen Kantons-Code, die Auswertung ist also
immer schweizweit. Das Tool sagte das auch — im Markdown als Klammerzusatz am
Ende einer Zeile, und im JSON gar nicht, sobald es keine Warnungen gab.Genau dort ist der Hinweis am wichtigsten: „keine aktiven Warnungen" plus ein
Kanton in der Anfrage liest sich als kantonale Entwarnung. Bei einem
Hochwasser-Tool ist das der Fehler, den man nicht machen will.Der Hinweis steht jetzt als eigene Warnzeile über der Tabelle und geht im
JSON nie verloren. Dazu istmatch_typebei gesetztem, nicht angewendetem
Filterfuzzystattexact— die maschinenlesbare Fassung derselben Aussage
für Clients, die der Envelope vertrauen.Die Feld-Beschreibung im Input-Schema sagt neu „NICHT ANGEWENDET" statt
„Kantonskürzel zum Filtern"; beide READMEs sind nachgezogen. Anders als bei
env_hydro_stationsbleibt der Parameter wirksam beantwortet: die gezeigten
Warnungen sind echt, nur eben schweizweit — sie zu verwerfen wäre bei
Sicherheitsdaten die schlechtere Antwort.5 neue Tests, darunter der tragende Fall „leere Antwort auf eine kantonale
Frage nennt den nicht angewendeten Filter" und der Nachweis, dass sich
gefilterte und ungefilterte Anfrage nur inmatch_typeunterscheiden, nicht in
den Daten. -
Streamable-HTTP wies unter jedem echten Hostnamen mit 421 ab (SEC-005).
build_cors_app()riefmcp.streamable_http_app()ohnehostauf. Unter
mcp 2.x ist das kein neutraler Default: das SDK leitet daraus seine
Host-Allow-List ab und aktiviert bei loopback-artigem Wert automatisch
127.0.0.1:*. Da das Argument selbst auf127.0.0.1defaultet, traf das jeden
Container mitMCP_HOST=0.0.0.0(Dockerfile/render.yaml). Vor der Migration
ginghostan denFastMCP-Konstruktor, wo dieselbe Logik den echten Bind sah
und den Schutz korrekt ausliess.Der SSE-Zweig war nicht betroffen: dort geht
hostanmcp.run(), wo das
SDK den echten Bind sieht. Nur der Streamable-HTTP-Pfad liess ihn aus.Der Bind reist jetzt in die App, und eine echte Allow-List wird aus dem neuen
MCP_ALLOWED_HOSTSgebaut. Ohne diese Variable bleibt der Schutz auf einem
Nicht-Loopback-Bind bewusst aus und der Aufrufer warnt — eine geratene Liste
wäre genau der 421-Fall.Der CORS-Default dieses Servers ist
*; als Transport-Origin wird er bewusst
nicht übernommen, weil Origins literal verglichen werden und ein Eintrag*
nichts erlauben würde. Ein Test hält das fest.13 neue Tests, darunter der tragende Fall „richtiger Hostname, falscher Port"
— nur er unterscheidet eine portgenaue Allow-List von einer, die alles
durchlässt. Mutationsgetestet: nimmt man denhost-Kwarg wieder weg,
reproduziert der Test das 421.Geprüft mit den wörtlichen CI-Kommandos: 143 passed / 1 skipped / 23
deselected,ruff check src/clean,ruff format --check src/(10 files
already formatted). -
Der Rug-Pull-Schutz sah die Parameter der Tools nie (SEC-022). Der
Snapshot lasinput_schema["properties"].keys()— und ein Tool dieses Servers
hat dort genau eine Property:params, deren Pydantic-Modell unter
$defsliegt und per$refreferenziert wird. Für alle 21 Tools stand
deshalb dieselbe Liste["params"]im Hash. Eine umbenannte, entfernte oder
in ihrer Bedeutung gedrehte Eingabe war unsichtbar; abgedeckt waren faktisch
nur Tool-Namen und Tool-Descriptions.Genau dieser blinde Fleck hat den Fehler durchgelassen, den der Review am
Kantonsfilter fand: das Tool sagte längst ab, die Feld-Beschreibung im Schema
warb weiter mit „Kantonskürzel zum Filtern" — und der Snapshot merkte nichts.
MCP-Clients lesen aber genau dieses Schema.Der
$refwird jetzt aufgelöst (auch der zweite, unter dem Pydantic Enums
ablegt). Gehasht werden je Feld: Name, Pflicht-Status, Beschreibung, Default
und die Validierungs-Schranken —pattern, Längen, Grenzwerte,enum. Eine
still gelockerte SEC-018-Whitelist fällt damit ebenfalls auf.Bewusst nicht aufgenommen:
type. Optionale Felder erscheinen je nach
Pydantic-Version alstypeoder alsanyOf— genau die Nicht-Reproduzierbar-
keit, wegen der der ursprüngliche Snapshot das rohe Schema mied. Der neue Hash
ist auf allen drei CI-Versionen identisch: 3.11.15, 3.12.3 und 3.13.12 liefern
2e8b4c721ca1.Die normalisierten Definitionen stehen neu im File, nicht nur als Hash.
Bei Drift will man sehen, was sich geändert hat —git diff tool-snapshot.json
zeigt die umbenannte Eingabe statt nur einer anderen Prüfsumme.6 Tests, darunter die beiden tragenden Mutationen: eine geänderte
Feld-Beschreibung und eine gelockertepatternmüssen den Hash bewegen. -
Der nächtliche Live-Lauf wurde rot, wenn ein Upstream kurz nicht ans
Telefon ging (OPS-001). Am 03.08.2026 risstest_slf_snowden Job mit
httpx.ConnectTimeoutgegenmeasurement-api.slf.ch— dreimal in Folge, denn
der Client wiederholt transiente Fehler bereits selbst (3 Versuche in ~16 s).
Dieselbe API antwortete davor und danach; von 15 Läufen scheiterten drei, je
an einem anderen Host.Diese Tests prüfen den Vertrag echter Fremd-APIs: liefert die Quelle noch,
was dieser Server aus ihr liest? Kam die Verbindung gar nicht erst zustande,
beantwortet der Lauf diese Frage nicht — er scheiterte an der Leitung. Ein
roter Job behauptet dann einen Befund, den es nicht gibt, und genau das
stumpft den nächtlichen Alarm ab.Ein Hook in
tests/conftest.pystuft deshalb einenlive-Test, der an einem
reinen Transportfehler scheitert, zu SKIPPED herab. Die Exception-Kette
wird dabei mitgelaufen, weil die Tools den httpx-Fehler einpacken
(ToolErrormit__context__, LINDASQueryTimeoutErrormit__cause__) —
ohne Kettenlauf würde nur der direkte API-Aufruf erkannt.Bewusst weiterhin rot: alles, was eine Antwort voraussetzt — HTTP 4xx/5xx,
geändertes Schema, verletzte Assertions, einSecurityErrordes
Egress-Guards. Ebenso rot bleibt ein Transportfehler in der gemockten
Standard-Suite: dort gibt es kein Netz, das ausfallen könnte.Übersprungen heisst nicht unsichtbar:
pytest_terminal_summaryschreibt einen
eigenen Block mit Zielhost und Fehlerklasse. Trifft es denselben Host mehrere
Nächte hintereinander, ist der Dienst tatsächlich weg — dann untersuchen.9 neue Tests, darunter der tragende Fall „HTTP 500 ist kein Transportfehler"
— nur er unterscheidet die Herabstufung von einem generellen
Live-Fehler-Schlucker. End-to-end mutationsgeprüft: mit einem Transport, der
für*.slf.cheinenConnectTimeoutwirft, meldettest_slf_snowohne den
Hook exakt den CI-Fehler (FAILED … - httpx.ConnectTimeout) und mit ihm
SKIPPED samt genanntem Zielhost.Geprüft mit den wörtlichen CI-Kommandos: 152 passed / 1 skipped / 23
deselected,pytest -m live23 passed,ruff checkund
ruff format --checkübersrc/ tests/ scripts/clean. -
Die Zusicherungen der Live-Suite waren wirkungslos (OPS-001).
check()
druckte bei einem Fehlschlag ein ❌ und zählte hoch — mehr nicht. Unter pytest
scheitert ein Test aber ausschliesslich an einer durchschlagenden Exception,
und dassys.exit(1)steht inmain(), also im Standalone-Pfad
(python tests/test_integration.py), den die CI nie aufruft. Alle ~100
Zusicherungen vontest_integration.pywaren damit Dekoration; rot wurde der
nächtliche Job nur, wenn ein Tool eine Exception warf.Nachweis, dass das nicht theoretisch war: zwei Zusicherungen in
test_nabel_stationsscheiterten seit der Envelope-Umstellung (SDK-002)
unbemerkt — sie prüftentotalundnabel_stationen, während das Tool längst
count/results/match_typeliefert. Der Job war jede Nacht grün.check()wirft neu einenAssertionError. Bewusst sofort statt gesammelt:
so meldet pytest einen regulären FAILED-Test statt eines Fehlers im Teardown.
main()fängt ihn ab, damit der Standalone-Lauf weiterhin alle Tests
durchläuft und am Ende bilanziert. Die zwei veralteten NABEL-Zusicherungen
sind auf die Envelope-Form nachgezogen.Damit ein Netzausfall dadurch nicht doch als Vertragsbruch erscheint, reicht
_tool_texteinenToolErrordurch, hinter dem ein reiner Transportfehler
steckt — der Hook stuft ihn dann zu SKIPPED herab, statt die Meldung durch die
Zusicherungen fallen zu lassen. End-to-end geprüft: mit einem Transport, der
jeden Connect in einen Timeout laufen lässt, endet die Live-Suite mit
11 skipped / 7 passed (die netzwerkfreien Tools) und 0 failed. -
env_snow_stationsundenv_avalanche_bulletinbestanden ihre Live-Tests
auch bei totem SLF. Beide Tests liefen über_tool_text, das den
ToolErrorabfängt, und prüften dann nur, ob „SLF" bzw. „Bulletin" im Text
steht — was die Fehlermeldungen („SLF-Stationsliste nicht abrufbar",
„Lawinenbulletin nicht abrufbar") ebenfalls erfüllen. Die Tests konnten
nichts widerlegen.Neu prüfen sie den Nutzinhalt:
env_snow_stationsgegen die JSON-Hülle
(nicht-leere GR-Trefferliste,countkonsistent, Kantonsfilter greift, alle
fünf Felder vorhanden, aus denen die Tabelle gebaut wird) und gegen die
gerenderte Markdown-Tabelle;env_avalanche_bulletingegen die beiden
gültigen Saison-Zweige, die unterscheidbar bleiben müssen (Zweig-Logik selbst
ist gemockt abgedeckt). Beide rufen das Tool direkt auf, damit einToolError
durchschlägt.Mutationsgeprüft gegen einen Upstream, der antwortet, aber nicht mehr das
Erwartete: bei umbenanntem Feld (elevation→hoehe, leeres Kantonsfeld)
und bei HTTP 500 bestanden beide Tests vorher — jetzt scheitern sie. Bei
unerreichbarem SLF bestanden sie vorher ebenfalls; jetzt werden sie
übersprungen, mit genanntem Zielhost. -
Drei weitere Live-Tests bestanden bei totem Netz. Nach
env_snow_stations
undenv_avalanche_bulletin(#59) bliebentest_hydro_stations,
test_hydro_historyundtest_nabel_current: alle drei prüften nur Inhalte,
die auch ohne Upstream im Text stehen — statische Links, das eingebettete
NABEL_STATIONS-Dict, die Fallback-Tabelle. Dazutest_bafu_datasets, dessen
Zusicherungen („opendata.swiss steht im Text", „Länge > 50") auch die Antwort
«0 Treffer» erfüllten. Genau diese Blindheit liess den falschen CKAN-Slug und
den toten Kanton-Pfad monatelang unbemerkt.Geprüft wird jetzt der Live-Anteil: der aktuelle LINDAS-Messwert, der
CKAN-Datensatzblock, die Trefferzahl (>0 bzw. >100 für den vollen Katalog),
die Stationszahl (>100) und der Gewässerfilter. Der Kantonsfilter prüft die
Absage samt Nachweis, dass keine Beispielstation als Treffer auftaucht.Fängt ein Tool den Upstream-Fehler selbst ab, fehlt dem Hook aus #58 der
Transportfehler, an dem er «Leitung» von «Befund» unterscheidet. Fehlt der
erwartete Live-Block, ruft der Test die rohe API deshalb direkt auf und holt
die verschluckte Exception nach.Mutationsgeprüft, indem die beiden gerade behobenen Defekte zurückgespielt
wurden (CKAN antwortet mitcount: 0, LINDAS ohne Stationen — beides
Antworten, keine Transportfehler): alle vier Tests scheitern. Bei totem Netz
bestehen nur noch die vier tatsächlich netzwerkfreien Tools (vorher sieben),
der Rest wird übersprungen.