Files
chemenu/INSTALL-MCP.md
T
torben 828521861d
CI / verify (push) Successful in 53s
Release / release (push) Successful in 36s
stack: MCP submit-Tool mit Upload Review Gate und Quarantäne-Schreibpfad (schliesst #32)
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
2026-09-11 09:51:37 +02:00

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/.venv und ripgrep
  • 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:

https://gitea.nehmer.net/torben/gitea-mcp-forward-auth

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.