Skip to content

v0.2.0

Latest

Choose a tag to compare

@malkreide malkreide released this 19 Sep 14:21
· 26 commits to main since this release
c4db04b

[0.2.0] — 2026-09-19

⚠️ Breaking fuer gehostete Deployments. Der Netz-Endpunkt ist /mcp
(Streamable HTTP) statt /sse; der alte Pfad antwortet mit 404. Die
Einstellung TERMDAT_MCP_TRANSPORT bleibt unveraendert gueltig — sse gilt
als Alias, bedient /mcp und warnt beim Start. Clients umstellen.

stdio-Nutzung (Claude Desktop, uvx termdat-mcp) ist nicht betroffen.

Minor statt Patch, obwohl das Schema 0.x fuehrt: Der Endpunktwechsel bricht
bestehende Netz-Clients, und eine Patch-Nummer haette das verschwiegen.

Geaendert

  • Der Netz-Transport ist Streamable HTTP unter /mcp, nicht mehr SSE unter
    /sse.
    Gemessen am 18.09.2026 gegen mcp 2.2.0: Eine tools/list-Anfrage
    mit dem 2026-07-28-Envelope bekam gegen die bisherige App /sse → 405,
    /mcp → 404, /messages/ → 400; gegen streamable_http_app() antwortet
    dieselbe Anfrage mit 200. Die Weiche in die moderne Protokoll-Aera steht in
    StreamableHTTPSessionManager._handle_request; mcp/server/sse.py erwaehnt
    sie nirgends. Der Server erreichte ueber das Netz also nur die Handshake-Aera
    — die Spec-Revision, die beide READMEs seit Monaten als bedient auswiesen,
    war fuer jeden HTTP-Client unerreichbar.

    Unbemerkt blieb das aus zwei Gruenden, die einander deckten: stdio bedient
    die moderne Aera tatsaechlich (ebenfalls nachgemessen, python -m termdat_mcp
    antwortet auf denselben Envelope), und die Suite pinnte die Revision nur
    gegen SDK-Konstanten. Eine Konstante, die 2026-07-28 sagt, sagt nichts
    darueber, ob eine Anfrage dort ankommt.

  • TERMDAT_MCP_TRANSPORT=sse bleibt gueltig, bedient aber /mcp und warnt
    beim Start
    — auf stderr und im Log. Den Wert abzulehnen waere sauberer und
    zugleich der Bruch, der jedes bestehende Deployment beim naechsten Start
    anhaelt. Deployments muessen ihre Clients auf /mcp umstellen; der alte
    Pfad antwortet mit 404. Das Dockerfile setzt jetzt streamable-http.

  • CORS gibt frei, was der Endpunkt tatsaechlich annimmt. DELETE (die
    ausdrueckliche Beendigung einer Session) und Last-Event-ID (Wiederaufnahme
    eines abgerissenen Streams) fehlten in der Freigabeliste — unter SSE gab es
    beides nicht. tests/test_cors.py liest die Methodenliste jetzt aus dem
    Allow-Header einer echten 405-Antwort des SDK statt aus der eigenen
    Konstante: Der parametrierte Test daneben zieht seine Faelle aus derselben
    Liste und haette das Streichen von DELETE mitgestrichen.

Hinzugefuegt

  • tests/test_streamable_http.py — beide Protokoll-Aeren, gemessen statt
    gepinnt.
    Echte HTTP-Anfragen durch den zusammengebauten ASGI-Stack: der
    Handshake antwortet fuer jede der vier Revisionen 2024-11-05 … 2025-11-25
    mit der angefragten und deckelt eine zu neue bei 2025-11-25; der Envelope
    oeffnet die moderne Aera, deren Antwort den Pflicht-resultType und die
    Cache-Hinweise traegt; eine unbekannte moderne Revision wird mit -32022
    samt supported/requested abgewiesen, ein Envelope-loser Aufruf mit
    -32602, ein Header, der dem Body widerspricht, mit -32020.

    Gegenprobe gefahren: Mit mcp.sse_app() an derselben Stelle fallen 14 der 15
    Zusicherungen. Gruen bleibt allein die, die die Deprecation-Warnung prueft —
    die haengt nicht am Transport.

