MCP-Schnittstelle (Model Context Protocol)

Ab Version 1.12.0 stellt der DKS einen MCP-Server bereit. Damit kann die Korrektur ohne zusätzliche Adapter direkt aus MCP-fähigen KI-Clients und Automatisierungsplattformen verwendet werden – etwa Cursor, Claude Desktop, ChatGPT, n8n, Dify oder Langdock.

Überblick

  • Protokoll: Model Context Protocol, Revision 2025-06-18 (die ältere Revision 2025-03-26 wird beim Handshake weiterhin akzeptiert)

  • Transport: Streamable HTTP, stateless JSON-RPC 2.0

  • Endpunkt: POST /mcp

  • Authentifizierung: API-Token (Bearer), Rolle API – identisch zur REST-API

  • Aktivierung: standardmäßig aktiviert; zweistufig über die Umgebungsvariable MCP_DISABLED (harter Kill-Schalter) plus einen Schalter in der Admin-Oberfläche

Es werden keine SSE-Streams erzeugt und keine Sitzungen geführt – jeder Aufruf ist atomar und kommt als einzelne JSON-RPC-Antwort zurück.

In der Admin-Oberfläche unter Text-Assistent -> MCP-Server werden Status, Endpunkt, Authentifizierung sowie die verfügbaren Werkzeuge (live aus tools/list) und Einbindungsbeispiele für die gängigen Clients angezeigt.

Aktivierung

Der Endpunkt ist standardmäßig aktiviert. Um ihn hart zu deaktivieren, wird die Umgebungsvariable MCP_DISABLED gesetzt:

MCP_DISABLED=true

Solange die Variable nicht gesetzt oder false ist, ist MCP verfügbar. Ist MCP_DISABLED=true gesetzt, beantwortet der Server jeden Request an /mcp mit einem leeren HTTP 404; Clients können nicht erkennen, dass die Schnittstelle existiert. Eine Änderung dieser Variable wird erst nach einem Neustart des Servers wirksam.

Laufzeit-Schalter (Admin-Oberfläche)

Solange MCP nicht per MCP_DISABLED=true hart abgeschaltet ist, lässt sich die Schnittstelle zusätzlich zur Laufzeit über die Admin-Oberfläche unter Text-Assistent -> MCP-Server an- und ausschalten – ohne Neustart. Die Umgebungsvariable bleibt dabei der harte Master-Schalter (z. B. um MCP in bestimmten Deployments vollständig zu sperren).

Wirksame Logik:

  • MCP_DISABLED=true -> MCP immer aus (Admin-Schalter ohne Wirkung).

  • MCP_DISABLED nicht gesetzt/false und Admin-Schalter an (Default) -> MCP an.

  • MCP_DISABLED nicht gesetzt/false und Admin-Schalter aus -> MCP aus.

Authentifizierung

Jede Anfrage an /mcp muss authentifiziert sein – identisch zur REST-API. Bevorzugt wird ein API-Token im Authorization-Header:

Authorization: Bearer <API-Token>

Alternativ wird HTTP-Basic-Authentifizierung (Benutzername/Passwort) akzeptiert. In jedem Fall ist die Rolle API erforderlich.

API-Tokens werden in der Admin-Oberfläche unter API-Tokens erstellt und verwaltet (siehe Benutzerkonten). Empfohlen wird ein eigener Token pro Client (z. B. Langdock Workspace RP), damit Aktivität nachvollziehbar bleibt und ein einzelner Client jederzeit revoziert werden kann.

Verfügbare Tools

Der Server meldet die Werkzeuge über tools/list. Jedes Tool liefert dort neben name, description und inputSchema zusätzlich:

  • title – menschenlesbarer Anzeigename

  • outputSchema – JSON-Schema des strukturierten Ergebnisses; die lesenden Tools sind damit voll selbstbeschreibend

  • annotations – Verhaltenshinweise (readOnlyHint, destructiveHint, idempotentHint, openWorldHint), an denen Clients ihre Sicherheits-UI und Auto-Freigabe ausrichten

Alle lesenden Tools sind readOnlyHint=true; propose_word schreibt (readOnlyHint=false), ist aber idempotent.

Jeder tools/call liefert ein content-Array (Text mit dem JSON als Zeichenkette) und – bei strukturierten Werkzeugen – dasselbe Ergebnis als strukturiertes JSON im Feld structuredContent. Bei Fehlern ist isError=true gesetzt.

check_text – Text prüfen

Prüft einen Text auf Rechtschreibung, Grammatik und Stil mit dem Duden-Korrektor. Liefert pro Fundstelle Position, Snippet, Typ, Fehlertext und Korrekturvorschläge.

Parameter:

  • text (string, Pflicht) – Der zu prüfende Text. Plain Text per Default; mit markupMode=xml für ausgezeichneten Text.

  • language (string, Default de-DE) – Sprache des Textes (BCP-47). Mögliche Werte liefert list_languages.

  • level (integer, Default 3) – Prüfstufe: 0 = keine, 1 = schnelle Rechtschreibung, 2 = Rechtschreibung+Grammatik, 3 = zusätzlich Stil (nur DE).

  • orthographyStandard (string) – überschreibt den im Profil hinterlegten Rechtschreibstandard. Werte: duden, conservative, progressive, extended, press.

  • propertySets (string[]) – Namen der anzuwendenden Korrekturprofile (siehe list_property_sets).

  • dictionaries (string[]) – Namen zusätzlicher Custom-Wörterbücher (siehe list_dictionaries).

  • markupMode (string, Default text) – text oder xml.

  • correctionProposals (boolean, Default true) – Korrekturvorschläge liefern.

  • hyphenation (boolean, Default false) – zusätzlich Silbentrennpositionen liefern.

  • glossary (boolean, Default false) – Glossartreffer mitliefern (nur wenn Glossarwörterbücher angegeben sind).

  • singleWordMode (boolean, Default false) – Eingabe als einzelnes Wort behandeln (z. B. für Wortlisten).

