netzhirsch / contao-mcp-bundle
Connects Claude and other AI agents to Contao 5 via the Model Context Protocol — 175 MCP tools for pages, articles, news, files, templates, members, forms, newsletters and more, secured with OAuth 2.1 (PKCE + Dynamic Client Registration). Commercial extension: 30-day trial, then a subscription per C
Package info
github.com/Netzhirsch/contao-mcp-bundle
Type:contao-bundle
pkg:composer/netzhirsch/contao-mcp-bundle
Requires
- php: ^8.1
- ext-sodium: *
- contao/core-bundle: ^5.3 || ^6.0
- league/oauth2-server: ^8.5
- nyholm/psr7: ^1.8
- php-mcp/server: ^3.3
- symfony/config: ^6.4 || ^7.0 || ^8.0
- symfony/console: ^6.4 || ^7.0 || ^8.0
- symfony/dependency-injection: ^6.4 || ^7.0 || ^8.0
- symfony/framework-bundle: ^6.4 || ^7.0 || ^8.0
- symfony/http-foundation: ^6.4 || ^7.0 || ^8.0
- symfony/http-kernel: ^6.4 || ^7.0 || ^8.0
- symfony/psr-http-message-bridge: ^2.3 || ^7.0 || ^8.0
- symfony/routing: ^6.4 || ^7.0 || ^8.0
- symfony/yaml: ^6.4 || ^7.0 || ^8.0
Requires (Dev)
- contao/calendar-bundle: ^5.3 || ^6.0
- contao/comments-bundle: ^5.3 || ^6.0
- contao/faq-bundle: ^5.3 || ^6.0
- contao/manager-plugin: ^2.0
- contao/news-bundle: ^5.3 || ^6.0
- contao/newsletter-bundle: ^5.3 || ^6.0
- friendsofsymfony/http-cache-bundle: ^3.0
- numero2/contao-deepl: ^1.0
- phpstan/phpstan: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^11.0
- terminal42/contao-leads: ^3.3
- terminal42/contao-url-rewrite: ^1.8
Suggests
- contao/calendar-bundle: Required for calendar_* and calendar_event_* tools.
- contao/comments-bundle: Required for comment_* tools.
- contao/faq-bundle: Required for faq_* tools.
- contao/news-bundle: Required for news_* tools.
- contao/newsletter-bundle: Required for newsletter_* tools.
- numero2/contao-deepl: Required for deepl_* tools (translation). Also supplies the DEEPL_API_KEY configuration both it and this bundle read.
- terminal42/contao-leads: Required for leads_list + lead_get (read-only access to form submissions).
- terminal42/contao-url-rewrite: Required for url_rewrite_* tools.
Provides
None
Conflicts
None
Replaces
- react/http: *
This package is auto-updated.
Last update: 2026-09-04 12:18:26 UTC
README
🇬🇧 English version — diese deutsche Fassung ist die Referenz.
Status: Stable — v1.8.2
Lizenz: proprietär, kommerziell lizenziert — 30 Tage kostenlos testen,
danach 49 €/Monat je Contao-Instanz (siehe Lizenz & Testphase
und LICENSE)
Ein Model Context Protocol-Server als Contao-5-Bundle. Verbindet Claude Desktop, Claude in der API, Claude Code, MCP Inspector oder jede andere MCP-fähige KI direkt mit dem Contao-Backend — ohne eigene REST-Endpunkte, ohne Middleware, ohne Port.
Statt jeder KI-Aufgabe einen eigenen API-Endpunkt nachzuziehen, bekommt die KI-Session strukturierten Zugriff auf den gesamten DCA-Stack: Redakteure können per natürlichsprachlichem Auftrag Inhalte anlegen, Pipelines können Seiten vollautomatisch aus Drittsystemen befüllen, Entwickler können Strukturmigrationen skripten — alles über dieselben 186 Tools, abgesichert mit denselben Backend-Benutzerrechten wie beim manuellen Bearbeiten.
Unterstützte Entitäten: News, Seiten, Artikel, Kalender, FAQ, Mitglieder, Formulare, Newsletter, Kommentare, Themes, Layouts, Module, Bildgrößen, Templates, Dateien, URL-Rewrites, Formular-Leads, Wartung + System-Einstellungen.
Was drin ist
- 186 Tools über Contao-Kernentitäten + populäre Extensions.
- Lazy-Mode-Discovery: drei Meta-Tools (
contao_search_tools,contao_describe_tool,contao_call) verstecken die übrigen vortools/list— spart bei Claude Desktop ~12 KB System-Prompt-Overhead pro Turn. - OAuth 2.1 mit PKCE, Client-ID-Metadatendokumenten (CIMD),
Dynamic Client Registration (RFC 7591) und Protected-Resource-Metadata
(RFC 9728). Mit CIMD verbindet sich Claude ohne jede Vorbereitung im
Backend — kein Pairing-Fenster, keine geöffnete Registrierung. Wer
registrieren will, kann es weiterhin: im Default-Modus
restrictedausschließlich im 15-Minuten-Pairing-Fenster. - Volltextsuche über die Website:
search_querydurchsucht Contaos Suchindex (tl_search) — findet also auch Text, der aus Modulen, Includes oder Erweiterungen stammt und über die CRUD-Tools nicht auffindbar wäre. Geschützte Seiten bleiben außen vor;search_index_statuszeigt, ob der Index überhaupt befüllt ist. - Filesystem-Suche:
files_search(rekursive Glob-Suche im Upload-Tree, POSIX-Syntax +**-Erweiterung, basename-Match bei Patterns ohne Slash) - Site-Building-Helfer:
entity_move,page_cache_invalidate,system_settings_update,insert_tags_list,page_preview,maintenance_run,dbafs_sync(Reconciletl_files↔ Disk). - Bauen in einem Aufruf statt in einer Schrittliste:
pages_create_treeundpages_delete_treefür den Seitenbaum,content_create_treefür eine ganze Inhaltsstrecke inklusive verschachtelter Container. Alles Prüfbare wird vor dem ersten Schreibvorgang geprüft;dry_runzeigt den Plan. entity_field_patch: eine Passage in einer Textspalte ersetzen, statt das ganze Feld neu zu schreiben.oldmuss genau so oft vorkommen wie erwartet, sonst bricht der Aufruf ab, ohne den Datensatz anzufassen — und der Schreibvorgang läuft trotzdem über das*_update-Tool der Tabelle, mit Versions-Snapshot.html_filter_info+html_filter_preview: was der Ausgabefilter von eigenem Markup übrig lässt — bevor es geschrieben wird. Gespeichert ist nicht gerendert: Der Read-back liefert das Markup unverändert zurück, während<input type>und<label for>im Frontend längst entfernt sind.- Optionale Extension-Tools: erscheinen automatisch, sobald das jeweilige
Bundle installiert ist (sonst sauberer
extension_not_available-Fehler): Newsletter, Kommentare,url_rewrite_*(terminal42), lesendleads_list+lead_getfür Formular-Einsendungen (terminal42/contao-leads) und Übersetzen mit DeepL (numero2/contao-deepl, siehe unten). - Author-Pass-Through: Writes laufen unter dem echten OAuth-User in
tl_log+tl_version. - Löschungen sind rückholbar: Was die KI löscht, landet inklusive
Kind-Datensätzen in
tl_undo— wiederherstellbar über Contaos normales „Rückgängig" im Backend. Wiederherstellen bleibt bewusst Handarbeit: Die KI kann löschen, aber nichts stillschweigend zurückholen. - Löschungen, die etwas kaputt machen würden, werden blockiert:
usage_findbeantwortet „wo wird das benutzt?" für Seiten, Dateien, Bilder, Artikel, Module, Formulare, Templates, Bildgrößen und alles Weitere — und derselbe Check läuft automatisch vor jedem*_delete. Gefunden wird an vier Stellen: DB-Felder (aus der DCA abgeleitet, also inkl. Extension-Feldern), Insert-Tags in beliebigen Textspalten ({{link::42}}, auch per Alias,{{file::…}},{{insert_module::…}}), in Dateien selbst —@import/url()in SCSS/CSS, hartcodierte Pfade in Templates — und bei Templates jedecustomTpl/…Tpl-Spalte, die darauf zeigt, plus{% extends %}/$this->extend()aus anderen Templates. Damit fallen auch die Fälle auf, die keine Datenbankabfrage sieht:_colors.scsswird als@import 'colors'eingebunden, und ein gelöschtesce_text_customändert stillschweigend, wie ein Content-Element rendert. Blockiert wird nur, was beweisbar und schädlich ist; Backend-Rechte-Mounts und bloße Namensnennungen werden berichtet, halten aber nichts auf. Überschreiben mitignore_references=true(landet intl_log). - Umbenennen und Verschieben werden mitgeprüft — aber nur, wo es wirklich
bricht:
file_rename,file_moveundtemplate_renamelaufen durch denselben Check. Contao behält beim Umbenennen Zeile, ID und UUID und schreibt nurtl_files.pathneu — also überlebensingleSRC = <uuid>und{{file::<uuid>}}das problemlos, während{{file::files/x.svg}}, ein SCSS-@importund ein hartcodierter Template-Pfad brechen. Blockiert wird deshalb nur, was an diesem Pfad bzw. Namen hängt; alles UUID-/ID-basierte wird gezeigt, hält aber nichts auf. Ein.html5-Template in einen anderen Ordner zu verschieben ist folgerichtig gar nicht blockiert: Contao findet es über den Basisnamen, der sich dabei nicht ändert. - Backend-Modul „MCP-Server" mit vier Bereichen: Status (Lizenz + Testphase/ Abo starten, OAuth-Clients, IATs), Konfiguration, Aktivitätslog, Tool-Panel (jedes Tool einzeln abschaltbar) — nur für Contao-Administratoren.
- Linux + Windows getestet (Laragon dev, Debian production).
Installation
1. Composer
composer require netzhirsch/contao-mcp-bundle
Mehr ist nicht nötig — kein repositories-Eintrag, kein Patch-Block, kein
allow-plugins. Das Bundle liegt auf
Packagist.
Alternativ im Contao Manager nach „Contao MCP Bundle" suchen und installieren.
2. Bundle registrieren
Auto-Discovery über das Contao Manager Plugin — kein manuelles Eintragen in
config/bundles.php nötig.
3. Schema-Migrationen + erste Konfig
vendor/bin/contao-console contao:migrate --env=prod
Legt die OAuth-Tabellen an (tl_mcp_oauth_*) und ergänzt die
External-ID-Spalten auf 24 Entity-Tabellen. Standardkonfig läuft
unauthentifiziert — für Production unbedingt auth_mode=oauth einschalten
(siehe Backend-Modul oder var/mcp/config.json).
Der MCP-Endpoint ist nach der Migration sofort live unter
https://<backend_url>/mcp — Apache/PHP-FPM serviert ihn wie jede andere
Symfony-Route. Kein Daemon, kein Port, kein Reverse-Proxy nötig.
4. Lizenz aktivieren (30 Tage kostenlos)
Ohne aktive Lizenz antworten alle Tools mit license_inactive — Contao selbst
läuft normal weiter. Im Backend unter MCP-Server → Status oben auf
„Testphase starten" klicken: 30 Tage, ohne Zahlungsdaten. Details siehe
Lizenz & Testphase.
5. In Claude Desktop / Cowork einbinden
Anleitungen im Repo: docs/installation.md (Client anbinden, online + lokal) und docs/dokumentation.md (vollständige Funktionsreferenz). Im Backend selbst gibt es keinen Doku-Tab mehr.
Client verbinden bei
oauth_registration_mode: restricted(Default): Der Weg ist MCP-Server → Status → „Registrierung für 15 Minuten öffnen". Das Fenster bleibt die vollen 15 Minuten offen, egal wie viele Versuche das kostet (bis 1.4.0 schloss es nach der ersten erfolgreichen Registrierung — daher scheiterten Retrys und ein zweiter Client). Abgewiesene Versuche stehen mit Grund und IP unter MCP-Server → Aktivität.
Schritt-für-Schritt-Anleitung für die lokale Connector-Einrichtung
(mcp-remote-Bridge, claude_desktop_config.json, OAuth, Schema-Cache +
Stolperfallen): docs/mcp-client-lokal-einrichten.md.
Kurzfassung der genauen Config-Werte auch im Backend-Doku-Tab des MCP-Server-Moduls.
Lizenz & Testphase
Das Bundle ist kommerziell lizenziert. Der Tool-Layer ist lizenzgeschützt:
ohne gültige Lizenz liefert jeder tools/call einen license_inactive-Fehler
(Ausnahme: ping). Contao selbst ist nie betroffen — Frontend, Backend und
alle anderen Erweiterungen laufen unverändert weiter.
| Testphase | 30 Tage, ohne Zahlungsdaten, eine je Domain/Konto |
| Preis | 49 €/Monat oder 539 €/Jahr (12 für 11), netto zzgl. MwSt. |
| Einheit | pro Contao-Instanz — unabhängig davon, wie viele Front-End-Domains sie bedient |
| Zahlung | Karte oder SEPA-Lastschrift, ausschließlich auf Stripe-gehosteten Seiten |
| Staging/Dev | kostenlos (lokale Hosts sowie Subdomains einer bezahlten Domain) |
Bestellen im Backend — alles unter MCP-Server → Status, Buttonleiste oben:
- „Testphase starten" → schaltet die Tools für 30 Tage frei.
- „Abonnieren" → öffnet die Stripe-Bezahlseite. Karten-/SEPA-Daten werden nur bei Stripe eingegeben, nie in Contao gespeichert.
- „Abo verwalten" → Stripe-Kundenportal (Zahlungsmittel, Rechnungen, Kündigung).
Alternativ per CLI:
vendor/bin/contao-console contao:mcp:license status # aktueller Zustand vendor/bin/contao-console contao:mcp:license trial <email> # Testphase starten vendor/bin/contao-console contao:mcp:license activate <token> # Token einspielen
Verlängerung läuft automatisch. Der Cron LicenseRenewalCron (stündlich,
gedrosselt) erneuert das Token; die Prüfung selbst ist offline (Ed25519).
Ein Ausfall des Lizenzservers sperrt daher niemanden aus — zusätzlich gelten
3 Tage Kulanz nach Ablauf. Voraussetzung ist ein laufender Contao-Cron.
Verbindung zum Lizenzserver:
https://license.netzhirsch.de, fest im Bundle hinterlegt — nichts zu konfigurieren. Übertragen werden nur Domain, Produkt und die E-Mail des bestellenden Backend-Users.
Anforderungen
- PHP
^8.1mit Extensions:openssl,sodium,pdo_mysql,mbstring,intl(sodiumist für die Lizenzprüfung zwingend — fehlt es, bleiben alle Tools gesperrt; der Code ist 8.1-sauber, die 8.1-Untergrenze deckt Contao-5.3-Installationen ab) - Contao 5.3 bis 6.0 (die CI fährt den Smoke-Test gegen 5.3, 5.7 und 6.0)
- Symfony ≥ 6.4, 7.x oder 8.x
Contao 6
Läuft ohne Anpassung. Zwei Dinge sind beim Installieren zu beachten:
Contao 6 verlangt PHP ≥ 8.4. Die PHP-Untergrenze des Bundles bleibt bei 8.1, damit Contao-5.3-Instanzen weiterlaufen — auf PHP 8.1 ist schlicht nur die 5er-Linie installierbar.
Der Installationsbefehl braucht -W:
composer require netzhirsch/contao-mcp-bundle -W
Grund ist nicht das Bundle, sondern php-mcp/server: es pinnt
phpdocumentor/reflection-docblock auf ^5.6 und symfony/finder auf ^7.2,
während eine Contao-6-Installation beide höher auflöst. -W erlaubt Composer,
sie zurückzustufen. Beide Pakete vertragen das; ohne -W bricht die Auflösung
ab. Auf Contao 5 ist das Flag überflüssig.
- MySQL ≥ 8.0 oder MariaDB ≥ 10.6 (strict mode unterstützt)
- Speicher für
var/mcp/: schreibbar, mehr nicht. Seit 1.9.1 schreibt das Bundle seine Zustandsdateien atomar perrename()und braucht kein funktionierendes Datei-Locking — NFS-Mounts ohnelockd/statdsind damit unproblematisch. Auf ≤ 1.9.0 konnteflock()dort unbegrenzt hängen und einen Gateway Timeout auslösen (siehe CHANGELOG 1.9.1).
Smoke-Test
vendor/bin/contao-console contao:mcp:smoke-test --env=dev
Geht ~200 Asserts gegen den Tool-Layer durch (CRUD auf Member/Group/Form/ Newsletter/Comments/Theme/Layout/Templates/Maintenance + External-ID + Audit-Regressions + Key-Rotation + Rate-Limit + MCP-Activity-Log), erstellt eigene Testdaten, räumt am Ende wieder auf. Soll grün durchlaufen.
Zusätzlich gibt es eine isolierte PHPUnit-Suite (vendor/bin/phpunit)
für OAuth-Crypto-Edge-Cases (dual-key Rotation, IAT single-use,
HMAC-Pepper) die der Smoke-Test als End-to-End nicht erreicht.
Lokale Entwicklung & HTTPS
Das Bundle terminiert kein TLS — HTTPS liefert der Webserver davor
(lokal Laragon, produktiv z.B. Plesk/Let's-Encrypt). Die extern beworbenen
OAuth-Endpunkte baut das Bundle aus dem konfigurierten backend_url, nicht
aus dem Request-Schema — dadurch ist es reverse-proxy-robust.
Für lokale MCP-Tests reicht meist backend_url: "http://localhost"
(Loopback ist von der Redirect-URI-Whitelist und der HTTPS-Warnung
ausgenommen) — kein Zertifikat nötig. Echtes lokales HTTPS
(https://<host>.test) inkl. der Node-/CA-Stolperfalle bei MCP-Clients:
siehe docs/lokales-https.md.
Health-Check vor Production-Deploy
// MCP-Call {"tool": "system_health_check"}
Returnt eine strukturierte Liste über PHP-Setup, var/mcp/-Permissions,
OAuth-Konfig + warnings: [...] mit konkreten Fix-Befehlen. Vor jedem
Site-Move oder Server-Wechsel laufen lassen.
Verbinden ohne Pairing: CIMD
Seit 1.11.0 kann ein Client sich mit einer HTTPS-URL ausweisen, statt sich zu registrieren — der Server liest die Client-Daten von dieser URL (Client ID Metadata Document). Für den Kunden heißt das: kein Pairing-Fenster öffnen, nichts vorbereiten. Claude wählt diesen Weg von selbst, wenn die Instanz ihn ankündigt.
Umschaltbar im Backend unter MCP-Server → Konfiguration:
| Modus | Bedeutung |
|---|---|
trusted (Standard) |
nur claude.ai, claude.com und deren Subdomains |
open |
jede HTTPS-client_id, die offene Haltung der Spezifikation |
off |
nicht angekündigt, Clients registrieren sich wie bisher (DCR) |
Der Standard ist trusted, weil „jede HTTPS-URL akzeptieren" gleichbedeutend
ist mit „jede HTTPS-URL abrufen, die ein Aufrufer nennt". Auf dem
Produktivsystem eines Kunden ist das ein größeres Versprechen, als der Nutzen
hergibt — die Clients, mit denen Contao hier spricht, sind bekannt.
Was beim Abruf passiert. Das Dokument wird geholt, bevor irgendjemand angemeldet ist, von einer URL, die der Aufrufer bestimmt. Entsprechend eng ist der Rahmen:
- nur
https, mit Pfad, ohne Fragment, ohne Zugangsdaten, ohne./.., keine IP-Literale - der Host wird aufgelöst, jede Antwort muss öffentlich routbar sein, und die Verbindung wird auf die geprüfte Adresse gepinnt (gegen DNS-Rebinding)
- geblockt sind neben RFC 1918 und Loopback auch CGNAT,
169.254.169.254, NAT64 und IPv4-in-IPv6 - keine Weiterleitungen, 5 Sekunden Zeitlimit, 5 KB Größengrenze beim Streamen,
Content-Typemuss JSON sein - Rate-Limit pro
client_id-Host, nur bei Cache-Miss - das
client_id-Feld im Dokument muss exakt der abgerufenen URL entsprechen logo_uriwird ignoriert
Redirect-URIs werden exakt geprüft. Die einzige Ausnahme ist RFC 8252 §7.3:
Bei Loopback-Adressen wird der Port ignoriert, weil ein nativer Client seinen
Port nicht vorher kennt. Alles andere — Schema, Host, Pfad, Query — muss
stimmen, und http://localhost.attacker.example/callback fällt durch.
Sind alle Redirect-URIs eines Clients Loopback-Adressen, warnt die Zustimmungsseite zusätzlich: Ein Metadatendokument kann nicht verhindern, dass ein anderes Programm auf demselben Rechner einen Port belegt und den Namen des echten Clients für sich beansprucht.
Konfiguration
Datei: var/mcp/config.json (wird beim ersten Backend-Aufruf des Moduls
angelegt).
Die vier MCP-Server-Backendmodule sind Administratoren vorbehalten — sie schalten
auth_mode(und damit die komplette Rechteprüfung), vergeben OAuth-Registrierungen, widerrufen Clients und schließen kostenpflichtige Abos ab. Ein Nicht-Admin bekommt auch mit gesetztem Modulrecht „Zugriff verweigert".
Felder:
| Key | Default | Bedeutung |
|---|---|---|
path |
mcp |
URL-Pfad (ohne führenden Slash) |
pagination_limit |
500 |
Max Tools pro tools/list (irrelevant in Lazy-Mode) |
auth_mode |
none |
none oder oauth |
backend_url |
"" |
Public Base-URL des Contao-Backends (Pflicht bei OAuth) |
oauth_registration_mode |
restricted |
restricted (Registrierung nur im Pairing-Fenster) oder open |
lazy_mode |
false |
Wenn true: nur 6 Discovery-Tools in tools/list |
Bundle-eigene Konfig in config/packages/netzhirsch_contao_mcp.yaml:
netzhirsch_contao_mcp: write: default_author_id: 1 # Fallback wenn auth_mode=none preview: # Nur nötig, wenn die Instanz hinter HTTP-Basic-Auth liegt. # Default ist die Env-Variable; ohne sie bleibt alles wie bisher. basic_auth: '%env(default::MCP_PREVIEW_BASIC_AUTH)%'
page_preview holt die Seite über ihre öffentliche URL — steht davor ein
Basic-Auth-Schutz (typisch auf Staging), antwortet der Webserver mit 401, bevor
Contao überhaupt läuft. Dann in der .env.local der Instanz:
MCP_PREVIEW_BASIC_AUTH="user:pass"
Das Tool weist bei 401/403 selbst darauf hin. Die Zugangsdaten stehen nur in der
.env.local, nie in der Antwort oder im Log.
Übersetzen mit DeepL
Braucht numero2/contao-deepl und
einen DeepL-API-Schlüssel. Beides konfiguriert man einmal, und zwar dort, wo
es das Bundle ohnehin erwartet:
composer require numero2/contao-deepl
DEEPL_API_KEY="…"
Der Schlüssel ist Pflicht, sobald das Bundle installiert ist:
numero2setzt%env(DEEPL_API_KEY)%ohne Fallback, ein fehlender Wert lässt schoncache:clearmit „Environment variable not found" abbrechen.
Danach erscheinen vier Tools. Fehlt eines von beidem, antworten sie mit
extension_not_available bzw. deepl_not_configured und sagen, was fehlt —
deepl_status beantwortet das direkt, inklusive der Liste der Zielsprachen.
| Tool | Wofür |
|---|---|
deepl_status |
Verfügbarkeit, Zielsprachen, optional der Kontostand |
deepl_translate |
Freitext rein, Übersetzung raus — rührt keinen Datensatz an |
deepl_translate_records |
Ein oder mehrere Datensätze einer Tabelle |
deepl_translate_page_tree |
Seite + Meta + Artikel + Inhalte + alle Unterseiten |
Übersetzbar sind tl_page, tl_article, tl_content, tl_news,
tl_news_archive, tl_calendar_events, tl_calendar, tl_faq,
tl_faq_category, tl_form, tl_form_field und tl_module — jeweils nur die
Spalten, die wirklich Fließtext enthalten. Contaos Strukturwerte bleiben intakt:
Eine Überschrift behält ihr h2, ein Listen-Element seine Reihenfolge, ein
Tabellen-Element seinen Zeilenschnitt, und Rich-Text geht mit DeepLs
tag_handling=html raus, damit Markup und Attribute überleben.
Drei Modi, zwei Schalter
Weil „übersetzen", „Geld ausgeben" und „Inhalt überschreiben" drei verschiedene Entscheidungen sind:
dry_run: true— nur planen. Kein API-Aufruf, kein Schreibzugriff, keine Kosten. Antwortet mit den betroffenen Datensätzen, den Feldern und der Zeichenzahl, die der echte Lauf einreichen würde.- beides
false(Default) — übersetzen und zurückgeben. Nichts wird geschrieben. Auf 50 Datensätze begrenzt, weil hier jede Quelle und jede Übersetzung mitkommt. save: true— übersetzen und über das*_update-Tool der Tabelle schreiben: Versions-Snapshot,tl_log-Eintrag,changed_fieldsund die Rechteprüfung pro Datensatz, genau wie bei einem direkten Update.
Zusätzlich bremst max_characters (Default 100 000) vor dem ersten
API-Aufruf, wenn der Plan teurer wäre als erlaubt.
Was ein Aufruf kostet
Jede Antwort führt mit, was sie verbraucht hat:
"usage": { "characters_submitted": 482, "characters_reused": 16, "api_requests": 2 }
characters_submitted ist die Zahl, auf die DeepL abrechnet — tatsächlich
gesendete Quellzeichen. Übersetzungen werden 30 Tage zwischengespeichert
(eigener Cache, nach Zielsprache, Quellsprache und Tag-Handling
geschlüsselt), deshalb kostet die empfohlene Reihenfolge planen → ansehen →
speichern nur einmal. Der Kontozähler aus deepl_status ist eine
Abrechnungsperioden-Summe und läuft der Realität hinterher — er ist nicht der
Preis des letzten Aufrufs.
Der übliche Weg zu einem zweiten Sprachbaum
Übersetzt wird an Ort und Stelle: Der Datensatz, den man nennt, ist der Datensatz, der sich ändert. Für eine zweite Sprache also erst kopieren, dann die Kopie übersetzen:
entity_duplicate(table: "tl_page", id: 42, into_pid: <Ziel-Root>, with_children: true, overrides: {"published": false})deepl_translate_page_tree(id: <die Kopie>, target_lang: "EN-GB", dry_run: true)— was kostet das?- dasselbe mit
save: true entity_language_link(...)für die Verknüpfung mit changelanguage
Schritt 1 kopiert in einen live geschalteten Baum, wenn es ihn schon gibt —
deshalb das published: false: sonst steht der noch unübersetzte Quelltext für
die Dauer der Übersetzung öffentlich im Netz. Der zurückgegebene tree ist die
komplette Quell→Ziel-ID-Karte, die man in Schritt 2 ohnehin braucht.
Dasselbe gilt außerhalb des Seitenbaums. entity_duplicate deckt ab:
tl_page, tl_article, tl_content,
tl_module, tl_layout,
tl_news_archive, tl_news,
tl_calendar, tl_calendar_events,
tl_faq_category, tl_faq,
tl_form, tl_form_field
Das ist der Weg für alles, was man sonst Spalte für Spalte in ein
*_create tippen müsste — eine tl_module-Zeile hat je nach Erweiterungen
114 bis über 250 Spalten. Eine Sammlung zu kopieren nimmt über die
ctable-Kaskade alle Einträge mit: entity_duplicate(table: "tl_news_archive", id: 1) legt das Archiv samt seiner 95 Meldungen und deren
Inhaltselementen an. Für einen Sprach-Rollout ist genau das der Sinn, aber
copied nennt die Gesamtzahl — vorher einkalkulieren.
Kopiert wird wie beim Kopieren-Knopf im Backend: doNotCopy-Felder werden nicht
übernommen, sondern aus dem DCA-default gefüllt (eine kopierte Meldung ist
deshalb heute datiert, nicht 1970), der Alias wird aus dem richtigen Feld neu
erzeugt (headline bei News, question bei FAQs) und folgt einem overrides,
das die Kopie umbenennt. Name und Titel macht das Werkzeug nicht eindeutig —
dafür ist overrides da.
tl_user und tl_member sind bewusst nicht dabei: Contaos Kopieren-Knopf
landet dort in der Bearbeitungsmaske, damit ein Mensch Benutzername und E-Mail
eindeutig macht, bevor gespeichert wird.
Beide Hälften einer Übersetzungsbeziehung
terminal42/contao-changelanguage hinterlegt eine Übersetzung an zwei
Stellen, und ohne die zweite wird die erste nicht ausgewertet:
| Ebene | Spalte | Tabellen |
|---|---|---|
| Datensatz | languageMain |
tl_page, tl_article, tl_news, tl_calendar_events, tl_faq |
| Sammlung | master |
tl_news_archive, tl_calendar, tl_faq_category |
Fehlt master, fällt der Sprachwechsler auf die Sprachwurzel zurück und der
hreflang-Alternate wird nicht ausgegeben — sichtbar wird das erst am
gerenderten Frontend, die Datenbank sieht korrekt aus.
entity_language_link deckt beide Ebenen ab und vervollständigt die
Sammlungshälfte selbst, wo das eindeutig und zulässig ist:
entity_language_link(table: "tl_news", default_id: 8, translations: {"en": 16})
→ linked: 1
collections_linked: [{table: "tl_news_archive", id: 3, master: 1}]
warnings: []
Geht das nicht — weil das Zielarchiv schon eine andere Übersetzung ist, oder
weil auf derselben Leserseite bereits eine Sammlung denselben Master
beansprucht —, steht in warnings, was fehlt und mit welchem Aufruf es zu
setzen ist. Die Sammlungen lassen sich auch direkt verknüpfen:
entity_language_link(table: "tl_news_archive", default_id: 1, translations: {"en": 3}).
Root-Seiten verknüpfen über languageRoot statt languageMain und werden hier
abgelehnt; dafür ist page_update zuständig.
Aliase werden bewusst nicht übersetzt. DeepL liefert Fließtext, kein Slug;
„Unsere Leistungen" gehört nicht in eine URL. Für übersetzte URLs erst den Titel
übersetzen und danach einen leeren Alias an page_update schicken — Contao
erzeugt ihn dann über den Slug-Service aus dem neuen Titel neu.
Wie Tools Fehler melden
Ein Tool, das nicht tun kann, was es soll, gibt ein strukturiertes Ergebnis
zurück statt einer Ausnahme — mit error, einer message im Klartext und,
wo es hilft, der Liste des Erlaubten. Zwei Fälle, die man kennen sollte:
Ein Feld, das der Datensatztyp nicht hat, wird abgelehnt und nennt den Typ:
Field "gibtsNicht" is not valid for content type "text".
Use content_palette_get("text") to see allowed fields. Currently allowed: pid, ptable, …
Ein Parameter, den das Tool nicht hat, ebenso — mit Vorschlag bei einem Tippfehler (seit 1.10.0; davor wurde er stillschweigend verworfen und der Aufruf meldete Erfolg, ohne etwas zu ändern):
Tool "page_update" has no parameter "pageTitel" (did you mean "pageTitle"?).
Nothing was changed. Allowed parameters: id, pid, title, type, sorting, …
Beides gilt für direkte tools/call und für den contao_call-Proxy im
Lazy-Mode.
Bekannte Einschränkungen
Stand v1.8.2:
- PHPUnit-Coverage deckt OAuth-Crypto, die Permission-Map und den Usage-Scanner ab. Der Tool-Layer wird stattdessen end-to-end vom Smoke-Test exerziert.
- Encryption-Key-Rotation ist NICHT implementiert. Der
var/mcp/oauth/encryption.keyschützt Refresh-Token-Payloads at rest — Rotation würde alle Refresh-Tokens invalidieren. (Die RSA-Signing-Keys lassen sich dagegen rotieren, siehecontao:mcp:oauth:rotate-keysunter „Wartung".) - Lizenz-Domainbindung wertet die konfigurierte
backend_urlaus. Das ist eine kaufmännische, keine kryptografische Grenze — sie hält ehrliche Installationen sauber getrennt, ist aber vom Betreiber der Instanz beeinflussbar.
Voller Audit-Stand: CHANGELOG.md.
Backup-Strategie
Das Bundle persistiert vier separate Daten-Surfaces. Ein vollständiger Restore braucht alle vier — sonst bleiben entweder OAuth-Tokens ungültig (Keys weg) oder Tool-Calls können keine externen Referenzen zuordnen (External-IDs weg).
| Surface | Pfad | Restore-Verhalten |
|---|---|---|
| OAuth-RSA-Keys + Encryption-Key | var/mcp/oauth/*.pem, var/mcp/oauth/encryption.key |
Pflicht. Fehlt → alle Refresh-Tokens ungültig, alle Access-Tokens müssen neu ausgestellt werden. Mode 0600 zwingend. |
| Bundle-Config | var/mcp/config.json |
Optional. Fehlt → Defaults greifen, Operator muss auth_mode=oauth manuell aktivieren. |
| OAuth-Tabellen | tl_mcp_oauth_client, tl_mcp_oauth_access_token, tl_mcp_oauth_refresh_token, tl_mcp_oauth_auth_code, tl_mcp_oauth_iat |
Pflicht für nahtlose Migration. Fehlt → Clients müssen sich neu registrieren (DCR). |
| External-ID-Spalten | external_id_namespace + external_id_key auf 24 Entity-Tabellen |
Pflicht für Skill-2-Integrationen. Fehlt → Updates müssen via Contao-PK statt externer Referenz erfolgen, schmerzhafte Doppel-Pflege. |
Empfehlung: tar über var/mcp/ + mysqldump auf die fünf
tl_mcp_oauth_*-Tabellen + ein DB-Dump des kompletten Contao-Schemas
(External-ID-Spalten leben auf Entity-Tabellen, kein eigener Backup-
Container möglich).
Entwicklung: Prüfkette vor einem Release
composer verify
Bündelt PHPStan + PHPUnit — genau das, was die CI fährt. Einmal pro Klon
composer setup-hooks ausführen: Danach lehnt ein pre-push-Hook einen Push ab,
der die CI rot machen würde (git push --no-verify umgeht ihn im Notfall).
Der Smoke-Test braucht ein laufendes Contao samt Datenbank und ist deshalb nicht Teil davon — er gehört vor jeden Release-Tag:
vendor/bin/contao-console contao:mcp:smoke-test --env=dev
Reihenfolge für einen Release: composer verify → Smoke-Test → committen →
pushen → CI grün abwarten → erst dann taggen.
Sicherheitslücken melden
Bitte nicht über ein öffentliches Issue, sondern über die Security-Policy (GitHub Security Advisory oder kalus@netzhirsch.de).
Bug-Reports
Issues / Findings bitte ins Repo, plus Anhang:
- Output von
system_health_check - Backend-User-Rolle + Contao-Version
- Relevante Einträge aus
var/log/prod.log(Symfony-Standard-Log)
Update von einer Version ≤ 1.4.0
Nichts zu tun — composer update netzhirsch/contao-mcp-bundle läuft durch,
auch wenn in der Root-composer.json noch der frühere Patch-Block steht. Die
patches/-Dateien liegen dafür bis 2.0.0 weiter im Paket; angewendet werden sie
von nichts mehr.
Wer aufräumen will (empfohlen, aber nicht dringend): extra.patches,
cweagans/composer-patches aus require und den allow-plugins-Eintrag aus der
Root-composer.json löschen, dann composer update. Der Vendor bleibt danach
gepatcht — das Plugin installiert php-mcp/server bei geschrumpfter Patch-Liste
nicht von sich aus neu. Folgenlos, weil ContaoDispatcher die betroffenen
Methoden überschreibt; wer es sauber will, hängt ein
composer reinstall php-mcp/server an. Details:
patches/README.md.
Wartung
Composer-Updates des Bundles:
composer update netzhirsch/contao-mcp-bundle
Es werden keine Vendor-Patches mehr angewendet: was das Bundle am
Dispatcher braucht (Lazy-Mode-Filter, Post-Call-Cleanup), liegt in
Server\ContaoDispatcher als Subklasse. Bei einem php-mcp/server-Major-Bump
dort prüfen, ob handleToolList()/handleToolCall() noch passen.
Wenn ein Update mit „no merge base" abbricht
Betrifft jede Instanz, auf der das Bundle als Git-Checkout im Vendor liegt („Source-Install"). Zwei Wege führen dorthin, und beide, nicht nur der erste:
- Die Versionsangabe ist ein Branch (
dev-master) — für Branches installiert Composer standardmäßig aus der Quelle. - Die Root-
composer.jsonenthält noch einenrepositories-Eintrag vom Typvcsauf das GitHub-Repository. Ohne GitHub-Token liefert der kein Dist-Archiv, also installiert Composer auch einen Tag aus der Quelle.
Ob es einen trifft, steht im Log: Bei einem Archiv steht dort Downloading netzhirsch/contao-mcp-bundle, bei einem Source-Install Syncing netzhirsch/contao-mcp-bundle … into cache.
Vor jedem Update prüft Composer den Checkout mit
git diff --name-status origin/master...master auf lokale Änderungen. Diese Drei-Punkt-Form braucht einen gemeinsamen Vorfahren — und wenn
der Composer-Cache zwischendurch neu aufgebaut wurde, hat der Vendor-Klon
gegenüber seinem origin keinen mehr:
In GitDownloader.php line 236:
Failed to execute git diff --name-status origin/master...master --
fatal: origin/master...master: no merge base
Composer schreibt diesen Fehler unter das Paket, das gerade an der Reihe war —
im beobachteten Fall php-mcp/server. Der Branch im Kommando verrät den
Verursacher: php-mcp/server liegt auf main, master ist dieses Bundle.
Reparatur mit Shell — den kaputten Checkout wegwerfen, Composer holt ihn neu:
rm -rf vendor/netzhirsch/contao-mcp-bundle composer install --no-dev --optimize-autoloader
Über den Contao Manager allein geht es nicht. Weder Aktualisieren noch
Entfernen des Pakets hilft, weil Composer den Checkout prüft, bevor es
irgendetwas mit ihm tut — in VcsDownloader::prepare(), und zwar für beide
Fälle:
if ($type === 'update') { $this->cleanChanges($prevPackage, $path, true); } elseif ($type === 'uninstall') { $this->cleanChanges($package, $path, false); }
prepare() läuft vor jeder Paket-Ausgabe, deshalb bricht auch ein
composer remove ab, ohne eine einzige - Removing …-Zeile zu drucken. Es
braucht also Datei-Zugriff: FTP/SFTP, den Dateimanager des Hosters oder SSH.
Kleinster Eingriff (FTP/SFTP, Hoster-Dateimanager): nur den Ordner
vendor/netzhirsch/contao-mcp-bundle/.git löschen — versteckte Dateien im
Client einblenden. Composer erkennt einen Git-Checkout ausschließlich an
is_dir($path.'/.git'); ohne dieses Verzeichnis steigt die Prüfung sofort aus,
und der nächste Vorgang im Manager läuft durch. Der Code bleibt liegen, die
Instanz läuft in der Zwischenzeit normal weiter.
Alternativ das ganze Verzeichnis vendor/netzhirsch/contao-mcp-bundle löschen
und im Manager das Paket neu hinzufügen. Datenbank (tl_mcp_oauth_*), Lizenz
und var/mcp/ bleiben dabei unangetastet, der Konnektor verbindet sich danach
unverändert.
Den Composer-Cache zu leeren reicht nicht. „no merge base" heißt, dass beide Refs aufgelöst werden konnten und keine gemeinsame Historie haben — das Problem sitzt im Vendor-Klon, den ein Cache-Leeren gar nicht anfasst.
Damit es gar nicht erst auftritt: das Bundle als Archiv statt als Git-Checkout
installieren. Dann ist der GitDownloader nicht beteiligt.
composer config preferred-install.netzhirsch/contao-mcp-bundle dist composer update netzhirsch/contao-mcp-bundle
Die eigentliche Ursache beseitigen: Das Bundle liegt auf
Packagist, und
Packagist liefert zu jedem Tag ein Zip. Ein repositories-Eintrag vom Typ vcs
auf GitHub ist damit überflüssig — und solange er dort steht, gewinnt er gegen
Packagist und erzwingt den Git-Checkout. Also aus der Root-composer.json
entfernen:
"repositories": [ { "type": "vcs", "url": "git@github.com:Netzhirsch/contao-mcp-bundle.git" } // ← weg ]
Danach composer update netzhirsch/contao-mcp-bundle. Ein Tag kommt dann als
Archiv, und der GitDownloader ist gar nicht mehr beteiligt.
Wenn Composer über psr/http-message stolpert
Die Meldung sieht so aus:
- php-mcp/server 3.3.0 requires react/http ^1.11 -> satisfiable by react/http[v1.11.0].
- react/http v1.11.0 requires psr/http-message ^1.0 -> found psr/http-message[1.0, 1.0.1, 1.1]
but these were not loaded, likely because it conflicts with another require.
Ab Version 1.9.0 tritt das nicht mehr auf: das Bundle installiert
react/http nicht mehr mit (siehe CHANGELOG). Auf einer Installation ≤ 1.8.x
ist ein Update auf ^1.9 die Lösung.
Falls die Meldung trotzdem auftaucht, ist der Hintergrund immer derselbe:
irgendein Paket im Projekt verlangt psr/http-message ^2.0, ein anderes
besteht auf ^1.0. Wer das ist, zeigt die composer.lock:
php -r '$l=json_decode(file_get_contents("composer.lock"),true); foreach($l["packages"] as $p){$c=$p["require"]["psr/http-message"]??null; if($c)printf("%-42s %s\n",$p["name"],$c);}'
Gesucht ist die Zeile, in der kein ^1. vorkommt — das ist der Blockierer.
Eine Falle beim Beheben: composer update <paket> --with-all-dependencies
hilft hier nicht. Ein Teil-Update darf nur Abhängigkeiten der genannten
Pakete bewegen — der Blockierer ist aber meist ein Geschwister, kein Kind.
Er muss mit auf die Kommandozeile, sonst bleibt -W wirkungslos.
Console-Kommandos
| Kommando | Zweck | Empfohlener Rhythmus |
|---|---|---|
contao:mcp:license status|trial|activate|renew |
Lizenz/Testphase verwalten | bei Bedarf (Verlängerung läuft per Cron automatisch) |
contao:mcp:oauth:cleanup |
abgelaufene Auth-Codes, Tokens, IATs purgen | täglich als Cron |
contao:mcp:oauth:rotate-keys |
OAuth-RSA-Signing-Keys rotieren (Dual-Key, ohne Ausloggen) | monatlich |
contao:mcp:permission-debug |
nachvollziehen, warum ein Backend-User ein Tool (nicht) darf | zur Fehlersuche |
contao:mcp:smoke-test |
End-to-End-Selbsttest des Tool-Layers | nach Updates/Serverumzug |
Der Contao-Cron muss laufen (contao:cron bzw. der Web-Cron) — daran hängt
auch die automatische Lizenzverlängerung.
Maintainer: Jan-Philipp Kalus <kalus@netzhirsch.de> — Netzhirsch