Behoben

  • Die Cache-Hinweise nach SEP-2549 erreichten ueber das Netz keine Leitung.
    server.py setzt CACHE_HINTS seit laengerem, und tests/test_cache_hints.py
    belegt sie ueber eine In-Process-ClientSession. Ueber HTTP kamen sie
    trotzdem nirgends an: Die Handshake-Aera siebt ttlMs und cacheScope aus
    dem Resultat (ihr Schema kennt die Felder nicht, gemessen: die
    tools/list-Antwort traegt ausser tools kein einziges Feld), und die
    moderne Aera, in der die Felder stehen, war unerreichbar. Seit dem Wechsel
    antwortet tools/list mit ttlMs: 300000 und cacheScope: "public".

  • Der Server behauptete, die Nutzungsbedingungen seien unbekannt. Das Feld
    source jeder Antwort sagte «The I14Y catalogue record carries no explicit
    licence statement — clarify terms with the Federal Chancellery before
    republishing». Der Katalogeintrag fuehrt tatsaechlich license: null, die
    Bedingungen gibt es trotzdem: Auf Anfrage hat die Sektion Terminologie der
    Bundeskanzlei am 21.08.2026 geantwortet, dass Weiterverwendung und
    Weiterveroeffentlichung nur unter Angabe der Quelle www.termdat.ch zulaessig
    sind und die Sektion vorgaengig ueber Zweck und Art zu informieren ist. Eine
    Bedingung, die niemand nennt, kann niemand erfuellen — der Hinweis schob die
    Klaerung jedem Einzelnen zu, statt sie einmal einzuholen. ATTRIBUTION nennt
    jetzt beide Auflagen; die Auskunft steht im Wortlaut in
    docs/bk-auskunft-2026-08-21.md.

  • Die Erklaerung der Abdeckungsluecke war eine Vermutung, formuliert wie ein
    Befund.
    READMEs und Tool-Beschreibung leiteten aus einem einzigen
    ausgelieferten Entwurfseintrag (1557) ab, «der Suchindex deckt validierte
    Eintraege ab». Die Bundeskanzlei nennt ein anderes Kriterium: Die Auswahl
    orientiert sich am Bedarf der Uebersetzerinnen und Uebersetzer der
    Bundesverwaltung, die Teilabdeckung ist beabsichtigt und eine vollstaendigere
    Abdeckung ist nicht geplant. Damit faellt auch der Satz weg, das sei «eher ein
    Fall fuer eine Rueckfrage als fuer einen Workaround» — die Rueckfrage ist
    beantwortet, einen Workaround gibt es nicht.

  • check_terms setzte «nicht gefunden» mit «nicht in TERMDAT» gleich. Der
    caveat sagte woertlich «'not_found' means 'not in TERMDAT'», und derselbe
    Kurzschluss stand im hint auf Leermengen («absent from TERMDAT»). Gemessen
    ist nur die API, und die ist erklaertermassen ein Ausschnitt: Beide Stellen
    sagen jetzt «not in the public API» und verweisen auf www.termdat.ch.

  • Tests fuer diese Texte. tests/test_terms_of_use.py prueft, dass die
    Quellenangabe www.termdat.ch und die Meldepflicht nennt, dass sie in einer
    echten Antwort ankommt und dass weder caveat noch hint eine Leermenge
    wieder zu einer Aussage ueber TERMDAT machen. Die Texte sind hier das Produkt
    — das Modell sieht nie die Auskunft, nur diese Felder.

  • Browser-Clients scheiterten am Preflight. Spec 2026-07-28 routet eine
    Anfrage über Mcp-Method, Mcp-Name und Mcp-Protocol-Version; die
    CORS-Freigabeliste nannte keinen davon, dafür mit Mcp-Session-Id den
    Session-Header, der für sich genommen keine Anfrage routet. Ein Browser darf
    einen nicht safelisteten Header nicht senden, wenn der Server ihn nicht
    nennt: die Anfrage starb vor dem ersten MCP-Byte, während stdio und Python,
    für die kein Preflight gilt, weiterliefen. Deshalb war nichts rot.