Rückgabe (structuredContent):

  • errorCount (integer) – Anzahl der Fundstellen.

  • errors (object[]) – eine Fundstelle je Problem, mit offset, length, snippet, type, errorCode, message, longMessage, examples (je false/correct), group, category und suggestions (string[]).

  • hyphenationPositions (object[]) – nur wenn hyphenation=true, sonst leer.

  • glossaryMessages (object) – nur wenn glossary=true, sonst ein leeres Objekt.

{
  "errorCount": 1,
  "errors": [
    {
      "offset": 12,
      "length": 8,
      "snippet": "Endwurff",
      "type": "orth",
      "errorCode": 4711,
      "message": "Mögliche Falschschreibung",
      "longMessage": "Das Wort wurde nicht im Wörterbuch gefunden ...",
      "examples": [
        { "false": "Endwurff", "correct": "Entwurf" }
      ],
      "group": "Rechtschreibung",
      "category": "Schreibung",
      "suggestions": ["Entwurf", "Entwürfe"]
    }
  ],
  "hyphenationPositions": [],
  "glossaryMessages": {}
}

list_dictionaries – Wörterbücher auflisten

Listet die im DKS verfügbaren Wörterbücher. Hilfreich, bevor ein Agent check_text oder propose_word mit einem bestimmten Wörterbuch aufruft. Keine Parameter.

Rückgabe (structuredContent):

  • count (integer)

  • dictionaries (object[]) – je name, description, language, isGlossary, isSystem, isReadOnly.

list_property_sets – Korrekturprofile auflisten

Listet alle Korrekturprofile (Property Sets) inklusive Sprache und Prüfstufe, damit ein Agent ein gültiges Profil für check_text.propertySets wählen kann. Keine Parameter.

Rückgabe (structuredContent):

  • count (integer)

  • propertySets (object[]) – je id, name, description, language, checklevel, orthstd, enforce und dictionaries (string[]).

list_languages – Sprachen auflisten

Listet die im DKS verfügbaren Sprachen, damit ein Agent einen gültigen Wert für check_text.language wählen kann. Keine Parameter.

Rückgabe (structuredContent):

  • count (integer)

  • languages (object[]) – je code (BCP-47) und source (dpf = eingebaut oder hunspell).

propose_word – Wort vorschlagen

Trägt ein Wort in ein beschreibbares Wörterbuch ein. Per Default wird das Wort als korrekt akzeptiert (accept); mit correction wird es stattdessen als Falschschreibung markiert und ein Korrekturvorschlag hinterlegt (reject). Idempotent: ein bereits existierender Eintrag für dasselbe Wort und Wörterbuch wird in-place aktualisiert statt dupliziert.

Parameter:

  • word (string, Pflicht, max. 76 Zeichen) – das vorzuschlagende Wort.

  • dictionary (string, Default Proposals) – Zielwörterbuch. Proposals ist das System-Wörterbuch für Nutzervorschläge; weitere Optionen siehe list_dictionaries.

  • comment (string, optional, max. 256 Zeichen) – Begründung / Kontext, der mit dem Eintrag gespeichert wird.

  • correction (string, optional, max. 256 Zeichen) – wird dieses Feld gesetzt, wird das Wort als Falschschreibung markiert (reject=true) und correction als Vorschlagswort gespeichert. Mehrere Vorschläge per Semikolon trennen. Ohne correction wird das Wort akzeptiert (accept=true).

Rückgabe (structuredContent):

  • status (string) – created oder updated.

  • mode (string) – accept oder reject.

  • word (string)

  • dictionary (string)

  • entryId (integer)

  • accept (boolean)

  • reject (boolean)

  • proposal (string) – hinterlegter Korrekturvorschlag (nur bei reject).

  • comment (string) – gespeicherte Begründung, falls vorhanden.

{
  "status": "created",
  "mode": "reject",
  "word": "Endwurff",
  "dictionary": "Proposals",
  "entryId": 1,
  "accept": false,
  "reject": true,
  "proposal": "Entwurf",
  "comment": "Tippfehler aus dem MCP-Smoketest"
}

Beispiel-Aufrufe (curl)

Typischer Ablauf: initialize, dann tools/list, dann tools/call. <API-Token> durch ein gültiges API-Token und <dks-host> durch den Hostnamen ersetzen.

Initialisierung:

curl -s -X POST https://<dks-host>/mcp \
  -H "Authorization: Bearer <API-Token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize",
       "params":{"protocolVersion":"2025-06-18","capabilities":{},
                 "clientInfo":{"name":"demo","version":"1"}}}'

Tool-Liste abrufen:

curl -s -X POST https://<dks-host>/mcp \
  -H "Authorization: Bearer <API-Token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'

Text prüfen:

curl -s -X POST https://<dks-host>/mcp \
  -H "Authorization: Bearer <API-Token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
       "params":{"name":"check_text",
                 "arguments":{"text":"Das ist ein Testt.",
                              "language":"de-DE","level":3}}}'

Einbindung in KI-Clients

Der DKS-MCP-Server lässt sich in gängige KI-Clients und Automatisierungsplattformen einbinden. Ersetzen Sie überall <API-Token> durch ein gültiges API-Token (Rolle API) und <dks-host> durch den Hostnamen Ihrer Instanz.

Bemerkung

Der Endpunkt muss vom Client erreichbar sein. Lokale Desktop-Clients (Cursor, Claude Desktop) genügen im selben Netz. Cloud-Dienste (ChatGPT, Langdock, Dify-Cloud) benötigen einen öffentlich über HTTPS erreichbaren Endpunkt.

Cursor

Cursor unterstützt Remote-MCP über Streamable HTTP direkt. Tragen Sie den Server in ~/.cursor/mcp.json (global) oder .cursor/mcp.json (projektbezogen) ein:

{
  "mcpServers": {
    "dks": {
      "url": "https://<dks-host>/mcp",
      "headers": {
        "Authorization": "Bearer <API-Token>"
      }
    }
  }
}

Danach Cursor neu laden („Reload Window“). Tipp: Statt Klartext kann das Token als Umgebungsvariable referenziert werden, z. B. "Bearer ${env:DKS_TOKEN}".

Claude Desktop

Claude Desktop spricht in seiner Konfiguration nur stdio; für Remote-HTTP wird die Bridge mcp-remote genutzt. Konfigurationsdatei: macOS ~/Library/Application Support/Claude/claude_desktop_config.json, Windows %APPDATA%\Claude\claude_desktop_config.json.

{
  "mcpServers": {
    "dks": {
      "command": "npx",
      "args": [
        "-y", "mcp-remote",
        "https://<dks-host>/mcp",
        "--transport", "http-only",
        "--allow-http",
        "--header", "Authorization:${DKS_AUTH}"
      ],
      "env": { "DKS_AUTH": "Bearer <API-Token>" }
    }
  }
}

--allow-http ist nur bei unverschlüsseltem http:// nötig (bei HTTPS weglassen). Das Bearer-Token wird wegen eines Whitespace-Bugs über die env-Variable gesetzt. Anschließend Claude Desktop neu starten.

ChatGPT

ChatGPT bindet eigene MCP-Server über den „Developer Mode“ (Entwicklermodus) als Connector/App ein – nur im Web (Desktop-Browser), nicht in der Mobile-App. Voraussetzung ist ein bezahlter Plan; Plus/Pro können nur lesende Connectors nutzen, schreibende Tools brauchen Business/Enterprise/Education (dort muss ein Admin den Developer Mode erst freigeben).

  1. Einstellungen -> „Apps & Connectors“ -> „Erweiterte Einstellungen“ -> „Developer Mode“ einschalten.

  2. Danach erscheint die Schaltfläche „Erstellen“ (Create): Connector anlegen mit Name, Beschreibung und der Endpunkt-URL https://<dks-host>/mcp.

  3. Im Chat über das „+“-/Tools-Menü „Developer Mode“ wählen und den Connector aktivieren.

Warnung

ChatGPT erreicht nur öffentlich über HTTPS erreichbare Endpunkte und unterstützt im Connector-Formular nur „OAuth“, „Keine Authentifizierung“ oder „Mixed“ – kein statisches Bearer-Token. Für den DKS-Server (Bearer-Auth) ist daher ein vorgelagerter HTTPS-Reverse-Proxy nötig, der OAuth bereitstellt oder das Token serverseitig injiziert.

n8n

In n8n verbindet der Node „MCP Client Tool“ externe MCP-Server (aktuelle n8n-Version mit HTTP-Streamable-Unterstützung vorausgesetzt; alternativ der Community-Node n8n-nodes-mcp).

  1. Node „MCP Client Tool“ zum Workflow hinzufügen.

  2. Connection Type auf „HTTP Streamable“ setzen und als URL https://<dks-host>/mcp eintragen.

  3. Als Authentication „Bearer Auth“ wählen und ein Bearer-Token-Credential mit dem API-Token anlegen.

  4. Operation wählen (z. B. „List Tools“ oder „Call Tool“) und den Node mit dem Agenten verbinden.

Dify

In Dify werden MCP-Server unter Tools eingebunden (nur HTTP-Transport).

  1. Tools -> MCP -> „Add MCP Server (HTTP)“ öffnen.

  2. Als Server-URL https://<dks-host>/mcp eintragen sowie Name und eine feste Server-ID vergeben.

  3. Im Headers-Feld den Authorization-Header hinterlegen:

    Authorization: Bearer <API-Token>
    

Das Headers-Feld gibt es erst in neueren Dify-Versionen; ältere unterstützen nur OAuth. Dify muss den Endpunkt erreichen können (bei Self-Hosting ggf. den SSRF-Proxy anpassen).

Langdock

In Langdock wird der Server als Integration hinzugefügt.

  1. Workspace-Einstellungen -> Integrations -> „Add Integration“ -> Typ „MCP“ wählen.

  2. Als URL https://<dks-host>/mcp eintragen und als Authentifizierung „API Key“ wählen.

  3. Header-Typ „Authorization: Bearer“ setzen, das API-Token eintragen und mit „Test connection“ prüfen.

Langdock läuft in der Cloud – der DKS-Endpunkt muss daher öffentlich über HTTPS erreichbar sein. Liegt der DKS hinter einer Firewall mit IP-Whitelist, sollte die statische IP der Langdock-Plattform freigeschaltet werden (siehe Langdock Docs: Static IP Configuration).

Korrekturen automatisch anwenden (Prompt-Vorlage)

Der häufigste Anwendungsfall ist: einen Text von check_text prüfen lassen und die gefundenen Korrekturen anschließend automatisch in den Text übernehmen. check_text liefert dazu die Befunde (Fundstellen samt Vorschlägen, Regeltext und Beispielen), aber es ändert den Text nicht – das Anwenden übernimmt ein Sprachmodell (LLM).

Bewährtes Muster (ein Durchlauf pro Text):

  1. check_text aufrufen und aus der Antwort das errors-Array aus structuredContent entnehmen.

  2. Die Befunde zusammen mit dem Originaltext an ein LLM geben – mit der Prompt-Vorlage unten und temperature = 0 für deterministische Ausgabe.

  3. Die LLM-Antwort ist der korrigierte Text.

Die Vorlage nutzt bewusst alle Felder eines Befunds: liegen suggestions vor, wählt das Modell den passendsten Vorschlag; ist die Liste leer, leitet es die Korrektur aus longMessage und den examples (Paare aus false und correct) ab. Dadurch werden auch Fehler behoben, für die DKS keinen fertigen Vorschlag hat.

