No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Lyra c65f8ae8bf
Some checks failed
CI / check (3.14) (push) Has been cancelled
CI / check (3.13) (push) Has been cancelled
CI / check (3.12) (push) Has been cancelled
CI / check (3.11) (push) Has been cancelled
fix(lesen): list_pages liefert vollständige Seiten- und Tag-Listen
pages.list holt die Tags über withGraphJoined('tags') und legt limit auf
das Ergebnis dieses Joins — also auf Zeilen, nicht auf Seiten. Eine Seite
mit vier Tags verbraucht vier Plätze, die Antwort bricht mitten in einer
Seite ab. Gemessen gegen eine produktive Instanz mit 128 Seiten: limit 10
→ 3 Seiten, limit 100 → 91 Seiten, deren letzte 2 statt 4 Tags trägt.

orderBy ist daran unbeteiligt. Dass ID unauffällig wirkte und TITLE nicht,
lag allein daran, dass die Seiten mit niedriger id ungetaggt sind — ID als
Default hätte den Fehler also nur verschoben, nicht behoben.

Deshalb geht kein limit mehr in die Query. Sortiert wird in sort_pages()
im Client, gekürzt wird im Tool, das die Gesamtzahl kennt: der Kopf nennt
jetzt immer "X von Y Seiten", limit 0 zeigt alles. Damit entfällt auch der
alte Vollständigkeits-Hinweis, der über len(pages) >= limit riet — er
schlug am exakten Limit falsch an und blieb bei oberhalb gekürzten
Antworten stumm.

Ein unbekanntes order_by fällt jetzt im Client auf, statt als GraphQL-Fehler
aus Wiki.js zurückzukommen; createdAt wird für CREATED mitgelesen.

Schließt #1, schließt #2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZyPb2JEJGp3KVcKawVR9z
2026-08-16 11:27:44 +00:00
.github/workflows ci: Python 3.14 in die Testmatrix aufnehmen 2026-08-14 17:16:48 +00:00
src/wikijs_mcp fix(lesen): list_pages liefert vollständige Seiten- und Tag-Listen 2026-08-16 11:27:44 +00:00
tests fix(lesen): list_pages liefert vollständige Seiten- und Tag-Listen 2026-08-16 11:27:44 +00:00
.env.example feat(guards): Read-only-Modus und Pfad-Allowlist 2026-08-14 16:24:06 +00:00
.gitignore refactor(struktur): Paket nach src/wikijs_mcp verschieben 2026-08-14 16:24:06 +00:00
CLAUDE.md fix(lesen): list_pages liefert vollständige Seiten- und Tag-Listen 2026-08-16 11:27:44 +00:00
LICENSE Initial commit: schlanker Wiki.js MCP-Server (stdio) 2026-07-21 23:00:37 +02:00
pyproject.toml test: pytest-Suite ohne Wiki.js-Instanz sowie CI 2026-08-14 16:24:06 +00:00
README.md fix(lesen): list_pages liefert vollständige Seiten- und Tag-Listen 2026-08-16 11:27:44 +00:00
server.py refactor(struktur): Paket nach src/wikijs_mcp verschieben 2026-08-14 16:24:06 +00:00
uv.lock chore: uv.lock mit Dev-Gruppe aktualisieren 2026-08-14 18:44:00 +02:00

wikijs-mcp

Ein schlanker, selbst geschriebener MCP-Server (stdio) für Wiki.js — kein Fork von Fremdcode, volle Kontrolle über Tool-Scope und Token-Rechte. Ersetzt Community-Server wie heAdz0r/wikijs-mcp-server.

Autoren: Lyra (Konzept & Umsetzung), Draven / DravenTec (Co-Autor). Lizenz: MIT.

Tool-Scope

Der Scope ist in drei Stufen gegliedert, damit der Blast-Radius eines kompromittierten Tokens eine Konfigurations- und keine Code-Entscheidung ist.

Lesen — immer verfügbar (read:pages)