Hinzugefügt

  • build_http_app(), herausgezogen aus _run_sse. Solange Aufbau und
    uvicorn.run in derselben Funktion standen, liess sich die CORS-Schicht nur
    lesen, nicht ausprobieren — und eine gelesene Freigabeliste kann vollständig
    aussehen und trotzdem nie an der Middleware ankommen. _run_sse ruft die
    neue Funktion auf; am Verhalten ändert sich nichts.

  • Frischehinweise auf tools/list und server/discover (SEP-2549, Spec
    2026-07-28): ttlMs 300000, cacheScope public. Das SDK setzt sonst
    «sofort veraltet, nie geteilt» und lässt damit jeden Client bei jeder
    Verbindung neu auflisten — für eine Liste, die beim Import feststeht.

  • Die Dokumentation nannte ein schmaleres Lint-Gate als die CI faehrt.
    README.md, README.de.md, CONTRIBUTING.md und CONTRIBUTING.de.md
    empfahlen ruff check src tests. Die CI faehrt fuenf Gates, darunter
    scripts/ im Lint-Pfad, ruff format --check und die beiden Pruefskripte.
    Wer der Dokumentation folgte, bekam gruen und danach eine rote CI, deren
    Ursache nicht im Diff stand. Alle vier Stellen nennen jetzt die Gates
    woertlich aus ci.yml.

  • Die READMEs nannten einen mcp-Pin, den es nicht mehr gibt. Dort stand
    >=1.2.0; pyproject.toml fordert seit dem 2.x-Bruch >=2.0.0,<3. Genau
    diese Untergrenze ist der Grund, warum ein frischer Install ueberhaupt
    laeuft — als >=1.2.0 gelesen wirkt sie wie eine unverbindliche Notiz.

  • Die Pruefsummen im Fixture-Nachweis waren Zierde. PROVENANCE.md fuehrt
    je Datei einen SHA-256 — um genau einen Fall zu fangen: eine Aufzeichnung,
    die nach dem Lauf von Hand nachgebessert wurde. Eine korrigierte Antwort ist
    wieder eine erfundene, und von aussen ist ihr das nicht anzusehen.
    Nachgerechnet hat sie kein Test. test_die_pruefsumme_im_nachweis_stimmt
    tut es jetzt, ueber die Bytes auf der Platte statt ueber den Loader — genau
    die hat der Recorder gehasht.

  • detail=False liefert Treffer ohne jede Benennung — und sagte es nicht.
    Der Parameter hängt am Werkzeug termdat_search, war im Docstring aber mit
    keinem Wort erklärt. Er setzt ReturnType=Summary; die Quelle lässt dann
    languageDetails weg, und flatten_entry macht daraus variants: []. Die
    Treffer behalten ID, URL, Status und Klassifikation — aber keine einzige
    Benennung. Für einen Terminologie-Server ist das ein Eintrag ohne Begriff,
    und ein Modell, das den Parameter zum Token-Sparen setzt, bekommt Treffer, aus
    denen es nichts lesen kann.

    Der Docstring sagt es jetzt, mitsamt dem, wofür der Modus taugt (zählen,
    nach Klassifikation filtern) und wofür nicht.

  • Der Fixture-Dispatcher ordnete nach Pfad zu. /Search bekam immer
    search_detail.json — auch für eine Summary-Abfrage. Der Unterschied, um den
    es hier geht, war damit im Test unsichtbar: eine Abfrageform ohne
    Aufzeichnung sah aus wie eine mit. Zugeordnet wird jetzt nach ReturnType,
    und ein unbekannter Wert fällt laut auf, statt still eine fremde Aufzeichnung
    zu bekommen.

  • Protokoll-Gate: beide Spec-Aeren gepinnt und geprueft
    (tests/test_protocol_version.py). mcp 2.x bedient zwei Aeren ueber
    denselben Server — den initialize-Handshake, der bei 2025-11-25
    deckelt, und den Pro-Request-Envelope, der 2026-07-28 erreicht.
    LATEST_PROTOCOL_VERSION ist ein Alias auf die moderne Aera; wer nur
    dagegen pinnt, laesst genau die Aera frei wandern, die heutige Clients
    aushandeln. Beide sind jetzt einzeln gepinnt, ein Dependabot-Bump von
    mcp kann keine davon still verschieben.

    Ohne gemessenen Teil: dieser Server baut keine ASGI-App, durch die sich ein
    initialize schicken liesse. Das Gate haengt deshalb an den SDK-Konstanten —
    die schwaechere Form, im Docstring benannt statt verschwiegen.

    Beide READMEs beschreiben die Aeren; ein Test haelt jede Sprache einzeln
    dagegen — im Portfolio sind EN und DE desselben Repos schon dreimal
    auseinandergelaufen, weil nur eine Fassung nachgezogen wurde.

  • Mcp-Session-Id ist weiterhin freigegeben — und das steht jetzt in einem
    Test statt in einem Satz.
    Der Docstring von tests/test_cors.py nannte den
    Header die Spur einer Mechanik, die 2026-07-28 abgeschafft habe. Das stimmt
    nicht: mcp 2.x bedient beide Protokoll-Aeren, die Session gehoert zur
    Handshake-Aera, und der Server gibt den Header nicht ohne Grund auch in
    expose_headers frei.

    Nachgemessen statt aus Spec-Text geschlossen: MCP_SESSION_ID_HEADER steht
    unveraendert in mcp/server/streamable_http.py, und ein echter initialize
    durch den zusammengebauten ASGI-Stack bekommt eine Session-ID im
    Antwort-Header zurueck.

    test_der_session_header_ist_weiterhin_freigegeben haelt beides fest. Die
    Gegenprobe zeigt, dass es die Luecke wirklich gab: nimmt man den Header aus
    der Freigabeliste, faellt genau dieser eine Test, und die sieben bestehenden
    bleiben gruen.

