@prontosite/mcp 0.14.6 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,34 @@
3
3
  Alle nennenswerten Änderungen am MCP-Server für ProntoSite. Der Server ist ein dünner Client um die
4
4
  ProntoSite Deploy-API; die eigentliche Logik lebt serverseitig.
5
5
 
6
+ ## 0.16.0 – 2026-11-03
7
+
8
+ - Formular-Anfragen per MCP (Scope `anfragen` bzw. `anfragen:schreiben`, beim Verbinden ein eigener, nie vorausgewählter Punkt;
9
+ API-Tokens von Kunden und Betreuern haben das Recht): `site_inbox_list` (Filter Status/Formular/Zeitraum/Volltext, max. 50 je Seite),
10
+ `site_inbox_get` (alle Felder, Anhänge mit 15-Minuten-Download-Link, Verlauf), `site_inbox_set_status` (neu/gelesen/erledigt,
11
+ spam ↔ kein-spam, mehrere IDs; „kein-spam" sendet die Benachrichtigung nach), `site_inbox_delete` (Papierkorb, 30 Tage),
12
+ `site_inbox_restore`, `site_inbox_stats`. Keine Antwort-Mails über MCP.
13
+ - `site_status`: `canInbox`/`canInboxWrite` und – mit Recht – `anfragen` (Anzahl je Status). Instructions 5f (Besucherdaten nie ungefragt
14
+ zusammenfassen oder weitergeben, Löschen nur nach Bestätigung).
15
+
16
+ ## 0.15.0 – 2026-11-03
17
+
18
+ - JS-Dateien bearbeitbar ohne die JS-Freigabe auszuhebeln: `site_edit_text` mit `scope:"all"` ersetzt auch in Skriptdateien (nur in
19
+ Texten/Zahlen/Kommentaren, Fundstellen in `skripte`); `site_file_put` darf JS für Kunden ändern. Der Server klassifiziert gegen den
20
+ zuletzt freigegebenen Stand: nur Zahlen/Texte/Kommentare → sofort wirksam, sonst `jsReview.status: "pending"` (live bleibt der freigegebene Stand).
21
+ - Neues Tool `site_js_review` (nur Betreuer-Token): ausstehende Prüfungen mit Diff ansehen, freigeben oder ablehnen.
22
+ - `site_status.jsReview`; Instructions 2e (Preise in Skripten, Hinweis „wartet auf Freigabe" an den Nutzer).
23
+
24
+ ## 0.14.7 – 2026-10-29
25
+
26
+ - `site_image_set`: neue Option `zoom` (true/false) – Bild per Klick vergrößern (Lightbox der ProntoSite-Runtime, Bildunterschrift
27
+ aus alt/figcaption, Galerie-Blättern in `.ps-gallery`). `zoom` geht auch ohne `mediaId`; `mediaId` ist dafür optional.
28
+ - Instructions 2d: Badge NUR als kanonisches Snippet einbauen (`data-ps="badge"` + `data-ps-variant` auto/light/dark/mono), Footer,
29
+ letzte Zeile, nie manuell stylen – der Server ersetzt beim Ausliefern alles außer der Variante.
30
+ - `site_forms_set`: neues Feld `captcha` (true/false) – Sicherheitscode (Bild-Captcha) je Formular; `site_forms_get` zeigt `captcha`,
31
+ `captchaLive` und `spamAbgewehrt` (abgewehrte Sendungen der letzten 30 Tage je Grund).
32
+ - Versionsnummer im lokalen Server (`index.mjs`) und `package-lock.json` wieder gleich `package.json`.
33
+
6
34
  ## 0.14.6 – 2026-10-27
7
35
 
8
36
  - Instructions 2c: neues Entwurfs-Limit je Website – 300 Änderungen je Stunde, davon 30 direkt hintereinander (Kunden und
package/api.mjs CHANGED
@@ -532,11 +532,11 @@ export async function apiMediaList(cfg, { site } = {}, fetchImpl = fetch) {
532
532
  return asJson(await fetchImpl(`${baseUrl(cfg)}/media/${encodeURIComponent(slug)}`, { headers: authHeaders(token, cfg) }));
533
533
  }
534
534
  /** POST /api/image-set/<site> – Bild einer Seite tauschen (setImage, ein Undo-Schritt). */
535
- export async function apiImageSet(cfg, { site, page, nodeId = null, marker = null, mediaId, alt = null, baseRev = null } = {}, fetchImpl = fetch) {
535
+ export async function apiImageSet(cfg, { site, page, nodeId = null, marker = null, mediaId = null, alt = null, zoom = null, baseRev = null } = {}, fetchImpl = fetch) {
536
536
  const { token, slug } = resolveTarget(cfg, site);
537
537
  return asJsonOr409(await fetchImpl(`${baseUrl(cfg)}/image-set/${encodeURIComponent(slug)}`, {
538
538
  method: "POST", headers: { ...authHeaders(token, cfg), "content-type": "application/json" },
539
- body: JSON.stringify({ page, mediaId, ...(nodeId ? { nodeId } : {}), ...(marker ? { marker } : {}), ...(alt != null ? { alt } : {}), ...(Number.isInteger(baseRev) ? { baseRev } : {}) }) }));
539
+ body: JSON.stringify({ page, ...(mediaId ? { mediaId } : {}), ...(nodeId ? { nodeId } : {}), ...(marker ? { marker } : {}), ...(alt != null ? { alt } : {}), ...(typeof zoom === "boolean" ? { zoom } : {}), ...(Number.isInteger(baseRev) ? { baseRev } : {}) }) }));
540
540
  }
541
541
  /** GET /api/forms/<site> – Formulare: Konto-Empfänger, Empfänger je Formular, Spam-Modus, Danke-Seiten, erkannte Formulare. */
542
542
  export async function apiFormsGet(cfg, { site } = {}, fetchImpl = fetch) {
@@ -544,9 +544,9 @@ export async function apiFormsGet(cfg, { site } = {}, fetchImpl = fetch) {
544
544
  return asJson(await fetchImpl(`${baseUrl(cfg)}/forms/${encodeURIComponent(slug)}`, { headers: authHeaders(token, cfg) }));
545
545
  }
546
546
  /** POST /api/forms/<site> – Empfänger (Konto oder je Formular), Spam-Modus, Danke-Seite setzen. */
547
- export async function apiFormsSet(cfg, { site, form = null, recipients, spamMode, thanksPage } = {}, fetchImpl = fetch) {
547
+ export async function apiFormsSet(cfg, { site, form = null, recipients, spamMode, thanksPage, captcha } = {}, fetchImpl = fetch) {
548
548
  const { token, slug } = resolveTarget(cfg, site);
549
- const body = { ...(form ? { form } : {}), ...(recipients !== undefined ? { recipients } : {}), ...(spamMode !== undefined ? { spamMode } : {}), ...(thanksPage !== undefined ? { thanksPage } : {}) };
549
+ const body = { ...(form ? { form } : {}), ...(recipients !== undefined ? { recipients } : {}), ...(spamMode !== undefined ? { spamMode } : {}), ...(thanksPage !== undefined ? { thanksPage } : {}), ...(typeof captcha === "boolean" ? { captcha } : {}) };
550
550
  return asJson(await fetchImpl(`${baseUrl(cfg)}/forms/${encodeURIComponent(slug)}`, {
551
551
  method: "POST", headers: { ...authHeaders(token, cfg), "content-type": "application/json" }, body: JSON.stringify(body) }));
552
552
  }
@@ -599,3 +599,33 @@ export async function apiLock(cfg, { site, action = null, note = null } = {}, fe
599
599
  if (!action) return asJson(await fetchImpl(url, { headers: authHeaders(token, cfg) }));
600
600
  return asJsonOr409(await fetchImpl(url, { method: "POST", headers: { ...authHeaders(token, cfg), "content-type": "application/json" }, body: JSON.stringify({ action, note }) }));
601
601
  }
602
+
603
+ /** GET/POST /api/js-review/<site> – JS-Prüfungen (nur Betreuer-Token): ohne action Liste + Diff, sonst approve|reject. */
604
+ export async function apiJsReview(cfg, { site, path: rel = null, action = null, note = null } = {}, fetchImpl = fetch) {
605
+ const { token, slug } = resolveTarget(cfg, site);
606
+ const url = `${baseUrl(cfg)}/js-review/${encodeURIComponent(slug)}`;
607
+ if (!action) return asJson(await fetchImpl(url, { headers: authHeaders(token, cfg) }));
608
+ return asJson(await fetchImpl(url, { method: "POST", headers: { ...authHeaders(token, cfg), "content-type": "application/json" }, body: JSON.stringify({ path: rel, action, note }) }));
609
+ }
610
+
611
+ /** GET /api/inbox/<site> – Formular-Anfragen: Liste (Filter), eine Anfrage (id) oder Statistik (stats). */
612
+ export async function apiInboxGet(cfg, { site, id = null, stats = false, status = null, form = null, seit = null, bis = null, q = null, limit = null, offset = null } = {}, fetchImpl = fetch) {
613
+ const { token, slug } = resolveTarget(cfg, site);
614
+ const p = new URLSearchParams();
615
+ if (id) p.set("id", String(id));
616
+ else if (stats) p.set("stats", "1");
617
+ else {
618
+ for (const s of [].concat(status ?? [])) if (s) p.append("status", String(s));
619
+ for (const [k, v] of Object.entries({ form, seit, bis, q, limit, offset })) if (v != null && v !== "") p.set(k, String(v));
620
+ }
621
+ const qs = p.toString();
622
+ return asJson(await fetchImpl(`${baseUrl(cfg)}/inbox/${encodeURIComponent(slug)}${qs ? `?${qs}` : ""}`, { headers: authHeaders(token, cfg) }));
623
+ }
624
+ /** POST /api/inbox/<site> – Status setzen (status), in den Papierkorb (delete) oder zurückholen (restore); mehrere IDs. */
625
+ export async function apiInboxPost(cfg, { site, action, ids, status = null } = {}, fetchImpl = fetch) {
626
+ const { token, slug } = resolveTarget(cfg, site);
627
+ return asJson(await fetchImpl(`${baseUrl(cfg)}/inbox/${encodeURIComponent(slug)}`, {
628
+ method: "POST", headers: { ...authHeaders(token, cfg), "content-type": "application/json" },
629
+ body: JSON.stringify({ action, ids: [].concat(ids ?? []), ...(status ? { status } : {}) }) }));
630
+ }
631
+
package/index.mjs CHANGED
@@ -21,7 +21,7 @@ const cfg = loadConfig();
21
21
  // es im MCP-Log/Terminal auffällt; die endgültige Auflösung (Betreuer-Token-Vorrang vs. Map) klärt sites_list.
22
22
  for (const w of (cfg.warnings || [])) process.stderr.write("[prontosite-mcp] ⚠ " + w + "\n");
23
23
  // instructions: Arbeitsweise (Weg B = Standard) – gleicher Text wie am Remote-Endpunkt.
24
- const server = new McpServer({ name: "prontosite", version: "0.14.5" }, { instructions: instructionsFor("local") });
24
+ const server = new McpServer({ name: "prontosite", version: "0.14.7" }, { instructions: instructionsFor("local") });
25
25
  // Tool-Definitionen: gemeinsame Quelle mit dem Remote-Endpunkt (tools.mjs, Konzept KI-Anbindung §4.2).
26
26
  registerTools(server, { z, cfg, mode: "local" });
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prontosite/mcp",
3
- "version": "0.14.6",
3
+ "version": "0.16.0",
4
4
  "description": "MCP-Server für ProntoSite – Website-Status, Export, Deploy und Veröffentlichen direkt aus einem KI-Assistenten (Claude Desktop). Dünner Client um die ProntoSite Deploy-API.",
5
5
  "type": "module",
6
6
  "bin": {
package/tools.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  * site_domain_connect nur mit Betreuer-Token (Entscheidung 9a).
10
10
  * `z` wird vom Aufrufer übergeben (dieselbe zod-Instanz wie sein MCP-SDK).
11
11
  */
12
- import { apiEditText, apiEditHtml, apiPageTexts, apiPageTextsSet, apiPageCopy, apiMediaUploadBegin, apiMediaUploadFile, apiMediaCommit, apiMediaList, apiImageSet, apiUndo, apiMediaDelete, apiMediaRestore, apiFormsGet, apiFormsSet, apiMediaSearch, apiMediaImport, apiPreviewScreenshot, apiStatus, apiExport, apiExportFetch, apiDeploy, apiPublish, sitesList, apiAccountCreate, apiCompanyGet, apiCompanySet, apiLegalOptions, apiDomainStatus, apiDomainConnect, apiEmbeds, apiEmbedConsent, apiWidgets, apiWidgetSet, apiFiles, apiFileGet, apiFilePut, apiFileDelete, apiFileRename, apiLock } from "./api.mjs";
12
+ import { apiEditText, apiEditHtml, apiPageTexts, apiPageTextsSet, apiPageCopy, apiMediaUploadBegin, apiMediaUploadFile, apiMediaCommit, apiMediaList, apiImageSet, apiUndo, apiMediaDelete, apiMediaRestore, apiFormsGet, apiFormsSet, apiMediaSearch, apiMediaImport, apiPreviewScreenshot, apiStatus, apiExport, apiExportFetch, apiDeploy, apiPublish, sitesList, apiAccountCreate, apiCompanyGet, apiCompanySet, apiLegalOptions, apiDomainStatus, apiDomainConnect, apiEmbeds, apiEmbedConsent, apiWidgets, apiWidgetSet, apiFiles, apiFileGet, apiFilePut, apiFileDelete, apiFileRename, apiLock, apiJsReview, apiInboxGet, apiInboxPost } from "./api.mjs";
13
13
 
14
14
  /**
15
15
  * Arbeitsweise (MCP-`instructions` beim Initialize – lokal UND /mcp): Weg B ist der Standard (Nick 19.10.).
@@ -22,16 +22,25 @@ const instructionLines = mode => [
22
22
  + "Danach dem Nutzer sagen, was geändert wurde, und den Vorschau-Link (previewUrl aus site_status) nennen.",
23
23
  "1a. Website wählen: Wenn der Nutzer eine Website beim Firmennamen nennt, erst sites_list, dann den passenden Slug verwenden; bei "
24
24
  + "Unklarheit nachfragen. (Die Tools lösen Firmenname/Domain/Titel auch selbst auf; mehrdeutig → Kandidatenliste, nichts geändert.)",
25
- "1b. Rechte aus site_status.can* lesen (canRead, canEditDraft, canPublish, canDeploy, Gründe in canReasons); nicht aus anderen Feldern "
25
+ "1b. Rechte aus site_status.can* lesen (canRead, canEditDraft, canPublish, canDeploy, canInbox, canInboxWrite, Gründe in canReasons); nicht aus anderen Feldern "
26
26
  + "schließen – restApiEnabled betrifft nur die REST-API, nicht diese Tools.",
27
27
  "2. AUSNAHME: site_export + Deploy (site_deploy bzw. deploy_upload_begin/deploy_commit) NUR, wenn der Nutzer ausdrücklich lokal arbeiten "
28
28
  + "möchte oder die Website komplett umgebaut wird.",
29
29
  "2a. Für Text-, Link- und Kontaktdaten-Änderungen (Telefonnummer, E-Mail, Adresse, Öffnungszeiten) IMMER site_edit_text mit "
30
30
  + "scope:\"all\" (bei Bedarf region:\"footer\"/\"header\") – EIN Aufruf für alle Seiten, inkl. tel:/mailto:-Links. NIE Seite für Seite per "
31
31
  + "site_file_put. Meldet die Antwort company, zusätzlich site_company_set für die Firmendaten (Impressum/Datenschutz/Kontakt).",
32
+ "2e. Preise, Texte und Zahlen in Skriptdateien (.js, z. B. Preisrechner) ändert site_edit_text mit scope:\"all\" mit – ersetzt wird dort "
33
+ + "nur in Texten/Zahlen/Kommentaren, nie im Code. Nur-Zahlen/Texte-Änderungen wirken sofort; alles andere prüft der Betreuer. Meldet eine "
34
+ + "Antwort jsReview.status \"pending\" (bzw. site_status jsReview.pending), dem Nutzer sagen: Änderung gespeichert, sie wartet auf Freigabe "
35
+ + "durch den Betreuer, der Betreuer ist benachrichtigt – bis dahin läuft auf der Website der bisherige Stand. Nicht erneut versuchen und "
36
+ + "nicht umgehen (z. B. nicht ins HTML verlagern).",
32
37
  "2c. Für Markup über alle Seiten (z. B. Footer-HTML) site_edit_html mit scope:\"all\" (ggf. region:\"footer\"), nie Seite für Seite per site_file_put. "
33
38
  + "Entwurfs-Limit je Website: 300 Änderungen je Stunde, davon 30 direkt hintereinander (Kunden und Betreuer gleich) – dieselbe Änderung auf vielen Seiten ist EIN Aufruf (site_edit_text scope:\"all\" bzw. site_edit_html). Bei 429 (rateLimited) NICHT aufgeben: retryAfter Sekunden warten, dann weitermachen; bisherige Änderungen bleiben gespeichert.",
34
- "2d. Badge nie im HTML ändern, nachbauen oder entfernen – es wird beim Ausliefern erzeugt (data-ps=\"badge\"; site_edit_text/site_edit_html/site_file_put lehnen das ab, erlaubt ist nur das Wiederherstellen des Original-Markers).",
39
+ "2d. Badge „Made with ProntoSite\" NUR als dieses Snippet einbauen (Footer, letzte Zeile, bündig mit dem Copyright), nie manuell stylen: "
40
+ + "<a data-ps=\"badge\" data-ps-variant=\"auto\" href=\"https://prontosite.io/?ref=badge&amp;site=<slug>\" rel=\"noopener noreferrer\">Made with ProntoSite</a> "
41
+ + "(Variante auto | light für dunkle Footer | dark für helle Flächen | mono). Der Server ersetzt beim Ausliefern alles außer der Variante "
42
+ + "(Schrift, Größe, Farbe, Icon). Ein vorhandenes Badge nie im HTML ändern, nachbauen oder entfernen (site_edit_text/site_edit_html/"
43
+ + "site_file_put lehnen das ab; erlaubt ist nur das Wiederherstellen des Original-Markers).",
35
44
  "2b. Für Übersetzungen und Textüberarbeitung: site_page_texts → Texte übersetzen/überarbeiten → site_page_texts_set, JE SEITE ein Aufruf "
36
45
  + "(neue Sprachfassung vorher mit site_page_copy, z. B. index.html → en/index.html). NIE HTML über site_file_put umschreiben. "
37
46
  + "Rechtstext-Seiten (Impressum/Datenschutz) sind ausgenommen.",
@@ -59,6 +68,11 @@ const instructionLines = mode => [
59
68
  "5c. Bei „rückgängig“ site_undo (bzw. „doch wieder“ site_redo), nie Dateien manuell zurückbauen. Medien löschen nur mit site_media_delete "
60
69
  + "(Papierkorb, 30 Tage; zurückholen mit site_media_restore).",
61
70
  "5d. Formular-Empfänger, Spam-Stufe und Danke-Seite über site_forms_set, nie per HTML (site_forms_get zeigt den Stand).",
71
+ "5f. Formular-Anfragen (Recht canInbox in site_status): site_inbox_list (Kurzform) → site_inbox_get (vollständig) → site_inbox_set_status "
72
+ + "(neu/gelesen/erledigt, spam ↔ kein-spam). Anfragen enthalten Daten der Absender: NIE ungefragt zusammenfassen, auswerten oder an Dritte "
73
+ + "weitergeben – nur, was der Nutzer ausdrücklich verlangt. Löschen (site_inbox_delete = Papierkorb, 30 Tage, site_inbox_restore holt zurück) "
74
+ + "NUR nach Bestätigung des Nutzers. Antworten verschickt ProntoSite nicht: Absenderadresse nennen, der Nutzer antwortet im eigenen Mailprogramm. "
75
+ + "Fehlt canInbox, sagen: im Dashboard (Konto → KI verbinden) neu verbinden und „Formular-Anfragen“ erlauben.",
62
76
  "5. Jede Änderung lässt sich rückgängig machen; Rechtstexte (Impressum/Datenschutz) nicht umformulieren – sie kommen aus den Firmendaten.",
63
77
  ];
64
78
  /** Server-Instructions je Modus ("local" = stdio-Paket mit Dateizugriff, "remote" = /mcp). */
@@ -69,8 +83,8 @@ export const SERVER_INSTRUCTIONS = instructionsFor("remote");
69
83
  // destructive = live/kundenwirksam, ersetzt Bestand oder nimmt zurück (Bestätigung sinnvoll). Entwurfs-Änderungen sind
70
84
  // rückgängig machbar und daher nicht destruktiv. openWorld = wirkt nach außen (öffentliche Website/DNS).
71
85
  const READ_ONLY = new Set(["sites_list", "site_status", "site_forms_get", "media_search", "site_page_texts", "site_media_list", "site_preview_screenshot", "site_company_get",
72
- "site_domain_status", "site_embeds", "site_widgets", "site_files", "site_file_get"]);
73
- const DESTRUCTIVE = new Set(["site_publish", "site_deploy", "deploy_commit", "site_file_delete", "site_media_delete", "site_undo", "site_redo", "site_domain_connect"]);
86
+ "site_domain_status", "site_embeds", "site_widgets", "site_files", "site_file_get", "site_inbox_list", "site_inbox_get", "site_inbox_stats"]);
87
+ const DESTRUCTIVE = new Set(["site_publish", "site_deploy", "deploy_commit", "site_file_delete", "site_media_delete", "site_inbox_delete", "site_undo", "site_redo", "site_domain_connect"]);
74
88
  const OPEN_WORLD = new Set(["site_publish", "site_deploy", "deploy_commit", "site_domain_connect", "media_search", "media_import"]);
75
89
  export function toolAnnotations(name, mode = "local") {
76
90
  // site_export: remote nur ein Download-Link (lesend), lokal schreibt es eine ZIP auf die Platte.
@@ -188,6 +202,8 @@ export function registerTools(server0, { z, cfg, mode = "local", remote = null }
188
202
  + "meta content) ALLER Seiten und ersetzt durch `replace` – EIN Aufruf, EIN Rückgängig-Schritt, Formatierung bleibt. "
189
203
  + "scope:\"all\" = alle Fundstellen auf allen Seiten sofort ersetzen (für Kontaktdaten im Kopf-/Fußbereich: plus region:\"footer\" bzw. "
190
204
  + "\"header\"); ohne scope ändert der Server bei mehr als einer Fundstelle NICHTS und liefert nur die Liste (oder pages:[…]). "
205
+ + "Mit scope:\"all\" auch in Skriptdateien (.js, z. B. Preisrechner) – dort nur in Texten/Zahlen/Kommentaren (skripte = Fundstellen); "
206
+ + "jsReview.status \"pending\" = wartet auf Freigabe durch den Betreuer (dem Nutzer sagen). "
191
207
  + "Telefonnummern werden unabhängig von Schreibweise gefunden (Leerzeichen, „/“, „-“, Klammern, geschütztes Leerzeichen); ersetzt "
192
208
  + "wird wörtlich durch `replace`. Antwort: treffer = getrennt nach text/linktext/linkziel/attribut; linktextNichtGefunden = tel:-Ziel "
193
209
  + "geändert, sichtbarer Linktext aber nicht (dem Nutzer sagen). pages = je Seite gefunden/geändert. Rechtstexte und geschützte Bereiche bleiben unberührt (skipped); steht der Wert in "
@@ -323,16 +339,20 @@ export function registerTools(server0, { z, cfg, mode = "local", remote = null }
323
339
  "site_image_set",
324
340
  "SCHREIBEND (Entwurf): setzt ein Bild auf einer Seite ein (ein Rückgängig-Schritt). Ziel: nodeId des Bild-Knotens oder marker (id- bzw. "
325
341
  + "data-ps-Wert eines Elements; dessen Bild wird getauscht). mediaId aus media_upload_commit oder site_media_list. alt = Bildbeschreibung "
326
- + "(für Barrierefreiheit/SEO immer setzen). Danach site_preview_screenshot prüfen.",
342
+ + "(für Barrierefreiheit/SEO immer setzen). zoom:true = Bild per Klick vergrößern (Lightbox mit Bildunterschrift aus alt/figcaption; "
343
+ + "Bilder in einem Container mit class=\"ps-gallery\" blättern als Galerie), zoom:false schaltet es ab; zoom geht auch ohne mediaId. "
344
+ + "Danach site_preview_screenshot prüfen.",
327
345
  { site: z.string().optional().describe(SITE_DESC), page: z.string().describe("Seite (ID, Datei oder Slug)."),
328
- nodeId: z.string().optional(), marker: z.string().optional(), mediaId: z.string(), alt: z.string().optional(),
346
+ nodeId: z.string().optional(), marker: z.string().optional(), mediaId: z.string().optional(), alt: z.string().optional(),
347
+ zoom: z.boolean().optional().describe("true = per Klick vergrößern (Lightbox), false = aus."),
329
348
  baseRev: z.number().int().optional().describe("Optional: draftRev (Konfliktschutz).") },
330
349
  async (a) => { try { return asText(await apiImageSet(cfg, a)); } catch (e) { return asError(e); } }
331
350
  );
332
351
  server.tool(
333
352
  "site_forms_get",
334
353
  "LESEND: Formulare der Website – Konto-Empfänger (defaultRecipients), je Formular: Art (verwaltetes ProntoSite-Formular/Marker "
335
- + "oder eigenes <form> aus dem Import), Seiten, Felder, eigene Empfänger (null = Konto-Empfänger), Danke-Seite; Spam-Modus "
354
+ + "oder eigenes <form> aus dem Import), Seiten, Felder, eigene Empfänger (null = Konto-Empfänger), Danke-Seite, Sicherheitscode "
355
+ + "(captcha, captchaLive = schon ausgeliefert), abgewehrte Sendungen der letzten 30 Tage (spamAbgewehrt); Spam-Modus "
336
356
  + "(wirksam, Kunde, Admin-Übersteuerung) und die möglichen Danke-Seiten.",
337
357
  { site: z.string().optional().describe(SITE_DESC) },
338
358
  async ({ site }) => { try { return asText(await apiFormsGet(cfg, { site })); } catch (e) { return asError(e); } }
@@ -344,14 +364,69 @@ export function registerTools(server0, { z, cfg, mode = "local", remote = null }
344
364
  + "thanksPage: interne Seite für die Weiterleitung nach dem Absenden ('' = Meldung am Formular), nur mit form (außer es gibt genau "
345
365
  + "eins). NEUE Empfänger-Adressen werden erst aktiv, wenn sie den Bestätigungslink in der Mail an sie anklicken (24 h) – bis dahin "
346
366
  + "bleiben die bisherigen aktiv (Antwort: ausstehend). Die bisherigen Adressen bekommen einen Hinweis mit Zurücksetzen-Link. Vorher "
347
- + "beim Nutzer bestätigen, wohin Anfragen künftig gehen.",
367
+ + "beim Nutzer bestätigen, wohin Anfragen künftig gehen. captcha: true/false = Sicherheitscode (Bild mit 5 Zeichen) je Formular, "
368
+ + "nur mit form (außer es gibt genau eins); erscheint mit dem nächsten Veröffentlichen. Nur einschalten, wenn trotz Spam-Schutz Spam "
369
+ + "durchkommt, und den Nutzer darauf hinweisen, dass ein Bild-Code für blinde/sehbehinderte Menschen eine Hürde ist.",
348
370
  { site: z.string().optional().describe(SITE_DESC),
349
371
  form: z.string().optional().describe("Formular-ID aus site_forms_get (für Empfänger/Danke-Seite je Formular)."),
350
372
  recipients: z.array(z.string()).optional().describe("E-Mail-Adressen (max. 10)."),
351
373
  spamMode: z.enum(["streng", "normal", "basis"]).optional(),
352
- thanksPage: z.string().optional().describe("Interner Pfad einer Seite dieser Website, z. B. /danke/ (keine externen URLs).") },
374
+ thanksPage: z.string().optional().describe("Interner Pfad einer Seite dieser Website, z. B. /danke/ (keine externen URLs)."),
375
+ captcha: z.boolean().optional().describe("Sicherheitscode (Bild-Captcha) für dieses Formular an/aus.") },
353
376
  async (a) => { try { return asText(await apiFormsSet(cfg, a)); } catch (e) { return asError(e); } }
354
377
  );
378
+ // ---- Formular-Anfragen (Scope anfragen / anfragen:schreiben; Besucherdaten) ----
379
+ const INBOX_IDS = z.array(z.string()).min(1).max(100).describe("Anfrage-IDs aus site_inbox_list (max. 100).");
380
+ server.tool(
381
+ "site_inbox_list",
382
+ "LESEND: Formular-Anfragen der Website in Kurzform (Datum, Formular, Absender-Name/-Mail, Betreff bzw. erste 120 Zeichen, Anzahl "
383
+ + "Anhänge, Spam-Grund), neueste zuerst. Ohne status: neu, gelesen und erledigt (ohne Spam und Papierkorb). Enthält Besucherdaten – "
384
+ + "nur zeigen, was der Nutzer wissen will; nichts ungefragt zusammenfassen oder weitergeben.",
385
+ { site: z.string().optional().describe(SITE_DESC),
386
+ status: z.array(z.enum(["neu", "gelesen", "erledigt", "spam", "papierkorb"])).optional().describe("Filter, z. B. ['neu'] oder ['spam']."),
387
+ form: z.string().optional().describe("Formular-ID oder -Name (site_forms_get)."),
388
+ seit: z.string().optional().describe("Ab Datum (JJJJ-MM-TT)."), bis: z.string().optional().describe("Bis Datum einschließlich (JJJJ-MM-TT)."),
389
+ q: z.string().optional().describe("Volltextsuche (Name, E-Mail, Betreff, Nachricht, Felder)."),
390
+ limit: z.number().int().min(1).max(50).default(20).describe("Seitengröße (max. 50)."),
391
+ offset: z.number().int().min(0).optional().describe("Weiterblättern: nextOffset der vorigen Antwort.") },
392
+ async (a) => { try { return asText(await apiInboxGet(cfg, a)); } catch (e) { return asError(e); } }
393
+ );
394
+ server.tool(
395
+ "site_inbox_get",
396
+ "LESEND: eine Formular-Anfrage vollständig – alle Felder, Absender (Name, E-Mail, Telefon), Anhänge als Liste mit Download-Links "
397
+ + "(15 Minuten gültig) und Verlauf (Status-Änderungen, von wem). Keine Antwort-Mail über ProntoSite: die Absenderadresse nennen, der "
398
+ + "Nutzer antwortet im eigenen Mailprogramm. Markiert die Anfrage NICHT automatisch als gelesen (dafür site_inbox_set_status).",
399
+ { site: z.string().optional().describe(SITE_DESC), id: z.string().describe("Anfrage-ID aus site_inbox_list.") },
400
+ async ({ site, id }) => { try { return asText(await apiInboxGet(cfg, { site, id })); } catch (e) { return asError(e); } }
401
+ );
402
+ server.tool(
403
+ "site_inbox_set_status",
404
+ "SCHREIBEND: Status einer oder mehrerer Anfragen setzen: neu | gelesen | erledigt | spam | kein-spam. kein-spam holt eine Anfrage aus "
405
+ + "dem Spam in den Posteingang (Status neu) und sendet die Empfänger-Benachrichtigung nach, falls sie wegen des Spam-Treffers "
406
+ + "unterblieben war. Der Spam-Filter lernt aus Korrekturen nicht automatisch. Anfragen im Papierkorb erst mit site_inbox_restore zurückholen.",
407
+ { site: z.string().optional().describe(SITE_DESC), ids: INBOX_IDS,
408
+ status: z.enum(["neu", "gelesen", "erledigt", "spam", "kein-spam"]) },
409
+ async ({ site, ids, status }) => { try { return asText(await apiInboxPost(cfg, { site, action: "status", ids, status })); } catch (e) { return asError(e); } }
410
+ );
411
+ server.tool(
412
+ "site_inbox_delete",
413
+ "SCHREIBEND: verschiebt Anfragen in den PAPIERKORB – 30 Tage wiederherstellbar (site_inbox_restore), danach endgültig gelöscht. "
414
+ + "Endgültig löschen geht nur im Dashboard. NUR nach ausdrücklicher Bestätigung des Nutzers (welche Anfragen, wie viele).",
415
+ { site: z.string().optional().describe(SITE_DESC), ids: INBOX_IDS },
416
+ async ({ site, ids }) => { try { return asText(await apiInboxPost(cfg, { site, action: "delete", ids })); } catch (e) { return asError(e); } }
417
+ );
418
+ server.tool(
419
+ "site_inbox_restore",
420
+ "SCHREIBEND: holt Anfragen aus dem Papierkorb zurück (vorheriger Status bzw. Spam bleibt erhalten). IDs aus site_inbox_list mit status ['papierkorb'].",
421
+ { site: z.string().optional().describe(SITE_DESC), ids: INBOX_IDS },
422
+ async ({ site, ids }) => { try { return asText(await apiInboxPost(cfg, { site, action: "restore", ids })); } catch (e) { return asError(e); } }
423
+ );
424
+ server.tool(
425
+ "site_inbox_stats",
426
+ "LESEND: Statistik der Formular-Anfragen – Anzahl je Status und je Formular, Eingänge der letzten 30 und 90 Tage, Spam-Quote. Ohne Inhalte.",
427
+ { site: z.string().optional().describe(SITE_DESC) },
428
+ async ({ site }) => { try { return asText(await apiInboxGet(cfg, { site, stats: true })); } catch (e) { return asError(e); } }
429
+ );
355
430
  server.tool(
356
431
  "site_undo",
357
432
  "SCHREIBEND (Entwurf): macht die letzte(n) Änderung(en) rückgängig – derselbe Verlauf wie der Rückgängig-Knopf im Dashboard (auch "
@@ -553,6 +628,21 @@ export function registerTools(server0, { z, cfg, mode = "local", remote = null }
553
628
  }
554
629
  );
555
630
  }
631
+ // JS-Prüfungen entscheiden (nur Betreuer-Token; der Server prüft die Rolle zusätzlich).
632
+ if (mode === "local" || remote?.isManager) {
633
+ server.tool(
634
+ "site_js_review",
635
+ "NUR BETREUER-TOKEN: ausstehende Prüfungen von Skript-Änderungen (JS) einer Website. Ohne action: Liste mit Gründen und Zeilen-Diff "
636
+ + "(freigegebener Stand → Entwurf; lesend). action='approve' gibt die Änderung frei (danach normaler Publish bzw. mit der nächsten "
637
+ + "Veröffentlichung), action='reject' setzt die Datei auf den freigegebenen Stand zurück (note = Grund, sieht der Kunde). Nur freigeben, "
638
+ + "was im Diff verstanden ist; im Zweifel ablehnen oder den Nutzer fragen.",
639
+ { site: z.string().optional().describe(SITE_DESC),
640
+ path: z.string().optional().describe("Datei (z. B. 'js/rechner.js') – für approve/reject Pflicht."),
641
+ action: z.enum(["approve", "reject"]).optional().describe("Leer = nur ansehen."),
642
+ note: z.string().optional().describe("Notiz/Grund (bei reject sinnvoll).") },
643
+ async ({ site, path, action, note }) => { try { return asText(await apiJsReview(cfg, { site, path, action, note })); } catch (e) { return asError(e); } }
644
+ );
645
+ }
556
646
  server.tool(
557
647
  "site_embeds",
558
648
  "LESEND: erkannte eingebettete Inhalte Dritter (iframes) einer Website – je Embed Host, Seite(n) und "
@@ -611,7 +701,9 @@ export function registerTools(server0, { z, cfg, mode = "local", remote = null }
611
701
  // ---- Datei-API: kleine Änderungen am Entwurf direkt auf dem Server (Konzept §7) ----
612
702
  const FILE_SMALL = "STANDARDWEG: Änderungen direkt am Server-Entwurf (für reine Textänderungen am einfachsten site_edit_text); "
613
703
  + "Export + Deploy nur als Ausnahme für größere Umbauten oder auf ausdrücklichen Wunsch. "
614
- + "Skriptdateien (JavaScript) ändern nur Betreuer und nur bei JavaScript-Freigabe der Website – Kunden bekommen dort 403. ";
704
+ + "Skriptdateien (JavaScript) nur bei JavaScript-Freigabe der Website; Kunden-Änderungen daran prüft der Server: nur Zahlen/Texte/"
705
+ + "Kommentare → wirkt sofort, sonst gespeichert, aber erst nach Freigabe durch den Betreuer live (Antwort jsReview.status \"pending\" – "
706
+ + "dem Nutzer so sagen). Preise/Texte in Skripten besser mit site_edit_text scope:\"all\". ";
615
707
  server.tool(
616
708
  "site_files",
617
709
  "LESEND: Listet die Dateien des Entwurfs einer Website (Pfad, Größe, Typ, ob bearbeitbar) und die aktuelle draftRev. "