Tool Signatur Zweck
search_pages (query, path=None, locale=None) Volltextsuche, optional auf Teilbaum/Sprache begrenzt
get_page (path, locale="de") Seite über Pfad lesen, inkl. Inhalt
get_page_by_id (id) Seite über id lesen — Gegenstück zu den Schreib-Tools
list_pages (locale="de", tags=None, limit=100, order_by="TITLE", order_direction="ASC") Seitenliste mit Status und Tags; limit kürzt nur die Ausgabe (0 = alle), der Kopf nennt immer die Gesamtzahl
get_page_tree (path="", locale="de", mode="ALL") Einträge unterhalb von path; leer für die Wurzel
list_tags () Alle vergebenen Tags
search_tags (query) Tags suchen
list_links (locale="de") Ausgehende Links + Ziele ohne passende Seite
page_history (id, offset_page=0, offset_size=25) Versionshistorie (read:history)
get_page_version (page_id, version_id) Inhalt einer früheren Version (read:history)

Schreiben — außer bei WIKIJS_READONLY=true (write:pages)

Tool Signatur Zweck
create_page (path, title, content, locale="de", description="", tags=None, published=True) Neue Seite oder Entwurf anlegen
update_page (id, content, published=None) Inhalt ersetzen, übriger Zustand bleibt
update_page_meta (id, title=None, description=None, tags=None, published=None) Metadaten ändern, Inhalt bleibt
publish_page (id) Veröffentlichen, ohne Inhalt zu senden
unpublish_page (id) Zurückziehen, ohne Inhalt zu senden
move_page (id, path, locale=None) Verschieben/Umbenennen (manage:pages)
restore_version (page_id, version_id) Frühere Version wiederherstellen (read:history)

Löschen — nur mit WIKIJS_ENABLE_DELETE=true (delete:pages)

Tool Signatur Zweck
delete_page (id) Seite löschen — nicht rückgängig zu machen

Kein User-/Gruppen-Management, keine Asset- oder Kommentar-Tools — nur bei echtem Bedarf später ergänzen.

Warum update_page die Seite erst liest

In Wiki.js 2.x ist pages.update kein reines Patch. Ein Blick in server/models/pages.js erklärt zwei Verhaltensweisen, die im Alltag als stiller Datenverlust auftreten:

isPublished: opts.isPublished === true || opts.isPublished === 1,   // Zeile 430
await WIKI.models.tags.associateTags({ tags: opts.tags, page })     // Zeile 443
  • Fehlt isPublished, ergibt der Ausdruck false — kein undefined, das Knex beim patch überspringen würde. Die Seite verschwindet dadurch still aus der Sicht normaler Leser. title und description überleben ein Update genau deshalb, weil dort tatsächlich undefined steht.
  • Fehlt tags, ruft associateTags tags.map() auf undefined auf. Das ist die Quelle der Meldung Cannot read properties of undefined (reading 'map') — sie kommt nach dem erfolgreichen Schreiben, weshalb der Aufruf trotz gespeichertem Inhalt als Fehler zurückkommt.
  • content ist bei jedem Update Pflicht (PageEmptyContent). Deshalb liest auch update_page_meta den Inhalt und schreibt ihn unverändert zurück.
  • Die Antwort einer Mutation trägt Spaltennamen statt Schema-Feldnamen (localeCode, editorKey). Wer im page-Block locale anfordert, bekommt Cannot return null for non-nullable field Page.locale — nach erfolgreichem Schreiben. Mutationen wählen deshalb nur id, path, title, isPublished und updatedAt aus.

Der Client lädt daher vor jedem Update den aktuellen Stand und schickt jedes nicht explizit überschriebene Feld unverändert mit. path und locale bleiben bewusst außen vor: weichen sie vom Ist-Zustand ab, macht Wiki.js daraus einen Move. Dafür gibt es move_page.

Bekannte Grenze: publishStartDate / publishEndDate liegen in Wiki.js 2.x als String-Spalte hinter einem Date!-Scalar, dessen serialize() toISOString() aufruft. Für jede Seite ohne Veröffentlichungsfenster würde ein Abruf dieser Felder die gesamte Query mit einem GraphQL-Fehler beantworten — sie werden deshalb weder gelesen noch gesetzt. Ein per Weboberfläche gesetztes Veröffentlichungsfenster geht bei einem Update über diesen Server verloren.