Hinzugefügt

  • SessionStart-Hook, der einen veralteten Klon meldet.
    .claude/hooks/session-start-stale-clone.sh sagt beim Sessionstart, wie
    viele Commits der ausgecheckte Stand hinter origin/<default-branch> liegt;
    bei 0 schweigt er. Ein veralteter Klon hat am 3.8.2026 zweimal eine rote CI
    erzeugt, deren Ursache nicht im Diff stand — es fehlten jeweils genau die
    Commits, die das Gate einführten, an dem der Branch scheiterte. Der
    Default-Branch wird über git ls-remote --symref ermittelt, nicht als main
    angenommen: drei Server im Portfolio heissen ihn master.

    Wichtiger als die Meldung ist, dass der Hook die Session niemals
    blockiert — ein Hook, der bei Netzproblemen die Arbeit anhält, wird nach dem
    zweiten Mal abgeschaltet und schützt danach gar nichts. Kein Netz, kein
    Remote, detached HEAD, kein Git-Repo: alles endet still mit exit 0, jede
    Netzoperation unter einer Zeitgrenze von 5 Sekunden.
    tests/test_session_start_hook.py fährt das Skript gegen echte
    Wegwerf-Repos und weist die Zeitgrenze an einem künstlich hängenden git
    nach — beide Zweige, mit timeout-Binary und ohne. Die Gegenprobe über
    diesen Zweig fand einen echten Fehler: ohne Job-Control landete der
    Hintergrundjob in der Prozessgruppe des Hooks, der Kill traf nur git
    selbst, und ein Enkelprozess hielt die stdout-Pipe die vollen 30 Sekunden
    offen.

  • search_summary.json — die zweite Abfrageform von /Search, echt
    aufgezeichnet. test_die_beiden_suchformen_sind_wirklich_verschieden hält
    fest, dass sie sich in genau dem Feld unterscheiden, aus dem die Benennungen
    kommen; ohne diesen Nachweis belegte die zweite Datei nichts.
    test_eine_summary_suche_liefert_treffer_ohne_jede_benennung hält den Stand
    fest und fällt, wenn die Quelle im Summary doch Benennungen liefert — dann
    gehört der Warnsatz im Docstring gestrichen.

Changed

  • Der Backoff-Schlaf wird ueber einen Modul-Alias gepatcht, nicht ueber
    asyncio.sleep.
    Die Tests nullten die Wartezeit mit
    monkeypatch.setattr(<modul>.asyncio, "sleep", ...). Das liest sich lokal,
    ersetzt sleep aber auf dem geteilten Modulobjekt — fuer httpx, respx,
    pytest-asyncio und jeden anderen Importeur im Prozess. Das Modul legt die
    Naht jetzt als _sleep = asyncio.sleep offen; gepatcht wird diese.
    test_der_retry_geht_ueber_den_alias haelt sie: umgeht der Retry den Alias,
    faellt der Test in Sekundenbruchteilen. Ohne ihn fiel gar nichts — die Suite
    wurde nur ein Vielfaches langsamer, und eine laengere Laufzeit ist kein
    Signal, das jemand liest.

Behoben

  • Der 20-Sekunden-Deckel war keine Grenze. Gedeckelt wurde vor dem
    Jittern, also wurde ein auf MAX_DELAY_S gedeckelter Wert anschliessend mit
    bis zu 1.5 multipliziert: exponentielle Wartezeiten bis 30 s,
    Retry-After-Wartezeiten bis 25 s. Neu wird nach dem Jittern gedeckelt.

  • Das Gesamtbudget war nicht garantiert. httpx wendet sein Timeout pro
    Operation an, und das Read-Timeout beginnt mit jedem Chunk von vorn — eine
    langsam troepfelnde Antwort konnte das Budget ueberdauern, ohne dass ein
    einzelner Read ablief. Neu liegt eine asyncio.wait_for-Deadline um den
    Request. (asyncio.timeout laese sich besser, kam aber erst in 3.11; dieses
    Paket unterstuetzt weiterhin 3.10.)

    Beide Befunde stammen aus einem Codex-Review an parlament-mcp#35. Der Test
    zur Deadline musste die echte asyncio.sleep festhalten, bevor die
    autouse-_no_sleep-Fixture aus conftest.py sie ersetzt — sonst waere auch
    dieser Test gruen gewesen, ohne etwas zu pruefen.