Variante A – Plain Text

Für einfache Texte ohne Auszeichnung (z. B. Titel, Überschriften, Fließtext). System-Prompt:

Du erhältst einen Text und eine Liste von DKS-Korrekturbefunden.
Jeder Befund enthält -- soweit verfügbar -- die Felder:
  - snippet     : die markierte Textstelle
  - type        : "orth" | "gram" | "style" | ...
  - errorCode   : numerischer DKS-Regel-Code
  - message     : Kurztext der Regel
  - longMessage : ausführliche Beschreibung der Regel
  - examples    : Liste von { "false": ..., "correct": ... } Beispielen
                  derselben Regel -- die wichtigste Hilfe, wenn
                  suggestions leer ist.
  - suggestions : ggf. von DKS vorgeschlagene Korrekturen
Wende AUSSCHLIESSLICH die Befunde an. Pro Befund gilt:
  a) suggestions gefüllt -> wähle den grammatikalisch und semantisch
     passendsten Vorschlag (Numerus, Genus, Kasus, Artikel, Verbkongruenz).
  b) suggestions leer     -> orientiere dich an longMessage und examples.
     Übertrage das Muster aus den Beispielen ANALOG auf den vorliegenden
     Text (gleiche Regel, anderer konkreter Wortlaut).
     Bist du dir trotz Regelinfo und Beispielen nicht sicher, lass den
     Befund unverändert.
Antworte ausschließlich mit dem korrigierten Text, ohne Anführungszeichen,
ohne Markdown, ohne Kommentar.

Als Benutzernachricht die Befunde und den Originaltext übergeben, z. B.:

=== DKS-Befunde (JSON) ===
<errors-Array aus structuredContent>

=== Originaltext ===
<der geprüfte Text>

Variante B – HTML/Markup erhalten

Wenn der Text Auszeichnung enthält (HTML, z. B. aus einem CMS) und diese zeichengenau erhalten bleiben muss. Wichtig: Vor der Prüfung den sichtbaren Text aus dem Markup extrahieren und an check_text geben; die Korrekturen werden anschließend auf das originale Markup angewandt. System-Prompt:

Du bist ein deterministischer HTML-Korrekturassistent.
Wende AUSSCHLIESSLICH die unten gelisteten Befunde des Duden-Korrektor-
Servers (DKS) auf den gegebenen HTML-Inhalt an. Jeder Befund enthält --
soweit verfügbar -- die Felder snippet, type, errorCode, message,
longMessage, examples ({ "false": ..., "correct": ... }), group/category
und suggestions.
Strikte Regeln:
1. Ändere KEINE HTML-Tags, -Attribute, -Klassennamen, -IDs, -URLs oder
   HTML-Entities. Die Tag-Struktur des Outputs muss zeichengenau mit der
   des Inputs übereinstimmen; nur reine Text-Knoten dürfen sich ändern.
2. Pro Befund -- abhängig von "suggestions":
   a) GEFÜLLT: Wähle den Vorschlag, der grammatikalisch und semantisch am
      besten in den umgebenden Satz passt (Numerus, Genus, Kasus, Artikel,
      Verbkongruenz, Satzbedeutung). longMessage und examples liefern den
      Regel-Hintergrund. Beispiel: bei "ein Haii" mit ["Haie","Hai"] wählst
      du "Hai", weil "ein" Singular verlangt.
   b) LEER: Bestimme die Korrektur SELBST anhand von longMessage und
      examples. Übertrage das in den Beispielen gezeigte Muster ANALOG auf
      den vorliegenden Text (gleiche Regel, anderer konkreter Wortlaut).
      Beispiele:
        - type "orth", examples ["Entgeld"->"Entgelt", "watren"->"warten"]:
          "Resteraunt" wird zu "Restaurant".
        - type "gram", example "Wir vertrauen Herr Schmidt"->"Wir vertrauen
          Herrn Schmidt": "bestellten ... ein komplizierter Cocktail" wird zu
          "... einen komplizierten Cocktail" (Akkusativ nach "bestellen").
        - type "style" ohne Vorschlag: meist nicht zwingend ändern -- nur bei
          einem trivialen, eindeutigen Fix aus den examples.
3. Ermöglichen weder suggestions noch examples eine eindeutige Korrektur,
   lass den Befund unverändert und ignoriere ihn still.
4. Korrigiere im sichtbaren Text nur die "snippet"-Strings (bei type "gram"
   ggf. die umrissene Wortgruppe) -- berücksichtige umliegende Satzzeichen.
   Erscheint ein Snippet mehrfach und der Kontext erlaubt keine eindeutige
   Unterscheidung, korrigiere alle Vorkommen.
5. Ist ein Snippet im sichtbaren Text nicht auffindbar, ignoriere den Befund
   still.
6. Füge KEINEN neuen Text, KEINE Erklärungen, KEINE Kommentare und KEINE
   Markdown-Codeblöcke hinzu.
7. Antworte ausschließlich mit dem korrigierten HTML, beginnend exakt mit dem
   ersten Zeichen des Originals.

Als Benutzernachricht die Befunde und die HTML-Quelle übergeben, z. B.:

=== DKS-Befunde (JSON-Array) ===
<errors-Array aus structuredContent>

=== HTML-Quelle ===
<das originale HTML>

Referenz-Workflow für Dify

Ein vollständiger Beispiel-Workflow, der genau diese Prompts verwendet, steht als Dify-DSL bereit: ausgelöst durch das Veröffentlichen eines WordPress-Artikels prüft er Titel und Inhalt per MCP (check_text) und schreibt die Korrekturen als Entwurf (Autosave) samt Editor-Kommentar zurück. Der Workflow ist ein optionales Beispiel und nicht Teil der DKS-Installation.

