feat: MCP-Leseserver, Bibliotheksgrenze, Haertung des Lesepfads, Publish-Remote-Gate scharf (2.4.0)
Files changed: - .gitea/workflows/ci.yml - CHANGES.md - README.md - VERSION - instructions/mcp-read-server.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/api.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/lint.py - tools/chemenu/commands/search.py - tools/chemenu/commands/types_cmd.py - tools/chemenu/config.py - tools/chemenu/corpus_cache.py - tools/chemenu/errors.py - tools/chemenu/frontmatter_io.py - tools/chemenu/lint_core.py - tools/chemenu/mcp/__init__.py - tools/chemenu/mcp/__main__.py - tools/chemenu/mcp/server.py - tools/chemenu/page.py - tools/chemenu/search/filters.py - tools/chemenu/search/registry.py - tools/chemenu/search/ripgrep.py - tools/chemenu/search/service.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_api.py - tools/chemenu/tests/test_corpus_cache.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_frontmatter_io.py - tools/chemenu/tests/test_instructions_cmd.py - tools/chemenu/tests/test_mcp_server.py - tools/chemenu/tests/test_new_page.py - tools/chemenu/tests/test_search.py - tools/chemenu/type_resolver.py - tools/chemenu/types_core.py - tools/requirements-mcp.txt
This commit is contained in:
+244
@@ -20,6 +20,250 @@ their date-only headings.
|
||||
|
||||
---
|
||||
|
||||
## 2.4.0 - 2026-09-02 - MCP-Leseserver: zweiter Konsument auf demselben Kern
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
Letzter Schritt der Sequenz aus #36, inhaltlich Issue #19. `chemenu` bekommt einen zweiten
|
||||
Konsumenten: `search`, `types`, `describe_type`, `lint` und `status` über MCP. Kein CLI mit
|
||||
angeschraubter Netzwerkschnittstelle — CLI und Server sind zwei Adapter auf dem Kern, den 2.3.0
|
||||
freigelegt hat.
|
||||
|
||||
**`tools/chemenu/mcp/`**, im Repo statt als eigenes Artefakt. Der Golden-Test, der die
|
||||
Serverantworten gegen die `--json`-Formen der CLI hält, läuft nur mit beiden Seiten in einer
|
||||
Testsuite; getrennt würde aus einem Contract eine Versionsabsprache. Der Test ruft `wikitool` als
|
||||
Subprozess gegen denselben Baum auf, über `CHEMENU_ROOT` — womit er nebenbei die Root-Auflösung
|
||||
von außen mitprüft.
|
||||
|
||||
**Zwei Transports.** `stdio` zum Entwickeln und Testen ohne Netz, `streamable-http` für die
|
||||
Auslieferung — der einzige, vor den sich die Authentifizierungs-Middleware überhaupt setzen kann,
|
||||
weil sie ein HTTP-Reverse-Proxy ist. `sse` ist über das SDK erreichbar und wird bewusst nicht
|
||||
angeboten: der abgelöste Remote-Transport, jetzt darauf zu bauen verschiebt den Wechsel nur.
|
||||
`--host`/`--port` gibt es, weil der Default auf Loopback bindet und ein Container hinter einem
|
||||
Proxy eine Adresse braucht, die der Proxy erreicht — eine Eigenschaft der Software, nicht einer
|
||||
Installation. Beide Transports sind gegen den echten Korpus gegengeprüft.
|
||||
|
||||
**Kein Schreibpfad, strukturell.** Weder der Server noch `chemenu.api` importiert irgendetwas
|
||||
unter `chemenu.commands`, also existieren `new`, `touch`, `xref`, `cite`, `publish`, `migrate`
|
||||
und `version bump` in dieser Reichweite gar nicht, statt aus einer Liste gefiltert zu werden. Ein
|
||||
Test importiert das Servermodul in einem frischen Interpreter und sieht in `sys.modules` nach;
|
||||
ein zweiter ruft alle fünf Tools auf und vergleicht den Dateibaum, `HEAD` und
|
||||
`git status --porcelain` vorher/nachher.
|
||||
|
||||
**Jede Antwort trägt ihren Commit.** `commit` und `as_of` in jedem Payload; `null` heißt, der
|
||||
bediente Baum hat uncommittete Änderungen und die Antwort entspricht keiner Revision. Der Stempel
|
||||
ist die Revision, aus der die Seiten *tatsächlich* gelesen wurden — zwischen Laden und Stempeln
|
||||
kann der Baum sich bewegen, deshalb reicht der Ladepfad seine Revision durch, statt noch einmal
|
||||
zu fragen. Das war beim ersten Durchlauf falsch: `types`/`lint`/`status` lasen die zuletzt
|
||||
*gecachte* Revision und stempelten `null`, obwohl der Baum sauber war.
|
||||
|
||||
**Telemetrie in den bedienten Baum wird beim Start verweigert**, nicht stillschweigend
|
||||
umgeleitet. Tracing ist per Default an und schreibt nach `reports/telemetry/` im Repo — genau das
|
||||
Verzeichnis, das der Sync per `git reset --hard` wegräumen darf. `WIKI_TRACE=0` oder
|
||||
`WIKI_TRACE_DIR` außerhalb des Korpus. Heute schreibt auf diesem Pfad nichts (der Emitter hängt an
|
||||
`cli.main()` und den Gates), die Sperre ist gegen später.
|
||||
|
||||
**Fehler an der Protokollgrenze.** Ein `ChemenuError` wird zum `ToolError` des SDK — eine
|
||||
absichtliche Ablehnung, deren Text den Aufrufer erreicht. Alles andere bleibt ein Absturz, dessen
|
||||
Text auf dem Server bleibt. Ein kaputtes Prädikat ist das Argument des Aufrufers, also muss die
|
||||
Zeile mitreisen, die sagt, was stattdessen zu schreiben ist.
|
||||
|
||||
**Bewusst nicht enthalten:** Authentifizierung und Rate Limiting (Middleware vor dem Prozess),
|
||||
Deployment (private Infrastruktur), der Iteration Budget Gate — er begrenzt eine Agenten-Session
|
||||
und nicht einen Nutzer, weshalb Retrieval von ihm befreit ist; ihn hier als Rate Limiter zu
|
||||
benutzen würde ihn dazu verwässern.
|
||||
|
||||
**Die Abhängigkeit ist optional** (`tools/requirements-mcp.txt`): eine Instanz, die nur die CLI
|
||||
benutzt, soll dafür nicht pydantic, starlette, uvicorn und cryptography installieren müssen. CI
|
||||
installiert sie, denn ein übersprungener Golden-Test ist genau der Weg, auf dem Server und CLI
|
||||
unbemerkt auseinanderlaufen.
|
||||
|
||||
Betrieb und Sync-Mechanismus: [instructions/mcp-read-server.md](instructions/mcp-read-server.md).
|
||||
Polling (`git fetch && git reset --hard`) statt Webhook — kein eingehender Endpunkt, keine
|
||||
Signaturprüfung. `reset --hard` ist dort tragend und keine Bequemlichkeit: ein abgedrifteter Baum
|
||||
antwortet zwar richtig, parst aber bei jeder Anfrage neu und stempelt jede Antwort mit `null`.
|
||||
|
||||
**Dateien:** `chemenu/mcp/` (neu: `server.py`, `__main__.py`), `chemenu/api.py`,
|
||||
`tools/requirements-mcp.txt` (neu), `instructions/mcp-read-server.md` (neu), `tools/CONTRACT.md`,
|
||||
`tools/README.md`, `.gitea/workflows/ci.yml`, `tests/test_mcp_server.py` (neu).
|
||||
|
||||
---
|
||||
|
||||
## 2.3.0 - 2026-09-02 - Bibliotheksgrenze: chemenu laesst sich auf einen Korpus zeigen
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
Dritter Schritt der Sequenz aus #36, inhaltlich Issue #31. Der Schritt, der `chemenu` von einem
|
||||
Skript, das in seinem eigenen Verzeichnis lebt, zu einer Bibliothek macht, auf die man einen
|
||||
Korpus *zeigen* kann. Rückwärtskompatibel: ohne gesetzte Variable verhält sich `tools/wikitool`
|
||||
unverändert.
|
||||
|
||||
**Root-Auflösung nach Präzedenz statt nach Dateilage.** `config.resolve_root()`: expliziter
|
||||
Parameter → `$CHEMENU_ROOT` → Walk-up wie bisher. Der Walk-up bleibt Default. Wichtiger als die
|
||||
neue Fähigkeit ist die beseitigte Fehlerklasse: `ROOT` und alles darunter waren
|
||||
Modulkonstanten, also **zur Importzeit gebunden**, und
|
||||
`monkeypatch.setattr(config, "ROOT", ziel)` zeigte `ROOT` um, aber nicht `KB_DIR`/`RAW_DIR`. Wer
|
||||
sich darauf verließ, baute etwas, das scheinbar auf einem Zielbaum arbeitete und in Wahrheit aus
|
||||
dem Entwickler-Checkout antwortete. Die abgeleiteten Pfade werden jetzt bei jedem Zugriff
|
||||
aufgelöst (PEP 562 `__getattr__`) und folgen `ROOT` — der halb-umgezeigte Zustand ist nicht mehr
|
||||
konstruierbar. `CHEMENU_ROOT` ist in `_WIKITOOL_ENV` registriert, #23-konform.
|
||||
|
||||
**`config.reset()` und `config.rooted()`.** `reset()` nimmt Zuweisungen auf die verwalteten
|
||||
Pfadnamen zurück; die Testsuite ruft es zwischen Tests, und das ist dort nicht optional:
|
||||
`monkeypatch` merkt sich den alten Wert, indem es ihn *liest* — also auflöst — und schreibt ihn
|
||||
beim Aufräumen als echtes Attribut zurück. Genau die stale Bindung, die der Umbau unmöglich
|
||||
machen sollte, vom Cleanup wieder aufgebaut. `rooted(root)` setzt den Root für die Dauer eines
|
||||
Blocks, prozessweit und damit nicht thread-sicher — der Aufrufer hält das Lock, dieselbe
|
||||
Disziplin wie beim Korpus-Cache. Nötig, weil nicht alles einen Root als Argument nimmt: der
|
||||
modulglobale `TypeResolver` muss `types/` finden, und ohne ihn läse ein fremder Korpus mit den
|
||||
Type-Specs *dieses* Checkouts.
|
||||
|
||||
**Die Naht ist gezogen.** `run_search`/`run_lint`/`types` lagen in Modulen, die `typer` auf
|
||||
Modulebene importieren und über `_util` auch `rich` — wer sie in-process aufrief, zog den
|
||||
kompletten CLI-Kopf mit. Der reine Kern liegt jetzt in `search/service.py`, `lint_core.py` und
|
||||
`types_core.py`; `commands/` sind die Terminal-Adapter darüber und re-exportieren die Namen, damit
|
||||
kein bestehender Import bricht. Ein Test importiert `chemenu.api` in einem frischen Interpreter
|
||||
und prüft, dass weder `chemenu.commands.*` noch `typer`/`rich`/`click` geladen werden.
|
||||
|
||||
**`chemenu.api.Corpus` als In-Process-Einstieg.** Nimmt einen Root, liefert exakt die
|
||||
`--json`-Formen der CLI zurück — ein Wire-Contract statt zwei — und stempelt jede Antwort mit dem
|
||||
Commit-SHA und einem Zeitstempel (`commit`, `as_of`), so dass aus einer stillen veralteten Antwort
|
||||
eine sichtbare wird. `search`/`lint`/`types`/`describe_type`/`status`; `status` ist bewusst
|
||||
serverseitig **komponiert** und kein Wrapper, weil es kein `wikitool status` gibt. Lesend
|
||||
strukturell: nichts unter `chemenu.commands` wird importiert, die Schreibfunktionen existieren in
|
||||
dieser Oberfläche also gar nicht, statt gefiltert zu werden. Das ist die Grenze, auf der #19
|
||||
aufsetzt.
|
||||
|
||||
**Exceptions statt Exit-Codes an der Grenze.** `chemenu/errors.py`: `ChemenuError` mit
|
||||
`ValidationError` (Eingabe abgelehnt) und `BackendError` (Abhängigkeit fehlt oder scheitert).
|
||||
`PredicateError`, `FrontmatterError`, `UnknownBackend` und die beiden `Ripgrep*` hängen jetzt
|
||||
darunter; `ValidationError` erbt zusätzlich von `ValueError`, weil `PredicateError` vorher eines
|
||||
war und Aufrufer es so fangen. Das CLI-Verhalten ist unverändert: `fail()` → `ERROR`-Zeile,
|
||||
Exit 1, Budget-Refund.
|
||||
|
||||
**`resolve()` reicht den Root an das Backend durch.** Vorher konnte ein Aufrufer `run_search` einen
|
||||
Korpus übergeben, während `RipgrepBackend` weiter `config.KB_DIR` durchlief — die Anfrage aus dem
|
||||
einen Baum beantwortet, die Seiten aus dem anderen gelesen, ohne dass irgendetwas das gesagt
|
||||
hätte.
|
||||
|
||||
**Zwei Abhängigkeiten, die durch Zufall hielten, stehen jetzt da.** `TypeResolver.repo_root`
|
||||
folgt `ROOT`, statt beim Import zu binden — womit Fixtures, die `ROOT` auf einen tmp-Baum zeigen,
|
||||
die mitgelieferten Type-Specs ausdrücklich benennen müssen (`use_shipped_type_specs`). Dieselbe
|
||||
Form wie das Loch, für das `raw_dir` geschrieben wurde, eine Ebene tiefer. Und
|
||||
`types describe --json` trug `root:` nicht im Payload, obwohl `types list --json` es tut:
|
||||
`types/instruction.md` deklariert `root: repo`, die Renderer-Zeile las es direkt aus dem
|
||||
Frontmatter. Jetzt im Payload, in derselben Form wie bei `list`.
|
||||
|
||||
**Dateien:** `config.py`, `errors.py` (neu), `api.py` (neu), `search/service.py` (neu),
|
||||
`lint_core.py` (neu), `types_core.py` (neu), `search/registry.py`, `type_resolver.py`,
|
||||
`commands/search.py`, `commands/lint.py`, `commands/types_cmd.py`, `tools/CONTRACT.md`, dazu
|
||||
`tests/conftest.py`, `tests/test_api.py` (neu), `tests/test_new_page.py`,
|
||||
`tests/test_instructions_cmd.py`.
|
||||
|
||||
---
|
||||
|
||||
## 2.2.4 - 2026-09-02 - Haertung des Lesepfads: ReDoS, Subprozess-Timeout, YAML-Alias-Budget, Korpus-Cache
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
Zweiter Schritt der Sequenz aus #36, inhaltlich Issue #33. Limits vor der Exposition: der
|
||||
Lesepfad bekommt mit dem MCP-Server (#19) einen Konsumenten, der nicht der Operator ist. Alle
|
||||
sechs Befunde waren reproduziert, nicht vermutet; jeder hat jetzt eine Regression.
|
||||
|
||||
**ReDoS über `--regex` beseitigt.** `search/ripgrep.py:_contains` gab nutzergesteuerten Regex an
|
||||
Pythons Backtracking-Engine: `(\w+\s?)+$` gegen 114 Zeichen gewöhnlichen Seiteninhalts terminiert
|
||||
nicht in acht Sekunden, ein deterministisch scheiterndes Muster braucht 0,2 ms — der Unterschied
|
||||
ist das Muster, nicht der Heuhaufen. `build_hit` ruft die Funktion je Treffer zweimal auf, und
|
||||
`\w` matcht jede Seite: eine Anfrage kaufte zwei unbegrenzte Suchen pro Korpusseite. Der Zweig ist
|
||||
**gelöscht**, nicht begrenzt. `rg` hat das Muster mit einer linearen Engine längst angewendet, wenn
|
||||
die Funktion überhaupt läuft; verloren geht nur der zusätzliche Titel-/Summary-Bonus für
|
||||
nicht-literale Muster, und Summary wie H1 sind selbst Zeilen in der Datei, die `rg` zählt.
|
||||
|
||||
**Subprozess-Timeout.** `rg` wird nach 30 s abgeräumt und über den vorhandenen
|
||||
`RipgrepFailed`-Pfad gemeldet. Kein Performance-Budget — eine Fixed-String-Suche kostet hier 6 ms —
|
||||
sondern ein Hänger-Abbruch, damit ein Aufruf als Fehler endet statt den Aufrufer offenzuhalten,
|
||||
während seine Ausgabe in den Heap puffert.
|
||||
|
||||
**YAML-Anchors und -Aliases im Frontmatter werden verweigert, nicht budgetiert.** Gemessen:
|
||||
267 Byte werden in 0,2 ms zu 672.603 Knoten beim Traversal, Wachstum 9ⁿ bei konstanter Parse-Zeit
|
||||
— ein Größenlimit fasst das nicht an, weil die Eingabe klein bleibt. Die Prüfung läuft auf dem
|
||||
*Event*-Strom (`yaml.parse`), der nichts auflöst, kostet also O(Text) und löst nie aus, wonach sie
|
||||
fragt; `*` ist in jedem Alias-Knoten notwendig, seine Abwesenheit beweist Abwesenheit ohne jeden
|
||||
Parse — der Weg, den jede echte Seite nimmt. Dazu ein Größenlimit von 64 KiB und ein Abfangen von
|
||||
`RecursionError` (PyYAML komponiert rekursiv, tiefe Verschachtelung ist kein `YAMLError`). Heute
|
||||
nicht erreichbar, weil `kb/` der Operator committet; erreichbar mit der Ingest-Queue (#32).
|
||||
|
||||
**`CSafeLoader` statt `SafeLoader`, mit Fallback.** Gemessen über diesen Korpus (176 Seiten,
|
||||
best of 5): **265 ms → 54 ms**. Kein Mikro-Tuning — der Korpus-Parse war der größte Einzelposten
|
||||
eines `search`-Aufrufs und skaliert linear mit der Korpusgröße. End-to-end fällt ein
|
||||
`wikitool search` damit von 593 ms auf **347 ms**; die verbleibenden 262 ms sind Modulimport und
|
||||
entfallen erst im residenten Prozess (#19).
|
||||
|
||||
**Stiller Frontmatter-Verlust wird gemeldet.** Entschieden: der Lesepfad *nennt* die Seite, statt
|
||||
sie zu schlucken. Kaputtes YAML wird weiterhin zu `{}` — Massenoperationen dürfen an einer Seite
|
||||
nicht scheitern —, aber der Grund wird mitgeführt (`Page.frontmatter_error`) und ausgegeben:
|
||||
`search --json` trägt immer eine `unreadable`-Liste aus `{path, reason}`, die Tabellenform
|
||||
schreibt dieselben Zeilen nach stderr. Das war nötig, weil so eine Seite weder `confidence` noch
|
||||
`kind` hat und damit aus jedem positiven `--field`-Prädikat fällt — ausgerechnet aus der
|
||||
Low-Confidence-Suche, die Seiten in genau diesem Zustand finden soll — und dabei aussieht wie eine
|
||||
Seite, die nicht gematcht hat. Für Frontmatter, das diese Instanz nicht selbst geschrieben hat,
|
||||
steht `read_page_strict()` bereit: die Quarantäne aus #32 muss strikt lesen, wo ein
|
||||
unlesbares Dokument das Dokument stoppen und nicht leeren soll.
|
||||
|
||||
**Ein Parser statt zwei.** `read_page()` und `frontmatter_error()` liefen bisher getrennt durch
|
||||
`safe_load` — so konnte der permissive Weg zu `{}` degradieren aus einem Grund, den der strikte
|
||||
Weg anders beschrieb, und jeder Aufrufer, der beide Antworten wollte, las die Datei zweimal.
|
||||
Beide gehen jetzt durch `_load_frontmatter()`; ein Test hält sie gegeneinander.
|
||||
|
||||
**Korpus-Cache am Commit-SHA** (`chemenu/corpus_cache.py`), als Objekt, das ein Aufrufer *hält* —
|
||||
kein Modul-Dict, das sich hinter allen einschaltet. Die CLI hält keins und verhält sich unverändert
|
||||
(ein Aufruf pro Prozess, nichts wiederzuverwenden); der residente Prozess aus #19 hält eins.
|
||||
Entscheidend ist nicht die Geschwindigkeit, sondern dass nichts veraltet: **ein schmutziger
|
||||
Arbeitsbaum wird nie gecacht**, sonst bekäme eine Sitzung, die eine Seite schreibt und danach
|
||||
sucht, die Analyse von vor dem Schreiben — bei unverändertem SHA. Kann git nicht antworten, gilt
|
||||
der Baum als schmutzig. Derselbe SHA ist der Antwort-Stempel aus #19, per Konstruktion also die
|
||||
Revision, aus der die Antwort auch wirklich berechnet wurde.
|
||||
|
||||
**Erhalten geblieben** ist die Eigenschaft, die kein Befund war: kein `shell=True`,
|
||||
`--fixed-strings` als Default, `--`-Terminator. Der Modul-Docstring führt sie jetzt als drei
|
||||
tragende Zusagen statt zwei — die dritte ist, dass nutzergesteuerte Muster ausschließlich `rg`
|
||||
sieht.
|
||||
|
||||
**Dateien:** `frontmatter_io.py`, `search/ripgrep.py`, `commands/search.py`, `page.py`,
|
||||
`corpus_cache.py` (neu), `tools/CONTRACT.md`, dazu `tests/test_frontmatter_io.py`,
|
||||
`tests/test_search.py`, `tests/test_corpus_cache.py` (neu).
|
||||
|
||||
---
|
||||
|
||||
## 2.2.3 - 2026-09-02 - Publish-Remote-Gate in diesem Checkout scharf, doctor benennt den Zustand
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
Erster Schritt der Sequenz aus Issue #36 (Weg zum MCP-Leseserver), inhaltlich Issue #34. Das
|
||||
Gate war seit 2.2.0 implementiert und in diesem Checkout **inert**: `.wikitool-remotes.json`
|
||||
fehlte, und eine fehlende Datei heißt unbeschränkt. `ENVIRONMENT.md` beschrieb `origin`
|
||||
gleichzeitig als einziges Publish-Ziel, `AGENTS.md` führt das Gate unter den drei in Code
|
||||
erzwungenen Grenzen. Eine dokumentierte Sicherung, die nicht greift, ist schlechter als eine
|
||||
fehlende — sie erzeugt genau das Vertrauen, das sie nicht verdient.
|
||||
|
||||
**Die Datei ist angelegt** (gitignored, per Checkout, reist nicht mit) und gegengeprüft: ein
|
||||
`publish --remote` auf ein nicht gelistetes Ziel verweigert mit Exit 42, bevor irgendetwas
|
||||
gestaged wird, und der Arbeitsbaum bleibt unberührt. Damit steht die Sicherung **vor** dem Klonen
|
||||
der privaten Instanz (#30) — nachträglich gesetzt ließe sie genau das Fenster offen, das sie
|
||||
schließt.
|
||||
|
||||
**`doctor` sagt jetzt, ob das Gate scharf ist, nicht nur ob die Datei da ist.** Alle drei
|
||||
Zustände beginnen mit `Gate armed:` bzw. `Gate not armed:`; der einzelne Remote ohne Allowlist
|
||||
bleibt `OK` (er hat nichts zu schützen, und ein FAIL machte die Datei durch die Hintertür
|
||||
verpflichtend), sagt aber ausdrücklich, dass jedes Push-Ziel durchkommt. Der Fall, der wirklich
|
||||
beißt — mehrere Remotes ohne Allowlist — bleibt `WARN`. Der Check hatte bislang **keine Tests**;
|
||||
drei sind dazugekommen, einer je Zustand.
|
||||
|
||||
**Dateien:** `.wikitool-remotes.json` (neu, nicht committet), `doctor.check_publish_remotes()`,
|
||||
`tools/chemenu/tests/test_doctor.py`.
|
||||
|
||||
---
|
||||
|
||||
## 2.2.2 - 2026-09-01 - Doku-Verdrahtung: Publish-Remote Gate im Werkzeugvertrag, Projektseite auf oeffentlich
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
Reference in New Issue
Block a user