Added

  • Aufgezeichnete Fixtures, eine je externem Endpunkt, mit Nachweis.
    tests/fixtures/ haelt jetzt echte TERMDAT-Antworten fuer alle vier
    Endpunkte, die dieser Server aufruft — /Search, /Entry, /Classification
    und /Collection —, aufgezeichnet von scripts/record_fixtures.py. Herkunft,
    Datum, Auswahlregel und SHA-256 stehen je Datei in
    tests/fixtures/PROVENANCE.md, wie im uebrigen Portfolio.
    tests/test_recorded_fixtures.py spielt sie durch den echten Client.

    Handgeschriebene Stubs kodieren die Annahme ihres Autors und koennen sie nicht
    widerlegen: in i14y-mcp blieb genau deshalb die ganze Suite gruen, waehrend
    drei Tools produktiv leere Titel lieferten. Fehlerpfade bleiben
    handgeschrieben, die lassen sich nicht auf Zuruf aufzeichnen.

    Gegenprobe, jede Zusicherung einzeln neutralisiert: Aufnahmedatum entfernt ->
    Datums-Check faellt; Fixture ohne PROVENANCE-Eintrag -> Vollstaendigkeits-Check
    faellt; Aufzeichnung geloescht -> Abdeckungs-Waechter faellt; languageIsoCode
    umbenannt -> der Mapper-Test faellt.

  • Retry-After wird gelesen und schlaegt die eigene Backoff-Kurve (ARCH-014).
    Bei 429 und 503 sagt TERMDAT im Header, wann es wieder mag — als Sekundenzahl
    oder HTTP-Datum; beide Formen kommen vor, beide werden gelesen
    (RFC 9110 §10.2.3). Wer stattdessen weiter seine Kurve faehrt, ignoriert eine
    ausdrueckliche Angabe. Ein unbrauchbarer Header fuehrt zurueck auf die Kurve
    statt zum Absturz — auf dem Fehlerpfad darf eine kaputte Kopfzeile nicht das
    Letzte sein, woran der Client stirbt.

  • Backoff ist gestreut (Jitter). 2**attempt war deterministisch: Faellt
    die API aus, waehrend mehrere Clients sie abfragen, retryen alle im
    Gleichtakt, und die Last kommt als Welle zurueck — genau wenn die API sich
    erholt. Exponentielle Wartezeiten landen jetzt in [0.5x, 1.5x]. Auf einem
    Retry-After ist die Streuung einseitig ([1.0x, 1.25x]): spaeter ist
    hoeflich, frueher waere die Missachtung derselben Angabe, die man gerade
    gelesen hat.

  • Deckel von 20 s auf jede einzelne Wartezeit — gegen die unbegrenzt
    wachsende Leiter und gegen ein Retry-After, das die API senden darf, das man
    aber nicht absitzen muss.

  • Gesamtbudget von 25 s ueber den ganzen Aufruf (ARCH-014). Eine Anzahl
    Versuche ist keine Grenze: Vier Versuche a 60 s Timeout plus Backoff sind
    ueber vier Minuten, und die Zahl 4 sagt 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: Die Arbeit laeuft
    weiter, die Last landet bei TERMDAT, das Ergebnis geht ins Leere.

    Der Anker ist gemessen, nicht geschaetzt: Das Python-MCP-SDK setzt
    MCP_DEFAULT_TIMEOUT = 30.0 fuer allgemeine Operationen
    (mcp/shared/_httpx_utils.py). 25 s lassen Luft fuer MCP-Framing,
    Antwort-Parsing und die Tool-Schicht. Ein Test haelt die Beziehung fest und
    schlaegt an, wenn das SDK seinen Default senkt.

    Geprueft wird vor jedem Versuch: Eine Wartezeit, die das Budget ueberdauern
    wuerde, wird nicht mehr angetreten, und das Timeout eines einzelnen Versuchs
    ist auf die verbleibende Zeit geklemmt. Log-Event und Meldung nennen neu,
    welche Grenze gegriffen hat — «all 4 attempts used» und «25s budget
    spent» verlangen verschiedene Antworten.

    Das Client-Timeout faellt von 60 s auf 25 s: Ein Wert oberhalb des Budgets
    haette nur noch behauptet, was er nicht mehr gewaehrt.

    Die Abwaegung ist bewusst: Ein langsamer erster Versuch kann jetzt das Budget
    aufbrauchen und laesst dann keinen Retry mehr zu. Genau das ist die
    beabsichtigte Antwort — ein Retry, der nach dem Aufgeben des Aufrufers fertig
    wird, bringt niemandem etwas und kostet TERMDAT eine Anfrage.