Zum Verwenden den folgenden Inhalt als .yml speichern und in Dify unter Studio -> Import DSL importieren (direkter Download). Über die Schaltfläche oben rechts im Codeblock lässt sich der gesamte Inhalt in die Zwischenablage kopieren.

## Dify Workflow DSL for "DKS Spellcheck"
## Tested against Dify 1.10.1 (DSL version 0.5.0).
##
## Placeholders that init_dify.sh substitutes before /apps/imports:
##   __WP_BASIC_AUTH__   = admin:<WP_APP_PASSWORD>
##   __DKS_API_TOKEN__   = <DKS_API_TOKEN>   (from api_token table)
##   __WP_INTERNAL_URL__ = http://wordpress-60
##   __DKS_MCP_URL__     = http://dks:80/mcp
##
## Pipeline (zwei parallele Branches: Titel + Inhalt, am Ende zusammengefuehrt):
##   start(post_id, post_type)
##     -> derive_endpoints     (post_type -> wp_collection 'posts'/'pages')
##     -> fetch_post           (GET WP REST raw post/page)
##     -> strip_html           (plain text for DKS, keeps raw_html + title)
##     -> [Titel-Branch]
##         -> dks_check_title          (level=2: Rechtschr.+Gramm., kein Stil)
##         -> extract_findings_title
##         -> llm_apply_title          (plain text, keine Markup-Sorgen)
##     -> [Inhalt-Branch]
##         -> dks_check_content        (level=3: + Stil)
##         -> extract_findings_content
##         -> llm_apply_content        (raw HTML, Markup wird erhalten)
##     -> build_payloads               (kombiniert beide LLM-Outputs)
##     -> create_autosave              (POST /wp/v2/{type}/{id}/autosaves)
##     -> add_comment                  (Editor-Kommentar mit Befunden)
##     -> end
##
## Das LLM-Modell ist auf beiden llm_apply_* Nodes gesetzt. Nach dem Import unter
## Dify -> Settings -> Model Provider das OpenAI-Plugin installieren und API-Key
## hinterlegen. Default-Provider "langgenius/openai/openai", Modell "gpt-4o" --
## anderen Provider/Modell pro Node im UI waehlbar.
app:
  description: Triggered by WordPress publish, runs Duden Korrektor (DKS) via MCP and writes corrections back as a draft.
  icon: "\U0001F4DD"
  icon_background: "#FFEAD5"
  icon_type: emoji
  mode: workflow
  name: DKS Spellcheck
