Files changed: - .gitignore - AGENTS.md - CHANGES.md - INSTALL-MCP.md - README.md - VERSION - docs/why-gates-are-code.md - instructions/gates.md - instructions/ingest-queue.md - instructions/mcp-read-server.md - instructions/wiki-ingest/SKILL.md - raw/CONTRACT.md - tools/CONTRACT.md - tools/chemenu/cli.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/upload_cmd.py - tools/chemenu/config.py - tools/chemenu/mcp/server.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_mcp_server.py - tools/chemenu/tests/test_upload.py - tools/chemenu/tests/test_upload_cmd.py - tools/chemenu/upload.py
13 KiB
MCP-Leseserver installieren
Dieses Dokument richtet sich an Menschen. Es beschreibt, wie der MCP-Leseserver eines Chemenu- Wikis lokal läuft, wie ein Client ihn einbindet, und wie er hinter einer Authentifizierung erreichbar wird. Der agent-seitige Betriebsablauf steht in instructions/mcp-read-server.md; die vollständige Kommandoreferenz in tools/CONTRACT.md.
Was der Server ist. Ein zweiter Konsument desselben Kerns, nicht ein zweites Programm. CLI
und Server rufen dieselben Funktionen auf; ein Golden-Test hält ihre Ausgaben gegeneinander.
Was tools/wikitool search --json liefert, liefert das MCP-Tool search auch — plus den
Commit, aus dem die Antwort berechnet wurde.
Was er nicht ist. Kein Schreibpfad nach kb/. Es gibt kein Tool, das eine Seite anlegt,
ändert oder publiziert — nicht weil eine Liste gefiltert wird, sondern weil der Server nichts
unter tools/chemenu/commands/ importiert. Die Funktionen sind aus diesem Prozess heraus nicht
erreichbar. Optional gibt es ein sechstes Tool, submit (Schritt 7): es schreibt, aber nur in
eine Quarantäne, die kein anderer Befehl liest — eine Positiv-Liste im Code statt einer
Abwesenheit, und ein Mensch entscheidet über jede Beförderung daraus.
Voraussetzungen
- Eine funktionierende Instanz nach INSTALL.md — inklusive
tools/.venvundripgrep - Python 3.11 oder neuer (wie die CLI)
Schritt 1: Abhängigkeit installieren
Sie liegt bewusst nicht in tools/requirements.txt. Eine Instanz, die nur die CLI benutzt, soll
dafür nicht pydantic, starlette, uvicorn und cryptography mitinstallieren müssen.
tools/.venv/bin/pip install -r tools/requirements-mcp.txt
Schritt 2: Lokal starten (stdio)
stdio ist der Weg zum Ausprobieren und für einen Client auf derselben Maschine: ein Prozess
pro Konsument, lokal gestartet, kein Netzwerk.
WIKI_TRACE=0 tools/.venv/bin/python -m chemenu.mcp
Der Prozess spricht MCP über stdin/stdout und gibt für sich genommen nichts aus — das ist richtig so. Gestartet wird er normalerweise nicht von Hand, sondern vom Client (Schritt 3).
Das WIKI_TRACE=0 oben ist in einer ausgelieferten Instanz meist redundant, aber trotzdem
richtig. Telemetrie-Default hängt vom Installationsweg ab
(INSTALL.md § Konfiguration): in einer per dist export ausgelieferten Instanz
steht er auf aus, in einem Git-Clone dieses Repos (Testbett/Demo) auf an. Der Server
prüft nicht WIKI_TRACE direkt, sondern denselben chemenu.telemetry.policy, den auch
wikitool doctor und der Writer befragen - läuft Telemetrie danach, verweigert der Server den
Start, statt still umzuleiten:
ERROR Telemetry is on and would write into the served checkout (...). Set WIKI_TRACE=0,
or point WIKI_TRACE_DIR outside the corpus.
Beide Auswege sind gleichwertig: WIKI_TRACE=0 schaltet ab, WIKI_TRACE_DIR=/var/log/chemenu
lenkt um. Der Grund steht in Schritt 6 — der Sync darf reports/ wegräumen. Das explizite
WIKI_TRACE=0 in den Befehlen dieses Dokuments bleibt der sichere Default: es ist korrekt, egal
welchen Weg die bediente Instanz genommen hat, und macht die Prüfung oben gegenstandslos, statt
sich auf den Installationsweg zu verlassen.
Schritt 3: Einen Client einbinden
Die Konfiguration folgt der üblichen MCP-Client-Form. Absolute Pfade, weil der Client kein Arbeitsverzeichnis erbt:
{
"mcpServers": {
"chemenu": {
"command": "/pfad/zur/instanz/tools/.venv/bin/python",
"args": ["-m", "chemenu.mcp"],
"cwd": "/pfad/zur/instanz/tools",
"env": {
"WIKI_TRACE": "0",
"CHEMENU_ROOT": "/pfad/zur/instanz"
}
}
}
}
CHEMENU_ROOT sagt, welches Wiki bedient wird. Ohne die Variable nimmt der Server den
Checkout, in dem das Paket selbst liegt — für eine einzelne Instanz reicht das, aber wer mehrere
Korpora hat, setzt sie besser immer.
Danach kennt der Client fünf Werkzeuge, und optional ein sechstes:
| Tool | Was es beantwortet |
|---|---|
search |
Seiten in kb/ nach Text, nach Frontmatter (!sources, tags~k8s) oder beidem |
types |
Welche Seitentypen dieses Wiki kennt |
describe_type |
Der vollständige Vertrag eines Typs: Felder, Pflichtangaben, Enums |
lint |
Strukturelle Befunde: kaputte Wikilinks, Waisen, Index-Drift, Schema-Lücken |
status |
Momentaufnahme: Seitenzahl, Verteilung auf Collections, Befundzahlen |
submit (optional, Schritt 7) |
Reicht ein Dokument in die Prüf-Warteschlange ein — kein Schreibpfad nach kb/, nur in eine Quarantäne |
Schritt 4: Ausgeliefert starten (streamable HTTP)
Der Transport für den Betrieb, und der einzige, vor den sich ein Reverse Proxy setzen kann.
WIKI_TRACE=0 CHEMENU_ROOT=/srv/chemenu \
tools/.venv/bin/python -m chemenu.mcp \
--transport streamable-http --host 0.0.0.0 --port 8000
Der Endpunkt ist dann http://<host>:8000/mcp.
--host 0.0.0.0 ist bewusst nicht der Default. Ohne die Angabe bindet der Server auf Loopback,
was lokal richtig und im Container falsch ist — dort muss der Proxy ihn erreichen können. Wer
0.0.0.0 setzt, muss also auch dafür sorgen, dass davor etwas steht (Schritt 5).
sse wird nicht angeboten. Es ist der abgelöste Remote-Transport; jetzt darauf zu bauen
verschiebt den Wechsel nur.
Schritt 5: Authentifizierung davor
Der Server authentifiziert nicht selbst, und das ist Absicht. Nicht sauber authentifizierte Zugriffe sollen den Python-Prozess gar nicht erst erreichen. Die Authentifizierung ist eine Traefik-ForwardAuth-Middleware:
Kurz, was sie tut: sie prüft Authorization: Bearer <token> gegen SHA-256-Hashes erlaubter
Tokens, antwortet 200 bei gültigem und 401 bei fehlendem oder falschem Token, und hält
GET /healthz immer offen. Klartext-Tokens liegen weder in der Konfiguration noch im Log — nur
Hashes und ein kurzer Fingerprint. Konfiguriert wird sie über
AUTH_PROXY_TOKEN_HASHES_DIR (ein Verzeichnis, eine Datei je Token-Hash — passend für ein
Kubernetes-Secret-Volume) oder AUTH_PROXY_TOKEN_HASHES (kommagetrennte Liste). Einzelheiten,
Referenzmanifeste und ein Testskript stehen im README dort.
Einen Token-Hash erzeugen:
echo -n "mein-token" | sha256sum | awk '{print $1}'
Rate Limiting gehört an dieselbe Stelle — vor den Prozess, neben die Authentifizierung. Nicht in den Iteration Budget Gate: der begrenzt eine Agenten-Session am unbemerkten Iterieren über den Wiki-Zustand, weshalb Retrieval von ihm ausgenommen ist. Ihn als Rate Limiter zu benutzen würde ihn dazu verwässern.
Ist der submit-Pfad scharf geschaltet (Schritt 7), kommt eine zweite Pflicht hinzu: die
Middleware muss den konfigurierten Identitäts-Header (Default X-Forwarded-User) selbst setzen
und eine vom Client mitgeschickte Kopie verwerfen. Der Prozess vertraut diesem Header als Wert
für submitter — ein Header, den der Client selbst setzen dürfte, wäre keine Identität, sondern
eine Behauptung.
Schritt 6: Den Korpus aktuell halten
Der Server liest den Arbeitsbaum. Ein veralteter Checkout antwortet selbstbewusst falsch — deshalb trägt jede Antwort den Commit, aus dem sie berechnet wurde:
{ "commit": "<40-stelliger SHA>", "as_of": "<ISO-8601, UTC>", "count": 3, "...": "..." }
Aktuell gehalten wird der Baum durch Polling, aus einem Timer neben dem Server:
git -C "$CHEMENU_ROOT" fetch --quiet origin && \
git -C "$CHEMENU_ROOT" reset --hard --quiet origin/main
Polling statt Webhook, weil es keinen eingehenden Endpunkt und keine Signaturprüfung braucht — eine kleinere Angriffsfläche als das, was es optimieren würde.
reset --hard ist dabei tragend und keine Bequemlichkeit: der Korpus-Cache verwendet einen
Parse wieder, solange der Commit gleich bleibt, und cacht einen schmutzigen Baum überhaupt
nicht. Ein abgedrifteter Checkout antwortet also zwar richtig, parst aber bei jeder Anfrage
neu — und stempelt jede Antwort mit "commit": null, weil sie keiner Revision entspricht.
Schritt 7: Optional - den submit-Pfad freischalten
Ohne diesen Schritt existiert submit als Tool nicht — nicht ungenutzt, sondern nicht
registriert. Die Datei .wikitool-upload.json im bedienten Korpus schaltet ihn frei:
{
"schema": 1,
"identity_header": "X-Forwarded-User",
"max_bytes": 10485760,
"allowed_extensions": [".md", ".txt", ".pdf", ".html", ".csv", ".json", ".png", ".jpg"],
"quota": { "submissions_per_day": 20, "bytes_per_day": 52428800 }
}
Jedes Feld ist Pflicht, keines hat einen eingebauten Default außer identity_header — eine
fehlerhafte Datei ist ein Startfehler des Servers, kein „keine Beschränkung": das Ziel ist
absichtlich die sichere Richtung. identity_header muss der Header sein, den Schritt 5 oben
gerade eben scharf gemacht hat (Middleware setzt, Client-Kopie verworfen) — sonst wird jede
Einreichung mangels Identität abgelehnt.
Eingereichte Dateien landen in mcp-upload/<id>/, gitignored, von keinem anderen Kommando
gelesen. Ein Mensch prüft und befördert sie über wikitool upload accept <id> --confirm <token>
(Exit 42 beim ersten Versuch, mit Manifest und Token in der Ausgabe) oder verwirft sie über
wikitool upload reject <id> --reason "<warum>" — siehe
instructions/ingest-queue.md für den Prüfablauf. Beide Kommandos
laufen im selben Checkout wie der Server, nicht im Prozess selbst.
Verifikation
Läuft es? Der schnellste Test ohne Client — startet den Server über stdio, listet die Tools und stellt eine Frage:
cd tools && WIKI_TRACE=0 .venv/bin/python - <<'EOF'
import asyncio, os
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main():
params = StdioServerParameters(
command=".venv/bin/python", args=["-m", "chemenu.mcp"],
env={"WIKI_TRACE": "0", "PATH": os.environ["PATH"]},
)
async with stdio_client(params) as (r, w):
async with ClientSession(r, w) as s:
await s.initialize()
print("Tools:", [t.name for t in (await s.list_tools()).tools])
out = await s.call_tool("status", {})
d = getattr(out, "structuredContent", None) or out.structured_content
print("Seiten:", d["pages"], "| Commit:", d["commit"])
asyncio.run(main())
EOF
Erwartete Ausgabe, sinngemäß:
Tools: ['search', 'types', 'describe_type', 'lint', 'status']
Seiten: 176 | Commit: 576df2cdddc96614a7e6641e562022d52112d411
Gegen die CLI gegenprüfen — beide müssen dieselbe Antwort geben:
tools/wikitool search "<begriff>" --json
Troubleshooting
ERROR Telemetry is on and would write into the served checkout — erwartetes Verhalten,
kein Fehler in der Installation. WIKI_TRACE=0 setzen oder WIKI_TRACE_DIR aus dem Korpus
heraus zeigen lassen (Schritt 2).
ModuleNotFoundError: No module named 'mcp' — Schritt 1 fehlt, oder der Client startet ein
anderes Python als das der Instanz. Im Client den absoluten Pfad auf tools/.venv/bin/python
setzen.
"commit": null in jeder Antwort — der bediente Baum hat uncommittete Änderungen. Entweder
läuft der Sync nicht, oder etwas schreibt in den Korpus, das dort nichts zu suchen hat. Die
fünf Lesewerkzeuge schreiben nie, und submit (falls scharf) ausschließlich nach
mcp-upload/ — gitignored, also selbst kein Grund für "commit": null; ein Test prüft das,
indem er alle Tools aufruft und Dateibaum, HEAD und git status --porcelain vorher/nachher
vergleicht.
commit nennt eine alte Revision — der Sync aus Schritt 6 läuft nicht.
Der Server antwortet anders als wikitool — das ist ein Defekt, keine
Konfigurationsdifferenz: beide gehen durch dieselben Funktionen, und ein Golden-Test hält sie
zusammen. Zuerst prüfen, ob beide auf denselben Root zeigen; CHEMENU_ROOT ist leicht für einen
von beiden gesetzt und für den anderen nicht.
Von außen nicht erreichbar — ohne --host 0.0.0.0 bindet der Server auf Loopback
(Schritt 4). Wenn er dann erreichbar ist, aber jeder Aufruf 401 bekommt, arbeitet die
Middleware aus Schritt 5 korrekt und das Token stimmt nicht.
Was hier bewusst nicht steht
Deployment — Cluster, Ingress-Hosts, Secret-Store, FluxCD-Quelle. Das ist private Infrastruktur und dieses Repo ist öffentlich.
Ein Container-Image für den Betrieb gibt es noch nicht; es ist als eigenes Vorhaben erfasst,
mitsamt den Entscheidungen, die dafür noch offen sind (Korpus im Image oder als Volume, wer den
Sync ausführt, Basis-Image, Healthcheck):
https://gitea.nehmer.net/torben/chemenu/issues/37. Bis dahin ist der Weg oben — venv,
python -m chemenu.mcp, Proxy davor — der vollständige. Ist der submit-Pfad scharf, gehört
mcp-upload/ zu derselben offenen Frage: es muss denselben Neustart und dieselbe
Persistenzentscheidung überleben wie der Rest des Checkouts, sonst verliert eine eingereichte,
noch nicht geprüfte Datei ihre Quarantäne.