Voraussetzungen

  • Python 3.11+
  • uv
  • Eine erreichbare Wiki.js-2.x-Instanz und ein API-Token

Einrichtung

# Abhängigkeiten auflösen
uv sync

# Zugangsdaten hinterlegen
cp .env.example .env
# .env öffnen und WIKIJS_URL / WIKIJS_TOKEN eintragen

Die .env ist über .gitignore vom Commit ausgeschlossen. Das Token wird nur als Authorization: Bearer …-Header verwendet und landet nicht in Logs oder Fehlermeldungen.

Guardrails

Drei Umgebungsvariablen begrenzen, was der Server überhaupt anbietet:

# Nur Lesen — Schreib-Tools werden gar nicht erst registriert
set -x WIKIJS_READONLY true

# Schreiben nur unterhalb bestimmter Pfade (kommagetrennt, Segmentgrenze)
set -x WIKIJS_ALLOWED_PATHS claude/,notizen/

# delete_page überhaupt anbieten
set -x WIKIJS_ENABLE_DELETE true

Die Allowlist greift zusätzlich zu den Rechten des Tokens, nicht statt ihrer. Der wirksamste Schutz bleibt ein Token, das nur kann, was es können soll.

Interne CA (TLS)

Läuft die Instanz hinter einer eigenen CA (z. B. eine *.internal-Adresse), der die von Python mitgelieferte CA-Liste nicht traut, scheitert die Verbindung mit CERTIFICATE_VERIFY_FAILED — obwohl curl funktioniert, weil es den System-Trust-Store nutzt. Dann WIKIJS_CA_BUNDLE auf ein CA-Bundle zeigen lassen:

set -x WIKIJS_CA_BUNDLE /etc/ssl/certs/ca-certificates.crt

Als Notlösung schaltet WIKIJS_VERIFY_SSL=false die Verifikation ab — nur verwenden, wenn kein Bundle greifbar ist. Per Default wird normal verifiziert.

Testen

Die Tests laufen ohne Wiki.js-Instanz — alle HTTP-Aufrufe gehen über respx an einen Mock. Geprüft wird, was der Client sendet:

uv run pytest -q
uv run ruff check .
uv run mypy src

Interaktiv mit dem MCP Inspector im Browser, jedes Tool einzeln aufrufbar:

uv run mcp dev src/wikijs_mcp/server.py

Danach ein echter Roundtrip gegen die Instanz, z. B. search_pages mit einem bekannten Suchbegriff und get_page mit einem bekannten Pfad (etwa home).

Registrierung in Claude Code

claude mcp add wikijs --env WIKIJS_URL=https://wiki.example.com \
  --env WIKIJS_TOKEN=<token> \
  --env WIKIJS_ALLOWED_PATHS=claude/ \
  -- uv run --directory /pfad/zu/wikijs-mcp wikijs-mcp

Registrierung in Claude Desktop

Eintrag unter mcpServers in ~/.config/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "wikijs": {
      "command": "uv",
      "args": ["run", "--directory", "/pfad/zu/wikijs-mcp", "wikijs-mcp"],
      "env": {
        "WIKIJS_URL": "https://wiki.example.com",
        "WIKIJS_TOKEN": "<token>",
        "WIKIJS_CA_BUNDLE": "/absoluter/pfad/zum/ca-bundle.crt"
      }
    }
  }
}

Seit 0.2.0 liegt der Server unter src/wikijs_mcp/. Bestehende Konfigurationen mit server.py als letztem Argument funktionieren über einen Shim weiter; der Konsolen-Entry-Point wikijs-mcp ist aber der bevorzugte Weg.

Hinweise zum GraphQL-Schema

Die Query- und Mutation-Namen wurden gegen das Schema einer echten Wiki.js-2.x-Instanz verifiziert (server/graph/schemas/page.graphql). Die Feldnamen können sich zwischen Wiki.js-Versionen unterscheiden — bei einer anderen Version die Queries in src/wikijs_mcp/client.py gegen die eigene Instanz prüfen.