kind: app
version: 0.5.0
workflow:
  features: {}
  environment_variables: []
  conversation_variables: []
  graph:
    nodes:
      - id: start
        position: {x: 0, y: 0}
        data:
          type: start
          title: Start
          desc: "Receives post_id + post_type from the WordPress mu-plugin"
          variables:
            - variable: post_id
              label: "Post ID"
              type: number
              required: true
            - variable: post_type
              label: "Post Type"
              type: text-input
              required: false
              default: "post"
            - variable: site_url
              label: "Site URL (override)"
              type: text-input
              required: false
              default: "http://wordpress-60"

      - id: derive_endpoints
        position: {x: 125, y: 0}
        data:
          type: code
          title: Derive REST Collection
          desc: "Mappt post_type (post|page) auf REST-Collection-Plural (posts|pages)"
          code_language: python3
          code: |
            def main(post_type: str) -> dict:
                ## fallback auf 'posts' falls leer/unbekannt
                pt = (post_type or "post").strip().lower()
                mapping = {"post": "posts", "page": "pages"}
                collection = mapping.get(pt, pt + "s")
                return {"wp_collection": collection}
          variables:
            - variable: post_type
              value_selector: [start, post_type]
          outputs:
            wp_collection:
              type: string

      - id: fetch_post
        position: {x: 250, y: 0}
        data:
          type: http-request
          title: Fetch Post/Page
          desc: "GET WordPress post/page with raw content"
          method: get
          url: "__WP_INTERNAL_URL__/wp-json/wp/v2/{{#derive_endpoints.wp_collection#}}/{{#start.post_id#}}?context=edit"
          authorization:
            type: api-key
            config:
              type: basic
              api_key: "__WP_BASIC_AUTH__"
              header: ""
          headers: ""
          params: ""
          body:
            type: none
            data: []

      - id: strip_html
        position: {x: 500, y: 0}
        data:
          type: code
          title: Strip HTML
          desc: "Parse fetch_post.body JSON and reduce post content.raw to plain text"
          code_language: python3
          code: |
            import json, re, html
            def main(post_body: str) -> dict:
                try:    post = json.loads(post_body or "{}")
                except: post = {}
                raw_html = (post.get("content") or {}).get("raw") or ""
                title    = (post.get("title")   or {}).get("raw") or ""
                txt = re.sub(r"<[^>]+>", " ", raw_html)
                txt = html.unescape(re.sub(r"\s+", " ", txt)).strip()
                return {"plain": txt, "raw_html": raw_html, "title": title}
          variables:
            - variable: post_body
              value_selector: [fetch_post, body]
          outputs:
            plain:
              type: string
            raw_html:
              type: string
            title:
              type: string

      - id: dks_check_title
        position: {x: 750, y: -180}
        data:
          type: http-request
          title: DKS MCP check_text (Titel)
          desc: "level=2 -- nur Rechtschreibung+Grammatik, kein Stil (kein 'fehlender Punkt am Ende' Geraffel)"
          method: post
          url: "__DKS_MCP_URL__"
          authorization:
            type: api-key
            config:
              type: bearer
              api_key: "__DKS_API_TOKEN__"
              header: ""
          headers: "Content-Type:application/json"
          params: ""
          body:
            type: json
            data:
              - key: ""
                type: text
                value: '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"check_text","arguments":{"text":"{{#strip_html.title#}}","language":"de-DE","level":2}}}'

      - id: extract_findings_title
        position: {x: 1000, y: -180}
        data:
          type: code
          title: Extract Findings (Titel)
          code_language: python3
          code: |
            import json
            def main(mcp_body) -> dict:
                if isinstance(mcp_body, str):
                    try:    mcp_body = json.loads(mcp_body)
                    except: mcp_body = {}
                if not isinstance(mcp_body, dict):
                    mcp_body = {}
                struct = (mcp_body.get("result") or {}).get("structuredContent") or {}
                errors = struct.get("errors", []) or []
                findings, md_lines = [], []
                for f in errors:
                    snippet = f.get("snippet", "")
                    if not snippet:
                        continue
                    findings.append({
                        "snippet":     snippet,
                        "suggestions": (f.get("suggestions") or [])[:8],
                        "type":        f.get("type", ""),
                        "errorCode":   f.get("errorCode"),
                        "message":     f.get("message", "")     or "",
                        "longMessage": f.get("longMessage", "") or "",
                        "examples":    (f.get("examples") or [])[:3],
                        "group":       f.get("group", "")    or "",
                        "category":    f.get("category", "") or "",
                    })
                    sugs = findings[-1]["suggestions"]
                    rhs   = ("[" + " | ".join(sugs) + "]") if sugs else "(kein Vorschlag)"
                    extra = " (" + findings[-1]["message"] + ")" if findings[-1]["message"] else ""
                    md_lines.append("- [Titel/" + (findings[-1]["type"] or "?") + "] '" + snippet + "' -> " + rhs + extra)
                return {
                    "findings_json": json.dumps(findings, ensure_ascii=False),
                    "findings_md":   "\n".join(md_lines),
                    "count":         len(findings),
                }
          variables:
            - variable: mcp_body
              value_selector: [dks_check_title, body]
          outputs:
            findings_json: {type: string}
            findings_md:   {type: string}
            count:         {type: number}

      - id: llm_apply_title
        position: {x: 1250, y: -180}
        data:
          type: llm
          title: LLM Apply Corrections (Titel)
          desc: "Plain-Text Korrektur, kein HTML"
          model:
            provider: langgenius/openai/openai
            name: gpt-4o
            mode: chat
            completion_params:
              temperature: 0
              max_tokens: 512
          prompt_template:
            - role: system
              text: |
                Du erhaeltst einen kurzen Artikel-Titel und eine Liste von DKS-Korrekturbefunden.
                Jeder Befund enthaelt -- soweit verfuegbar -- die Felder:
                  - snippet     : die markierte Textstelle
                  - type        : "orth" | "gram" | "style" | ...
                  - errorCode   : numerischer DKS-Regel-Code
                  - message     : Kurztext der Regel (z.B. "Dieses Wort korrigieren?")
                  - longMessage : ausfuehrliche Beschreibung der Regel
                  - examples    : Liste von { "false": ..., "correct": ... } Beispielen
                                  derselben Regel -- DIE wichtigste Hilfe, wenn
                                  suggestions leer ist.
                  - suggestions : ggf. von DKS vorgeschlagene Korrekturen
                Wende AUSSCHLIESSLICH die Befunde an. Pro Befund gilt:
                  a) suggestions gefuellt -> waehle den grammatikalisch und semantisch
                     passendsten Vorschlag (Numerus, Genus, Kasus, Artikel, Verbkongruenz).
                  b) suggestions leer     -> orientiere dich an longMessage und examples.
                     Uebertrage das Muster aus den Beispielen ANALOG auf den vorliegenden
                     Titel (gleiche Regel, anderer konkreter Text).
                     Bist du dir trotz Regelinfo + Beispielen nicht sicher, lass den
                     Befund unveraendert.
                Antworte ausschliesslich mit dem korrigierten Titel als plain text,
                ohne Anfuehrungszeichen, ohne Markdown, ohne Kommentar.
            - role: user
              text: |
                === DKS-Befunde (JSON) ===
                {{#extract_findings_title.findings_json#}}

                === Original-Titel ===
                {{#strip_html.title#}}
          context: {enabled: false, variable_selector: []}
          vision:  {enabled: false}
          structured_output: {enabled: false}

      - id: dks_check_content
        position: {x: 750, y: 180}
        data:
          type: http-request
          title: DKS MCP check_text (Inhalt)
          desc: "level=3 -- Rechtschreibung+Grammatik+Stil"
          method: post
          url: "__DKS_MCP_URL__"
          authorization:
            type: api-key
            config:
              type: bearer
              api_key: "__DKS_API_TOKEN__"
              header: ""
          headers: "Content-Type:application/json"
          params: ""
          body:
            type: json
            data:
              - key: ""
                type: text
                value: '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"check_text","arguments":{"text":"{{#strip_html.plain#}}","language":"de-DE","level":3}}}'

      - id: extract_findings_content
        position: {x: 1000, y: 180}
        data:
          type: code
          title: Extract Findings (Inhalt)
          code_language: python3
          code: |
            import json
            def main(mcp_body) -> dict:
                if isinstance(mcp_body, str):
                    try:    mcp_body = json.loads(mcp_body)
                    except: mcp_body = {}
                if not isinstance(mcp_body, dict):
                    mcp_body = {}
                struct = (mcp_body.get("result") or {}).get("structuredContent") or {}
                errors = struct.get("errors", []) or []
                findings, md_lines = [], []
                for f in errors:
                    snippet = f.get("snippet", "")
                    if not snippet:
                        continue
                    findings.append({
                        "snippet":     snippet,
                        "suggestions": (f.get("suggestions") or [])[:8],
                        "type":        f.get("type", ""),
                        "errorCode":   f.get("errorCode"),
                        "message":     f.get("message", "")     or "",
                        "longMessage": f.get("longMessage", "") or "",
                        "examples":    (f.get("examples") or [])[:3],
                        "group":       f.get("group", "")    or "",
                        "category":    f.get("category", "") or "",
                    })
                    sugs = findings[-1]["suggestions"]
                    rhs   = ("[" + " | ".join(sugs) + "]") if sugs else "(kein Vorschlag)"
                    extra = " (" + findings[-1]["message"] + ")" if findings[-1]["message"] else ""
                    md_lines.append("- [Inhalt/" + (findings[-1]["type"] or "?") + "] '" + snippet + "' -> " + rhs + extra)
                return {
                    "findings_json": json.dumps(findings, ensure_ascii=False),
                    "findings_md":   "\n".join(md_lines),
                    "count":         len(findings),
                }
          variables:
            - variable: mcp_body
              value_selector: [dks_check_content, body]
          outputs:
            findings_json: {type: string}
            findings_md:   {type: string}
            count:         {type: number}

      - id: llm_apply_content
        position: {x: 1250, y: 180}
        data:
          type: llm
          title: LLM Apply Corrections (Inhalt)
          desc: "Apply DKS findings to the original HTML, preserving every tag, attribute and entity"
          model:
            provider: langgenius/openai/openai
            name: gpt-4o
            mode: chat
            completion_params:
              temperature: 0
              max_tokens: 8192
          prompt_template:
            - role: system
              text: |
                Du bist ein deterministischer HTML-Korrekturassistent.
                Wende AUSSCHLIESSLICH die unten gelisteten Befunde des Duden-Korrektor-Servers (DKS) auf den gegebenen WordPress-HTML-Inhalt an.
                Jeder Befund enthält -- soweit verfügbar -- die Felder:
                  - snippet     : die markierte Textstelle im sichtbaren Text
                  - type        : "orth" | "gram" | "style" | ...
                  - errorCode   : numerischer DKS-Regel-Code
                  - message     : Kurztext der Regel (z.B. "Dieses Wort korrigieren?")
                  - longMessage : ausführliche Erklärung der Regel
                  - examples    : Liste von { "false": ..., "correct": ... } Beispielen
                                  derselben Regel -- maßgebliche Orientierungshilfe
                                  besonders wenn suggestions leer ist.
                  - group/category : grobe DKS-Kategorisierung
                  - suggestions : ggf. von DKS vorgeschlagene Korrekturen
                Strikte Regeln:
                1. Ändere KEINE HTML-Tags, -Attribute, -Klassennamen, -IDs, -URLs oder HTML-Entities. Die Tag-Struktur des Outputs muss zeichengenau mit der des Inputs übereinstimmen; nur reine Text-Knoten dürfen sich ändern.
                2. Pro Befund -- abhängig von "suggestions":
                   a) GEFÜLLT: Wähle den Vorschlag, der grammatikalisch und semantisch am besten in den umgebenden Satz passt -- berücksichtige Numerus (Singular/Plural), Genus, Kasus, Artikel, Verbkongruenz und Satzbedeutung. longMessage und examples beschreiben dabei den Regel-Hintergrund. Beispiel: bei "ein Haii" mit ["Haie","Hai"] wählst du "Hai", weil "ein" Singular verlangt.
                   b) LEER: DKS hat den Fehler erkannt, aber keinen automatischen Vorschlag erzeugt. Bestimme die Korrektur SELBST anhand von longMessage und examples. Übertrage das in den Beispielen gezeigte Muster ANALOG auf den vorliegenden Text (gleiche Regel, anderer konkreter Wortlaut). Beispiele:
                       - type "orth", longMessage "Schreibweise unbekannt", examples ["Entgeld"->"Entgelt", "watren"->"warten"]: → "Resteraunt" wird zu "Restaurant", "verschiednen" wird zu "verschiedenen".
                       - type "gram", longMessage "steht im Nominativ, möglicherweise passt anderer Kasus besser", example "Wir vertrauen Herr Schmidt"->"Wir vertrauen Herrn Schmidt": → "bestellten ... ein komplizierter Cocktail" wird zu "bestellten ... einen komplizierten Cocktail" (Akkusativ nach "bestellen").
                       - type "style" ohne Vorschlag: meist nicht zwingend ändern -- nur wenn die examples einen trivialen, eindeutigen Fix zeigen.
                3. Wenn weder suggestions noch examples eine eindeutige Korrektur ermöglichen, lass den Befund unverändert und ignoriere ihn still.
                4. Korrigiere im sichtbaren Text nur die "snippet"-Strings (bzw. bei type "gram": die ggf. längere, durch das snippet umrissene Wortgruppe) -- berücksichtige umliegende Satzzeichen. Erscheint ein Snippet mehrfach und der Kontext erlaubt keine eindeutige Unterscheidung, korrigiere alle Vorkommen.
                5. Wenn ein Snippet im sichtbaren Text gar nicht auffindbar ist, ignoriere den Befund still.
                6. Füge KEINEN neuen Text, KEINE Erklärungen, KEINE Kommentare und KEINE Markdown-Codeblöcke hinzu.
                7. Antworte ausschließlich mit dem korrigierten HTML, beginnend exakt mit dem ersten Zeichen des Originals.
            - role: user
              text: |
                === DKS-Befunde (JSON-Array) ===
                {{#extract_findings_content.findings_json#}}

                === Anzahl Befunde ===
                {{#extract_findings_content.count#}}

                === HTML-Quelle ===
                {{#strip_html.raw_html#}}
          context: {enabled: false, variable_selector: []}
          vision:  {enabled: false}
          structured_output: {enabled: false}

      - id: build_payloads
        position: {x: 1500, y: 0}
        data:
          type: code
          title: Build WP Payloads
          desc: "Kombiniert korrigierten Titel + korrigierten HTML-Inhalt zu Autosave + Comment"
          code_language: python3
          code: |
            import json
            def main(post_id,
                     title_orig: str, corrected_title: str,
                     raw_html: str, corrected_html: str,
                     count_title, count_content,
                     findings_md_title: str, findings_md_content: str) -> dict:
                ## --- Titel ---------------------------------------------------------
                t = (corrected_title or "").strip()
                ## LLM koennte aus Reflex Anfuehrungszeichen oder Codefences setzen
                if t.startswith("```"):
                    nl = t.find("\n")
                    t  = t[nl+1:] if nl != -1 else t[3:]
                    if t.endswith("```"):
                        t = t[:-3]
                    t = t.strip()
                if len(t) >= 2 and t[0] == t[-1] and t[0] in ('"', "'", "\u201c", "\u201e"):
                    t = t[1:-1].strip()
                if not t:
                    t = title_orig or ""

                ## --- Inhalt --------------------------------------------------------
                s = (corrected_html or "").strip()
                if s.startswith("```"):
                    nl = s.find("\n")
                    s  = s[nl+1:] if nl != -1 else s[3:]
                    if s.endswith("```"):
                        s = s[:-3]
                    s = s.strip()
                if not s:
                    s = raw_html or ""

                ## --- Autosave + Comment -------------------------------------------
                autosave = json.dumps({"title": t, "content": s})

                total = int(count_title) + int(count_content)
                md_parts = [x for x in [findings_md_title, findings_md_content] if x]
                comment = json.dumps({
                    "post":    int(post_id),
                    "content": "DKS-Vorschlag als Autosave hinterlegt -- im Editor pruefen.\n\n"
                               + str(total) + " Befund(e):\n" + ("\n".join(md_parts) or "(keine Befunde)"),
                })
                return {
                    "autosave_json": autosave,
                    "comment_json":  comment,
                    "total_count":   total,
                }
          variables:
            - variable: post_id
              value_selector: [start, post_id]
            - variable: title_orig
              value_selector: [strip_html, title]
            - variable: corrected_title
              value_selector: [llm_apply_title, text]
            - variable: raw_html
              value_selector: [strip_html, raw_html]
            - variable: corrected_html
              value_selector: [llm_apply_content, text]
            - variable: count_title
              value_selector: [extract_findings_title, count]
            - variable: count_content
              value_selector: [extract_findings_content, count]
            - variable: findings_md_title
              value_selector: [extract_findings_title, findings_md]
            - variable: findings_md_content
              value_selector: [extract_findings_content, findings_md]
          outputs:
            autosave_json: {type: string}
            comment_json:  {type: string}
            total_count:   {type: number}

      - id: create_autosave
        position: {x: 1750, y: 0}
        data:
          type: http-request
          title: Create Autosave
          desc: "POST /wp/v2/{type}/{id}/autosaves -- Editor sieht 'neuere Version' beim Oeffnen des Live-Artikels"
          method: post
          url: "__WP_INTERNAL_URL__/index.php?rest_route=/wp/v2/{{#derive_endpoints.wp_collection#}}/{{#start.post_id#}}/autosaves"
          authorization:
            type: api-key
            config:
              type: basic
              api_key: "__WP_BASIC_AUTH__"
              header: ""
          headers: "Content-Type:application/json"
          params: ""
          body:
            type: raw-text
            data:
              - key: ""
                type: text
                value: "{{#build_payloads.autosave_json#}}"

      - id: add_comment
        position: {x: 2000, y: 0}
        data:
          type: http-request
          title: Add Editor Comment
          desc: "POST comment on the original post summarising findings (no-op fuer pages mit deaktivierten comments)"
          method: post
          url: "__WP_INTERNAL_URL__/index.php?rest_route=/wp/v2/comments"
          authorization:
            type: api-key
            config:
              type: basic
              api_key: "__WP_BASIC_AUTH__"
              header: ""
          headers: "Content-Type:application/json"
          params: ""
          body:
            type: raw-text
            data:
              - key: ""
                type: text
                value: "{{#build_payloads.comment_json#}}"

      - id: end
        position: {x: 2250, y: 0}
        data:
          type: end
          title: End
          outputs:
            - variable: autosave_status
              value_selector: [create_autosave, status_code]
            - variable: comment_status
              value_selector: [add_comment, status_code]
            - variable: findings_count
              value_selector: [build_payloads, total_count]
            - variable: findings_count_title
              value_selector: [extract_findings_title, count]
            - variable: findings_count_content
              value_selector: [extract_findings_content, count]
            - variable: wp_collection
              value_selector: [derive_endpoints, wp_collection]

    edges:
      - {id: e0,  source: start,                    target: derive_endpoints,        sourceHandle: source}
      - {id: e1,  source: derive_endpoints,         target: fetch_post,              sourceHandle: source}
      - {id: e2,  source: fetch_post,               target: strip_html,              sourceHandle: source}

      ## Titel-Branch
      - {id: e3t, source: strip_html,               target: dks_check_title,         sourceHandle: source}
      - {id: e4t, source: dks_check_title,          target: extract_findings_title,  sourceHandle: source}
      - {id: e5t, source: extract_findings_title,   target: llm_apply_title,         sourceHandle: source}
      - {id: e6t, source: llm_apply_title,          target: build_payloads,          sourceHandle: source}

      ## Inhalt-Branch
      - {id: e3c, source: strip_html,               target: dks_check_content,       sourceHandle: source}
      - {id: e4c, source: dks_check_content,        target: extract_findings_content,sourceHandle: source}
      - {id: e5c, source: extract_findings_content, target: llm_apply_content,       sourceHandle: source}
      - {id: e6c, source: llm_apply_content,        target: build_payloads,          sourceHandle: source}

      ## Merge -> WP
      - {id: e7,  source: build_payloads,           target: create_autosave,         sourceHandle: source}
      - {id: e8,  source: create_autosave,          target: add_comment,             sourceHandle: source}
      - {id: e9,  source: add_comment,              target: end,                     sourceHandle: source}