@prontosite/mcp 0.11.1 → 0.11.2

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,14 @@
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.11.2 – 2026-10-19
7
+
8
+ - **Intern, kein Verhaltenswechsel:** die Tool-Definitionen liegen jetzt in `tools.mjs` – dieselbe Quelle nutzt der neue
9
+ Remote-Endpunkt `https://app.prontosite.io/mcp` (Streamable HTTP, Bearer-Token; für Cursor und Claude Code ohne lokale
10
+ Installation). Lokal bleiben `site_export`/`site_deploy` mit Dateipfaden; remote heißen sie Download-Link bzw.
11
+ `deploy_upload_begin` + `deploy_commit`.
12
+ - MCP-SDK mindestens 1.31.0 (Sicherheitshinweis GHSA-6qxp-vccf-f47h betrifft nur die OAuth-Client-Seite, hier ungenutzt).
13
+
6
14
  ## 0.11.1 – 2026-10-18
7
15
 
8
16
  - **Sanfte Sperre:** neue Tools `site_lock` (Notiz optional, `takeover` nur Betreuer) und `site_unlock`. Zeigt allen anderen
package/api.mjs CHANGED
@@ -91,11 +91,13 @@ export function resolveTarget(cfg, site) {
91
91
  return { token: needToken(cfg.token, want), slug: want };
92
92
  }
93
93
 
94
- const baseUrl = cfg => `https://${cfg.host}/api`;
94
+ // cfg.apiBase: Remote-Endpunkt (/mcp) ruft die REST-API über den internen Loopback auf (http://127.0.0.1:<port>/api).
95
+ const baseUrl = cfg => cfg.apiBase || `https://${cfg.host}/api`;
95
96
  // Client-Kennung: der Server führt damit im Verlauf je Site den Weg „mcp" (statt „api" für curl).
96
97
  let CLIENT_ID = "mcp";
97
98
  try { CLIENT_ID = "mcp/" + JSON.parse(fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")).version; } catch { /* egal */ }
98
- const authHeaders = token => ({ authorization: `Bearer ${token}`, "x-prontosite-client": CLIENT_ID });
99
+ // Remote: cfg.clientId (Weg/Client-Name) + cfg.extraHeaders (interne Loopback-Kennung, echte Client-IP).
100
+ const authHeaders = (token, cfg = {}) => ({ authorization: `Bearer ${token}`, "x-prontosite-client": cfg.clientId || CLIENT_ID, ...(cfg.extraHeaders || {}) });
99
101
 
100
102
  /**
101
103
  * Server-Fehler → VERSTÄNDLICHE Meldung für den Chat. Zentral für ALLE Tools (über asJson). Übersetzt die
@@ -128,19 +130,24 @@ async function asJson(res) {
128
130
  /** GET /api/status/<site> – Status der Site (Plan, Slug, Live-URL, Publish-Bedarf, letzte Änderung). */
129
131
  export async function apiStatus(cfg, { site } = {}, fetchImpl = fetch) {
130
132
  const { token, slug } = resolveTarget(cfg, site);
131
- const res = await fetchImpl(`${baseUrl(cfg)}/status/${encodeURIComponent(slug)}`, { headers: authHeaders(token) });
133
+ const res = await fetchImpl(`${baseUrl(cfg)}/status/${encodeURIComponent(slug)}`, { headers: authHeaders(token, cfg) });
132
134
  return asJson(res);
133
135
  }
134
136
 
135
- /** GET /api/export/<site>?which= – ZIP in einen lokalen Ordner speichern. */
136
- export async function apiExport(cfg, { site, which = "published", outDir = "." } = {}, fetchImpl = fetch) {
137
+ /** GET /api/export/<site>?which= – ZIP holen (Puffer + Dateiname). Remote macht daraus einen Download-Link. */
138
+ export async function apiExportFetch(cfg, { site, which = "published" } = {}, fetchImpl = fetch) {
137
139
  const { token, slug } = resolveTarget(cfg, site);
138
- const res = await fetchImpl(`${baseUrl(cfg)}/export/${encodeURIComponent(slug)}?which=${encodeURIComponent(which)}`, { headers: authHeaders(token) });
139
- if (!res.ok) return asJson(res); // Fehler kommen als JSON
140
+ const res = await fetchImpl(`${baseUrl(cfg)}/export/${encodeURIComponent(slug)}?which=${encodeURIComponent(which)}`, { headers: authHeaders(token, cfg) });
141
+ if (!res.ok) { await asJson(res); throw new Error(`HTTP ${res.status}`); } // Fehler kommen als JSON → Exception
140
142
  const buf = Buffer.from(await res.arrayBuffer());
141
143
  const cd = res.headers.get("content-disposition") || "";
142
144
  const m = /filename="?([^"]+)"?/.exec(cd);
143
- const name = (m && m[1]) || `prontosite-${slug}-${which}.zip`;
145
+ return { buf, slug, name: (m && m[1]) || `prontosite-${slug}-${which}.zip` };
146
+ }
147
+
148
+ /** GET /api/export/<site>?which= – ZIP in einen lokalen Ordner speichern. */
149
+ export async function apiExport(cfg, { site, which = "published", outDir = "." } = {}, fetchImpl = fetch) {
150
+ const { buf, slug, name } = await apiExportFetch(cfg, { site, which }, fetchImpl);
144
151
  fs.mkdirSync(outDir, { recursive: true });
145
152
  const outPath = path.join(outDir, name.replace(/[^a-zA-Z0-9._-]+/g, "_"));
146
153
  fs.writeFileSync(outPath, buf);
@@ -168,7 +175,7 @@ export async function apiDeploy(cfg, { site, customer = null, slug: wishSlug = n
168
175
  if (isNew && name) q.set("name", String(name)); // Erst-Import: Site-Name explizit setzen (sonst aus der Startseite abgeleitet)
169
176
  const qs = q.toString() ? `?${q}` : "";
170
177
  const res = await fetchImpl(`${baseUrl(cfg)}/deploy/${encodeURIComponent(slug)}${qs}`, {
171
- method: "POST", headers: { ...authHeaders(token), "content-type": "application/zip" }, body: fs.readFileSync(zipPath) });
178
+ method: "POST", headers: { ...authHeaders(token, cfg), "content-type": "application/zip" }, body: fs.readFileSync(zipPath) });
172
179
  const out = await asJson(res);
173
180
  // ERST-Import + Firmendaten: jetzt auf die ERSTELLTE Website setzen (out.slug/out.site). Hinweis: die beim
174
181
  // Erst-Import automatisch erzeugten Rechtstexte kennen diese Daten noch nicht – ein erneuter Rechts-Lauf/
@@ -197,7 +204,7 @@ export async function apiAccountCreate(cfg, { name, email = null, plan = null, n
197
204
  if (note) body.note = String(note).trim();
198
205
  if (override === true) body.override = true;
199
206
  const res = await fetchImpl(`${baseUrl(cfg)}/account`, {
200
- method: "POST", headers: { ...authHeaders(token), "content-type": "application/json" }, body: JSON.stringify(body) });
207
+ method: "POST", headers: { ...authHeaders(token, cfg), "content-type": "application/json" }, body: JSON.stringify(body) });
201
208
  return asJson(res);
202
209
  }
203
210
 
@@ -206,14 +213,14 @@ export async function apiAccountCreate(cfg, { name, email = null, plan = null, n
206
213
  export async function apiPublish(cfg, { site, force = false } = {}, fetchImpl = fetch) {
207
214
  const { token, slug } = resolveTarget(cfg, site);
208
215
  const qs = force ? "?force=1" : "";
209
- const res = await fetchImpl(`${baseUrl(cfg)}/publish/${encodeURIComponent(slug)}${qs}`, { method: "POST", headers: authHeaders(token) });
216
+ const res = await fetchImpl(`${baseUrl(cfg)}/publish/${encodeURIComponent(slug)}${qs}`, { method: "POST", headers: authHeaders(token, cfg) });
210
217
  return asJson(res);
211
218
  }
212
219
 
213
220
  /** GET /api/company/<site> – konto-weite Firmendaten (effektiv + account_company + siteOverride-Flag). */
214
221
  export async function apiCompanyGet(cfg, { site } = {}, fetchImpl = fetch) {
215
222
  const { token, slug } = resolveTarget(cfg, site);
216
- const res = await fetchImpl(`${baseUrl(cfg)}/company/${encodeURIComponent(slug)}`, { headers: authHeaders(token) });
223
+ const res = await fetchImpl(`${baseUrl(cfg)}/company/${encodeURIComponent(slug)}`, { headers: authHeaders(token, cfg) });
217
224
  return asJson(res);
218
225
  }
219
226
 
@@ -223,7 +230,7 @@ export async function apiCompanySet(cfg, { site, company, siteOverride = false }
223
230
  if (!company || typeof company !== "object") throw new Error("company-Objekt erwartet.");
224
231
  const { token, slug } = resolveTarget(cfg, site);
225
232
  const res = await fetchImpl(`${baseUrl(cfg)}/company/${encodeURIComponent(slug)}`, {
226
- method: "PUT", headers: { ...authHeaders(token), "content-type": "application/json" },
233
+ method: "PUT", headers: { ...authHeaders(token, cfg), "content-type": "application/json" },
227
234
  body: JSON.stringify({ company, siteOverride: !!siteOverride }) });
228
235
  return asJson(res);
229
236
  }
@@ -233,7 +240,7 @@ export async function apiCompanySet(cfg, { site, company, siteOverride = false }
233
240
  export async function apiDomainStatus(cfg, { site, check } = {}, fetchImpl = fetch) {
234
241
  const { token, slug } = resolveTarget(cfg, site);
235
242
  const qs = check ? `?check=${encodeURIComponent(check)}` : "";
236
- const res = await fetchImpl(`${baseUrl(cfg)}/domain/${encodeURIComponent(slug)}${qs}`, { headers: authHeaders(token) });
243
+ const res = await fetchImpl(`${baseUrl(cfg)}/domain/${encodeURIComponent(slug)}${qs}`, { headers: authHeaders(token, cfg) });
237
244
  return asJson(res);
238
245
  }
239
246
 
@@ -244,7 +251,7 @@ export async function apiDomainConnect(cfg, { site, domain, canonical = "www", a
244
251
  if (!domain) throw new Error("domain erwartet.");
245
252
  const { token, slug } = resolveTarget(cfg, site);
246
253
  const res = await fetchImpl(`${baseUrl(cfg)}/domain/${encodeURIComponent(slug)}`, {
247
- method: "POST", headers: { ...authHeaders(token), "content-type": "application/json" },
254
+ method: "POST", headers: { ...authHeaders(token, cfg), "content-type": "application/json" },
248
255
  body: JSON.stringify({ domain, canonical, action }) });
249
256
  return asJson(res);
250
257
  }
@@ -252,7 +259,7 @@ export async function apiDomainConnect(cfg, { site, domain, canonical = "www", a
252
259
  /** GET /api/embeds/<site> – erkannte Embeds (Host, Seite(n), aktuelle Kategorie gated|notwendig). LESEND. */
253
260
  export async function apiEmbeds(cfg, { site } = {}, fetchImpl = fetch) {
254
261
  const { token, slug } = resolveTarget(cfg, site);
255
- const res = await fetchImpl(`${baseUrl(cfg)}/embeds/${encodeURIComponent(slug)}`, { headers: authHeaders(token) });
262
+ const res = await fetchImpl(`${baseUrl(cfg)}/embeds/${encodeURIComponent(slug)}`, { headers: authHeaders(token, cfg) });
256
263
  return asJson(res);
257
264
  }
258
265
 
@@ -263,7 +270,7 @@ export async function apiEmbedConsent(cfg, { site, host, page, category = "notwe
263
270
  if (!host) throw new Error("host erwartet.");
264
271
  const { token, slug } = resolveTarget(cfg, site);
265
272
  const res = await fetchImpl(`${baseUrl(cfg)}/embeds/${encodeURIComponent(slug)}`, {
266
- method: "PUT", headers: { ...authHeaders(token), "content-type": "application/json" },
273
+ method: "PUT", headers: { ...authHeaders(token, cfg), "content-type": "application/json" },
267
274
  body: JSON.stringify({ host, page: page || null, category, confirmed: !!confirmed }) });
268
275
  return asJson(res);
269
276
  }
@@ -271,7 +278,7 @@ export async function apiEmbedConsent(cfg, { site, host, page, category = "notwe
271
278
  /** GET /api/widgets/<site> – Katalog aller verwalteten Widgets (name, aktiv, optionen, schema). Lesend. */
272
279
  export async function apiWidgets(cfg, { site } = {}, fetchImpl = fetch) {
273
280
  const { token, slug } = resolveTarget(cfg, site);
274
- const res = await fetchImpl(`${baseUrl(cfg)}/widgets/${encodeURIComponent(slug)}`, { headers: authHeaders(token) });
281
+ const res = await fetchImpl(`${baseUrl(cfg)}/widgets/${encodeURIComponent(slug)}`, { headers: authHeaders(token, cfg) });
275
282
  return asJson(res);
276
283
  }
277
284
  /** PUT /api/widgets/<site>/<widget> {aktiv, optionen} – Widget setzen (gleicher Schema-Sanitizer wie das Dashboard). */
@@ -282,7 +289,7 @@ export async function apiWidgetSet(cfg, { site, widget, aktiv, optionen } = {},
282
289
  if (aktiv !== undefined) body.aktiv = !!aktiv;
283
290
  if (optionen !== undefined) body.optionen = optionen;
284
291
  const res = await fetchImpl(`${baseUrl(cfg)}/widgets/${encodeURIComponent(slug)}/${encodeURIComponent(widget)}`, {
285
- method: "PUT", headers: { ...authHeaders(token), "content-type": "application/json" }, body: JSON.stringify(body) });
292
+ method: "PUT", headers: { ...authHeaders(token, cfg), "content-type": "application/json" }, body: JSON.stringify(body) });
286
293
  return asJson(res);
287
294
  }
288
295
 
@@ -292,12 +299,12 @@ export async function apiLegalOptions(cfg, { site, impressum, datenschutz } = {}
292
299
  const { token, slug } = resolveTarget(cfg, site);
293
300
  const endpoint = `${baseUrl(cfg)}/legal-options/${encodeURIComponent(slug)}`;
294
301
  if (impressum === undefined && datenschutz === undefined) {
295
- return asJson(await fetchImpl(endpoint, { headers: authHeaders(token) }));
302
+ return asJson(await fetchImpl(endpoint, { headers: authHeaders(token, cfg) }));
296
303
  }
297
304
  const body = {};
298
305
  if (impressum !== undefined) body.impressum = impressum;
299
306
  if (datenschutz !== undefined) body.datenschutz = datenschutz;
300
- const res = await fetchImpl(endpoint, { method: "PUT", headers: { ...authHeaders(token), "content-type": "application/json" }, body: JSON.stringify(body) });
307
+ const res = await fetchImpl(endpoint, { method: "PUT", headers: { ...authHeaders(token, cfg), "content-type": "application/json" }, body: JSON.stringify(body) });
301
308
  return asJson(res);
302
309
  }
303
310
 
@@ -305,7 +312,7 @@ export async function apiLegalOptions(cfg, { site, impressum, datenschutz } = {}
305
312
  * jeder Eintrag per Status abgefragt; sonst die eine konfigurierte Site. Fehler je Site werden mitgeführt. */
306
313
  // GET /api/sites mit EINEM Token → normalisierte Liste (Betreuer-Token: alle betreuten Kunden).
307
314
  async function apiSitesWithToken(cfg, token, fetchImpl) {
308
- const body = await asJson(await fetchImpl(`${baseUrl(cfg)}/sites`, { headers: authHeaders(token) }));
315
+ const body = await asJson(await fetchImpl(`${baseUrl(cfg)}/sites`, { headers: authHeaders(token, cfg) }));
309
316
  const sites = (body.sites || []).map(s => ({ label: s.slug || s.site || s.customer, slug: s.slug || null, id: s.site || null,
310
317
  name: s.name || null, plan: s.plan, everPublished: s.everPublished, publishNeeded: s.publishNeeded,
311
318
  lastChangeAt: s.lastChangeAt || null, liveUrl: s.liveUrl || null, customer: s.customer,
@@ -353,36 +360,36 @@ async function asJsonOr409(res) {
353
360
  /** GET /api/files/<site> – Dateiliste des Entwurfs (Pfad, Größe, Typ, bearbeitbar) + draftRev. */
354
361
  export async function apiFiles(cfg, { site } = {}, fetchImpl = fetch) {
355
362
  const { token, slug } = resolveTarget(cfg, site);
356
- return asJson(await fetchImpl(`${baseUrl(cfg)}/files/${encodeURIComponent(slug)}`, { headers: authHeaders(token) }));
363
+ return asJson(await fetchImpl(`${baseUrl(cfg)}/files/${encodeURIComponent(slug)}`, { headers: authHeaders(token, cfg) }));
357
364
  }
358
365
  /** GET /api/file/<site>?path= – Quelltext einer Datei (Text bzw. Base64) + draftRev + Overlay-Hinweis. */
359
366
  export async function apiFileGet(cfg, { site, path: rel } = {}, fetchImpl = fetch) {
360
367
  const { token, slug } = resolveTarget(cfg, site);
361
- return asJson(await fetchImpl(`${baseUrl(cfg)}/file/${encodeURIComponent(slug)}?path=${encodeURIComponent(rel)}`, { headers: authHeaders(token) }));
368
+ return asJson(await fetchImpl(`${baseUrl(cfg)}/file/${encodeURIComponent(slug)}?path=${encodeURIComponent(rel)}`, { headers: authHeaders(token, cfg) }));
362
369
  }
363
370
  /** PUT /api/file/<site>?path= – Text-Inhalt ersetzen (Entwurf). baseRev Pflicht. */
364
371
  export async function apiFilePut(cfg, { site, path: rel, content, baseRev, overlay = "keep" } = {}, fetchImpl = fetch) {
365
372
  const { token, slug } = resolveTarget(cfg, site);
366
373
  return asJsonOr409(await fetchImpl(`${baseUrl(cfg)}/file/${encodeURIComponent(slug)}?path=${encodeURIComponent(rel)}`, {
367
- method: "PUT", headers: { ...authHeaders(token), "content-type": "application/json" }, body: JSON.stringify({ content, baseRev, overlay }) }));
374
+ method: "PUT", headers: { ...authHeaders(token, cfg), "content-type": "application/json" }, body: JSON.stringify({ content, baseRev, overlay }) }));
368
375
  }
369
376
  /** POST /api/file/<site>/delete – Datei im Entwurf als gelöscht markieren (undo-fähig). */
370
377
  export async function apiFileDelete(cfg, { site, path: rel, baseRev, confirmDestructive = false } = {}, fetchImpl = fetch) {
371
378
  const { token, slug } = resolveTarget(cfg, site);
372
379
  return asJsonOr409(await fetchImpl(`${baseUrl(cfg)}/file/${encodeURIComponent(slug)}/delete`, {
373
- method: "POST", headers: { ...authHeaders(token), "content-type": "application/json" }, body: JSON.stringify({ path: rel, baseRev, confirmDestructive }) }));
380
+ method: "POST", headers: { ...authHeaders(token, cfg), "content-type": "application/json" }, body: JSON.stringify({ path: rel, baseRev, confirmDestructive }) }));
374
381
  }
375
382
  /** POST /api/file/<site>/rename – Datei umbenennen (Verweise werden NICHT umgeschrieben). */
376
383
  export async function apiFileRename(cfg, { site, from, to, baseRev } = {}, fetchImpl = fetch) {
377
384
  const { token, slug } = resolveTarget(cfg, site);
378
385
  return asJsonOr409(await fetchImpl(`${baseUrl(cfg)}/file/${encodeURIComponent(slug)}/rename`, {
379
- method: "POST", headers: { ...authHeaders(token), "content-type": "application/json" }, body: JSON.stringify({ from, to, baseRev }) }));
386
+ method: "POST", headers: { ...authHeaders(token, cfg), "content-type": "application/json" }, body: JSON.stringify({ from, to, baseRev }) }));
380
387
  }
381
388
 
382
389
  /** GET/POST /api/lock/<site> – sanfte Sperre („in Bearbeitung durch …"); blockiert niemanden. action: lock|unlock|takeover. */
383
390
  export async function apiLock(cfg, { site, action = null, note = null } = {}, fetchImpl = fetch) {
384
391
  const { token, slug } = resolveTarget(cfg, site);
385
392
  const url = `${baseUrl(cfg)}/lock/${encodeURIComponent(slug)}`;
386
- if (!action) return asJson(await fetchImpl(url, { headers: authHeaders(token) }));
387
- return asJsonOr409(await fetchImpl(url, { method: "POST", headers: { ...authHeaders(token), "content-type": "application/json" }, body: JSON.stringify({ action, note }) }));
393
+ if (!action) return asJson(await fetchImpl(url, { headers: authHeaders(token, cfg) }));
394
+ return asJsonOr409(await fetchImpl(url, { method: "POST", headers: { ...authHeaders(token, cfg), "content-type": "application/json" }, body: JSON.stringify({ action, note }) }));
388
395
  }
package/index.mjs CHANGED
@@ -13,343 +13,16 @@ import { realpathSync } from "node:fs";
13
13
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
14
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
15
  import { z } from "zod";
16
- import { loadConfig, apiStatus, apiExport, apiDeploy, apiPublish, sitesList, apiAccountCreate, apiCompanyGet, apiCompanySet, apiLegalOptions, apiDomainStatus, apiDomainConnect, apiEmbeds, apiEmbedConsent, apiWidgets, apiWidgetSet, apiFiles, apiFileGet, apiFilePut, apiFileDelete, apiFileRename, apiLock } from "./api.mjs";
16
+ import { loadConfig } from "./api.mjs";
17
+ import { registerTools } from "./tools.mjs";
17
18
 
18
19
  const cfg = loadConfig();
19
20
  // Deutliche Startwarnung bei ambiger Konfiguration (Top-Level-Token NEBEN sites-Map). Nach stderr, damit
20
21
  // es im MCP-Log/Terminal auffällt; die endgültige Auflösung (Betreuer-Token-Vorrang vs. Map) klärt sites_list.
21
22
  for (const w of (cfg.warnings || [])) process.stderr.write("[prontosite-mcp] ⚠ " + w + "\n");
22
- const server = new McpServer({ name: "prontosite", version: "0.11.1" });
23
- const asText = obj => ({ content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] });
24
- const asError = e => ({ isError: true, content: [{ type: "text", text: "Fehler: " + (e?.message || String(e)) }] });
25
- const SITE_DESC = "Website-Slug (z. B. 'mas-monti'). Ohne Angabe die Standard-Site der Config. Bei Betreuer-"
26
- + "Config (mehrere Sites) hier die gewünschte Website angeben.";
27
-
28
- // Firmendaten-Felder (identisch zum Dashboard-Formular / Sanitizer). Nur gesetzte Felder werden geändert.
29
- const companyObject = z.object({
30
- name: z.string().optional().describe("Firmenname"),
31
- legalForm: z.string().optional().describe("Rechtsform (z. B. GmbH, UG, Einzelunternehmen, Freiberufler)"),
32
- representative: z.string().optional().describe("Inhaber / Vertretungsberechtigte:r"),
33
- street: z.string().optional().describe("Straße und Hausnummer"),
34
- zip: z.string().optional().describe("PLZ"),
35
- city: z.string().optional().describe("Ort"),
36
- country: z.string().optional().describe("Land"),
37
- email: z.string().optional().describe("E-Mail-Adresse (Impressum)"),
38
- phone: z.string().optional().describe("Telefon"),
39
- fax: z.string().optional().describe("Fax"),
40
- vatId: z.string().optional().describe("USt-IdNr."),
41
- registerCourt: z.string().optional().describe("Registergericht"),
42
- registerNumber: z.string().optional().describe("Handelsregister-Nr."),
43
- kammer: z.string().optional().describe("Zuständige Kammer (reglementierte Berufe)"),
44
- berufsbezeichnung: z.string().optional().describe("Berufsbezeichnung (reglementierte Berufe)"),
45
- aufsichtsbehoerde: z.string().optional().describe("Aufsichtsbehörde (erlaubnispflichtige Gewerbe)"),
46
- verantwortlichMStV: z.string().optional().describe("Verantwortlich i.S.d. § 18 Abs. 2 MStV (falls abweichend)"),
47
- berufshaftpflicht: z.string().optional().describe("Berufshaftpflichtversicherung (Name, Ort, Geltungsraum)"),
48
- social: z.array(z.object({ label: z.string(), href: z.string() })).optional().describe("Social-Links [{label,href}] (href muss http/https sein)"),
49
- }).describe("Firmendaten fürs Impressum/Footer – Teil-Update: nur gesetzte Felder ändern sich.");
50
-
51
- server.tool(
52
- "sites_list",
53
- "Listet alle erreichbaren ProntoSite-Websites auf (Slug, Plan, letzte Änderung, offener Veröffentlichungs"
54
- + "bedarf). Nützlich, um im Chat eine Website beim Namen zu nennen. LESEND.",
55
- {},
56
- async () => { try { return asText(await sitesList(cfg)); } catch (e) { return asError(e); } }
57
- );
58
-
59
- server.tool(
60
- "account_create",
61
- "SCHREIBEND (nur mit Betreuer-Token: admin/partner/agentur): Legt ein NEUES Kundenkonto an – der Dashboard-"
62
- + "Weg „Kunde anlegen“. Nutze dieses Tool, BEVOR du eine Website importierst, wenn es für den Kunden noch "
63
- + "KEIN Konto gibt (z. B. sites_list zeigt den Kunden nicht, oder der Nutzer sagt „für Kunde X gibt es noch "
64
- + "kein Konto“). Erst Konto anlegen, dann mit der zurückgegebenen Konto-ID (k-…) per site_deploy und "
65
- + "site=\"<k-id>:new\" die erste Website importieren. Richte KEIN site=new/Erst-Import ein, solange die "
66
- + "Konto-ID unbekannt ist – dann zuerst dieses Tool. Mit email wird ein Login-Konto angelegt und eine "
67
- + "Einladungs-Mail verschickt; ohne email ein verwaltetes Konto (ohne Login, Betrieb über den Betreuer). "
68
- + "Partner/Agentur: das Konto wird DIR zugeordnet; Agentur-Kunden erhalten automatisch den Agentur-Plan "
69
- + "(Abrechnung über die Agentur). Schutz: bei gleichem Namen/gleicher E-Mail kommt eine Warnung mit der "
70
- + "bereits vorhandenen Konto-ID statt einer Doppelanlage – mit override:true trotzdem anlegen. "
71
- + "Bitte VOR dem Aufruf bestätigen lassen (es wird ggf. eine Mail an den Kunden gesendet).",
72
- { name: z.string().describe("Pflicht: Anzeigename des Kunden (z. B. Firmenname)."),
73
- email: z.string().optional().describe("Optional: E-Mail des Kunden. Gesetzt → eingeladenes Login-Konto (Invite-Mail); weggelassen → verwaltetes Konto ohne Login."),
74
- plan: z.enum(["free", "agentur-kunde"]).optional().describe("Optional. Standard richtet sich nach dem Betreuer-Konto (Agentur → agentur-kunde, sonst free). Nur angeben, wenn du den Standard bestätigen willst."),
75
- note: z.string().optional().describe("Optionale interne Notiz – erscheint im Konto-Verlauf des neuen Kontos."),
76
- override: z.boolean().default(false).describe("true = eine Namensdublette-Warnung übergehen und trotzdem ein weiteres Konto anlegen. Eine bereits vergebene E-Mail lässt sich NICHT erzwingen.") },
77
- async ({ name, email, plan, note, override }) => {
78
- try { return asText(await apiAccountCreate(cfg, { name, email, plan, note, override })); } catch (e) { return asError(e); }
79
- }
80
- );
81
-
82
- server.tool(
83
- "site_status",
84
- "Status einer ProntoSite-Website: Plan, Slug, Live-URL, letzte Änderungen, offener Veröffentlichungs"
85
- + "bedarf, draftRev und recentEdits (wer hat zuletzt wo geändert). VOR Änderungen lesen – draftRev ist die baseRev für "
86
- + "site_file_put. LESEND – ändert nichts.",
87
- { site: z.string().optional().describe(SITE_DESC) },
88
- async ({ site }) => { try { return asText(await apiStatus(cfg, { site })); } catch (e) { return asError(e); } }
89
- );
90
-
91
- server.tool(
92
- "site_export",
93
- "Exportiert eine Website als ZIP in ein lokales Verzeichnis. which: 'published' (Live-Stand, Standard), "
94
- + "'draft' (Entwurf) oder 'original' (flacher Stand ohne ProntoSite-Zutaten). LESEND.",
95
- { site: z.string().optional().describe(SITE_DESC),
96
- which: z.enum(["published", "draft", "original"]).default("published"),
97
- outDir: z.string().default(".").describe("Zielordner für die ZIP-Datei (lokal).") },
98
- async ({ site, which, outDir }) => { try { return asText(await apiExport(cfg, { site, which, outDir })); } catch (e) { return asError(e); } }
99
- );
100
-
101
- server.tool(
102
- "site_deploy",
103
- "SCHREIBEND: Übergibt eine lokale ZIP-Datei an ProntoSite (Import). Mit publish=true wird die Website "
104
- + "DIREKT LIVE geschaltet und der bestehende Live-Stand überschrieben. ERST-IMPORT (neue Website): "
105
- + "Einzel-Config site='new'; Multi-Site/Betreuer site='<label>:new' (legt im Konto DES LABELS an). "
106
- + "WICHTIG beim ERST-IMPORT: Frag den Nutzer VOR dem Deploy nach der gewünschten Adresse "
107
- + "(„Unter welcher Adresse soll die Website laufen?“, z. B. 'wuerttemberg' → wuerttemberg.prontosite.io) "
108
- + "und übergib sie als slug – ohne slug wird die Adresse nur aus Konto-/Seitenname ABGELEITET (die Antwort "
109
- + "nennt dann die abgeleitete Adresse; sie ist später änderbar). Bitte VOR dem Aufruf bestätigen lassen. "
110
- + "REIHENFOLGE beim Erst-Import: zuerst Konto (account_create) → Firmendaten (company hier mitgeben ODER "
111
- + "site_company_set) → Import; so stehen Impressum/Datenschutz sofort korrekt (ein Firmendaten-Set nach dem "
112
- + "Import lässt die Rechts-Automatik automatisch neu laufen – kein zweiter Import nötig). "
113
- + "WEBSITE-NAME: wird aus der Startseite abgeleitet (og:site_name → Titel-Präfix vor dem ersten Trenner → "
114
- + "Header-H1/Logo-alt); mit name=… explizit setzen (Betreuer), falls die Ableitung danebenliegt. "
115
- + "MARKER (wenn du das Template lokal baust): markiere im HTML mit dem Attribut data-ps, wo Bausteine "
116
- + "erscheinen sollen – data-ps=\"formular:kontakt\" (verwaltetes Kontaktformular), data-ps=\"impressum\" bzw. "
117
- + "data-ps=\"datenschutz\" (Rechtstext-Seite) und data-ps=\"link-impressum\" / data-ps=\"link-datenschutz\" "
118
- + "(Footer-Links auf die Rechtstexte). Beim Import legt ProntoSite die Bausteine automatisch an. "
119
- + "PFLICHT-MARKER: (a) Rechtsseiten impressum/index.html + datenschutz/index.html tragen IMMER den "
120
- + "data-ps-Marker als Inhaltsbereich UND eine EIGENE <h1>; schreib KEINEN Rechtstext von Hand hinein. Weil "
121
- + "die Seite ihre eigene H1 hat, die Widget-Überschrift ausschalten – site_legal_options mit showTitle:false "
122
- + "(bei ?markers=auto setzt ProntoSite das automatisch, sobald die Rechtsseite eine eigene H1 hat). "
123
- + "(b) Footer IMMER mit allen vier Markern link-impressum, link-datenschutz, cookie-einstellungen, "
124
- + "cookie-richtlinie (sonst hängt das Consent-Widget einen ungestylten Block an den Seitenfuß; mehrsprachig "
125
- + "je Sprach-Footer dieselben Marker). (c) Mobile-Menü-Toggle braucht aria-controls (Menü rendert als Drawer). "
126
- + "Syntax-Referenz: https://prontosite.io/docs/marker-shortcodes",
127
- { site: z.string().optional().describe(SITE_DESC + " Erst-Import: 'new' (Einzel) bzw. '<label>:new' (Multi-Site). Betreuer-Token: '<Konto-ID>:new' (k-…) legt im betreuten Konto an – die Konto-ID im Label ersetzt den customer-Parameter. sites_list zeigt betreute Konten OHNE Website (needsFirstImport) samt ihrer k-ID."),
128
- slug: z.string().optional().describe("Erst-Import: gewünschte Web-Adresse (Subdomain-Label, z. B. 'wuerttemberg' → wuerttemberg.prontosite.io). Beim Erst-Import den Nutzer danach FRAGEN; wird sie weggelassen, leitet ProntoSite eine Adresse ab. Bei Updates ignoriert."),
129
- name: z.string().optional().describe("Erst-Import: expliziter Website-Name (überschreibt die Ableitung aus der Startseite). Nützlich, wenn der Titel einen Ortszusatz trägt (z. B. 'Physio Phoenix – Bad Honnef Süd' → name='Physio Phoenix'). Bei Updates ignoriert."),
130
- customer: z.string().optional().describe("Betreuer-Token + Erst-Import: Ziel-Konto-ID (k-…) für die neue Website. Alternativ direkt im site-Argument als '<Konto-ID>:new'."),
131
- zipPath: z.string().describe("Pfad zur ZIP-Datei der Website (lokal)."),
132
- publish: z.boolean().default(false).describe("true = nach dem Import direkt veröffentlichen (LIVE)."),
133
- resetOverlay: z.boolean().default(false).describe("true = bisherige Dashboard-Änderungen verwerfen."),
134
- legal: z.boolean().optional().describe("Rechts-Automatik (Impressum/Datenschutz): true erzwingt, false schaltet ab."),
135
- company: companyObject.optional().describe("Optional: Firmendaten VOR dem Deploy setzen (ein Schritt) – Impressum/Datenschutz nutzen sie dann direkt. Teil-Update."),
136
- siteOverride: z.boolean().default(false).describe("Nur mit company: true = Firmendaten NUR für diese Website (statt konto-weit).") },
137
- async ({ site, slug, name, customer, zipPath, publish, resetOverlay, legal, company, siteOverride }) => {
138
- try { return asText(await apiDeploy(cfg, { site, slug, name, customer, zipPath, publish, resetOverlay, legal, company, siteOverride })); } catch (e) { return asError(e); }
139
- }
140
- );
141
-
142
- server.tool(
143
- "site_publish",
144
- "SCHREIBEND: Veröffentlicht den aktuellen Stand einer Website ERNEUT (ohne ZIP) – z. B. um Plattform-"
145
- + "Aktualisierungen live zu bringen. Meldet die Antwort nothing:true (keine erkannte Änderung), aber du "
146
- + "willst trotzdem neu ausliefern, setze force:true. Bitte VOR dem Aufruf bestätigen lassen – und die richtige Website prüfen.",
147
- { site: z.string().optional().describe(SITE_DESC),
148
- force: z.boolean().default(false).describe("true = trotzdem veröffentlichen, auch ohne erkannte Änderung (wie der Menüpunkt Erneut veröffentlichen).") },
149
- async ({ site, force }) => { try { return asText(await apiPublish(cfg, { site, force })); } catch (e) { return asError(e); } }
150
- );
151
-
152
- server.tool(
153
- "site_company_get",
154
- "Firmendaten (Impressum/Footer) einer Website lesen: effektiver Stand + konto-weite Angaben + ob eine "
155
- + "Website-Abweichung gesetzt ist. LESEND – ändert nichts.",
156
- { site: z.string().optional().describe(SITE_DESC) },
157
- async ({ site }) => { try { return asText(await apiCompanyGet(cfg, { site })); } catch (e) { return asError(e); } }
158
- );
159
-
160
- server.tool(
161
- "site_company_set",
162
- "SCHREIBEND: Firmendaten des Kontos setzen/ergänzen (Name, Rechtsform, Inhaber, Anschrift, Kontakt, "
163
- + "USt-IdNr., Register, Kammer/Beruf, Aufsicht, § 18 MStV, Social). Diese Daten füllen beim nächsten Deploy "
164
- + "automatisch Impressum & Datenschutz. TEIL-UPDATE: nur die übergebenen Felder ändern sich. Du darfst die "
165
- + "Angaben aus jeder Vorlage extrahieren (Impressum-Text, Visitenkarte, Freitext) und strukturiert übergeben. "
166
- + "Default konto-weit; siteOverride=true nur, wenn DIESE eine Website abweichende Angaben braucht. NACH EINEM "
167
- + "IMPORT lässt dieser Aufruf die Rechts-Automatik AUTOMATISCH neu laufen (Entwurf) – Impressum/Datenschutz "
168
- + "übernehmen die Firmendaten sofort, ein zweiter Import ist NICHT nötig (die Antwort nennt die neu erzeugten "
169
- + "Rechtstexte unter legal.refreshed); danach site_publish zum Live-Schalten. Bitte VOR dem Speichern die "
170
- + "erkannten Felder kurz zusammenfassen und bestätigen lassen.",
171
- { site: z.string().optional().describe(SITE_DESC),
172
- company: companyObject,
173
- siteOverride: z.boolean().default(false).describe("true = Firmendaten NUR für diese Website (statt konto-weit).") },
174
- async ({ site, company, siteOverride }) => {
175
- try { return asText(await apiCompanySet(cfg, { site, company, siteOverride })); } catch (e) { return asError(e); }
176
- }
177
- );
178
-
179
- const legalOptShape = z.object({
180
- showTitle: z.boolean().optional().describe("Widget-Überschrift des Rechtstextes anzeigen (Default an). Bringt die Rechtsseite ihre EIGENE <h1> mit (Pflicht-Marker-Konvention), hier showTitle:false setzen, sonst doppelte Überschrift."),
181
- showDate: z.boolean().optional().describe("Stand-Datum anzeigen (Default an; nur wo vorhanden, i. d. R. Datenschutz)."),
182
- draftNotice: z.boolean().optional().describe("Entwurfs-Hinweis in der VERÖFFENTLICHTEN Seite anzeigen (Default an)."),
183
- }).describe("Anzeige-Optionen einer Rechtstext-Rolle – Teil-Update, nur gesetzte Booleans wirken.");
184
-
185
- server.tool(
186
- "site_legal_options",
187
- "LESEND/SCHREIBEND: Anzeige-Optionen der Rechtstext-Widgets je Rolle (impressum/datenschutz): showTitle "
188
- + "(Überschrift), showDate (Stand-Datum), draftNotice (Entwurfs-Hinweis in der Live-Ausgabe). OHNE impressum/"
189
- + "datenschutz nur LESEN. Teil-Update (nur gesetzte Felder). HINWEIS: draftNotice:false blendet den rechtlich "
190
- + "empfohlenen Entwurfs-Hinweis live aus (protokolliert) – bitte vorher bestätigen lassen. "
191
- + "WICHTIG: Rechtsseiten sollen laut Konvention ihre EIGENE <h1> mitbringen (Marker data-ps=\"impressum\"/"
192
- + "\"datenschutz\" als Inhaltsbereich) – dann hier showTitle:false setzen, damit keine doppelte Überschrift "
193
- + "entsteht. MARKER (beim lokalen Bauen): Rechtstext-Seiten und -Links im Template mit data-ps markieren – "
194
- + "data-ps=\"impressum\" / data-ps=\"datenschutz\" (Seite) bzw. data-ps=\"link-impressum\" / "
195
- + "data-ps=\"link-datenschutz\" (Footer-Links). Syntax-Referenz: https://prontosite.io/docs/marker-shortcodes",
196
- { site: z.string().optional().describe(SITE_DESC),
197
- impressum: legalOptShape.optional(),
198
- datenschutz: legalOptShape.optional() },
199
- async ({ site, impressum, datenschutz }) => {
200
- try { return asText(await apiLegalOptions(cfg, { site, impressum, datenschutz })); } catch (e) { return asError(e); }
201
- }
202
- );
203
-
204
- server.tool(
205
- "site_domain_status",
206
- "LESEND: Kundendomain-Status einer Website – verbundene Domains (kanonisch/Weiterleitung), Zertifikat-"
207
- + "Gültigkeit und offene Verbindungs-Jobs. Mit `check` stattdessen NUR eine Live-DNS-Prüfung der angegebenen "
208
- + "Domain (A-Record für @ und www muss auf den Server zeigen, kein AAAA) – ideal VOR dem Verbinden.",
209
- { site: z.string().optional().describe(SITE_DESC),
210
- check: z.string().optional().describe("Domain (z. B. ihre-firma.de) NUR auf DNS-Bereitschaft prüfen, nichts verbinden.") },
211
- async ({ site, check }) => { try { return asText(await apiDomainStatus(cfg, { site, check })); } catch (e) { return asError(e); } }
212
- );
213
-
214
- server.tool(
215
- "site_domain_connect",
216
- "SCHREIBEND: eigene Domain mit der Website verbinden oder trennen. Beim Verbinden werden nginx-Serverblock "
217
- + "und HTTPS-Zertifikat (Let's Encrypt) automatisch eingerichtet und die Domain als kanonische Adresse gesetzt "
218
- + "(inkl. Republish für Canonical/Sitemap). VORAUSSETZUNG: der DNS zeigt bereits auf den Server – vorher IMMER "
219
- + "site_domain_status mit `check` aufrufen und das Ergebnis bestätigen lassen. Nur ab Starter-Plan (Betreuer-/"
220
- + "Operator-Token ausgenommen); eine Domain kann nur an EINER Website hängen. Läuft asynchron über eine Job-"
221
- + "Queue – danach site_domain_status pollen. action='disconnect' trennt die Domain wieder.",
222
- { site: z.string().optional().describe(SITE_DESC),
223
- domain: z.string().describe("Die zu verbindende/trennende Domain, z. B. ihre-firma.de (mit oder ohne www)."),
224
- canonical: z.enum(["www", "apex"]).default("www").describe("Kanonische Adresse: 'www' (mit www) oder 'apex' (ohne www). Nur beim Verbinden relevant."),
225
- action: z.enum(["connect", "disconnect"]).default("connect").describe("connect = verbinden, disconnect = trennen.") },
226
- async ({ site, domain, canonical, action }) => {
227
- try { return asText(await apiDomainConnect(cfg, { site, domain, canonical, action })); } catch (e) { return asError(e); }
228
- }
229
- );
230
-
231
- server.tool(
232
- "site_embeds",
233
- "LESEND: erkannte eingebettete Inhalte Dritter (iframes) einer Website – je Embed Host, Seite(n) und "
234
- + "aktuelle Consent-Kategorie (`gated` = lädt erst nach Einwilligung / Zwei-Klick, `notwendig` = technisch "
235
- + "erforderlich, lädt ohne Einwilligung). Basis für site_embed_consent.",
236
- { site: z.string().optional().describe(SITE_DESC) },
237
- async ({ site }) => { try { return asText(await apiEmbeds(cfg, { site })); } catch (e) { return asError(e); } }
238
- );
239
-
240
- server.tool(
241
- "site_embed_consent",
242
- "SCHREIBEND: ein Embed als `notwendig` (technisch erforderlich, § 25 Abs. 2 Nr. 2 TDDDG) oder zurück auf "
243
- + "`gated` einstufen. Bei `notwendig` erklärt der Kunde/Betreuer, dass der Dienst für die vom Besucher "
244
- + "gewünschte Funktion (z. B. Reservierung) UNBEDINGT ERFORDERLICH ist und übernimmt die Verantwortung – "
245
- + "das MUSS mit dem Nutzer bestätigt werden (confirmed:true; sonst lehnt der Server mit dem Verantwortungs-"
246
- + "Text ab). Dann lädt das Embed OHNE Einwilligung, aber nur auf der angegebenen Seite; der Datenschutz-Text "
247
- + "passt sich automatisch an. Greift beim nächsten Veröffentlichen. Betreuer-Token gilt für betreute Kunden.",
248
- { site: z.string().optional().describe(SITE_DESC),
249
- host: z.string().describe("Embed-Host/Key aus site_embeds (z. B. 'resmio', 'youtube' oder ein Hostname)."),
250
- page: z.string().optional().describe("Seiten-Datei, auf die die Einstufung beschränkt ist (z. B. 'reservierung.html'); leer = alle Seiten mit diesem Embed."),
251
- category: z.enum(["notwendig", "gated"]).default("notwendig").describe("notwendig = technisch erforderlich (ohne Einwilligung); gated = zurück hinter das Consent-/Zwei-Klick-Gate."),
252
- confirmed: z.boolean().default(false).describe("MUSS true sein, um 'notwendig' zu setzen (Verantwortungs-Bestätigung). Vorher den Nutzer explizit fragen.") },
253
- async ({ site, host, page, category, confirmed }) => {
254
- try { return asText(await apiEmbedConsent(cfg, { site, host, page, category, confirmed })); } catch (e) { return asError(e); }
255
- }
256
- );
257
-
258
- server.tool(
259
- "site_widgets",
260
- "LESEND: Katalog der verwalteten Widgets einer Website – je Widget name, Titel, aktiv, aktuelle optionen und "
261
- + "das Konfig-schema (Feldtypen/Defaults). Widgets sind in JEDEM Plan (auch Free) verfügbar. Bekannte Widgets: "
262
- + "consent (Cookie-Banner), a11y (Barrierefreiheit-Hilfen), notice (Pop-Ups/Hinweise – Modal/Banner/inline, "
263
- + "Listen-Widget optionen.items[]), google-reviews, ps-impressum, ps-datenschutz. Basis für site_widget_set.",
264
- { site: z.string().optional().describe(SITE_DESC) },
265
- async ({ site }) => { try { return asText(await apiWidgets(cfg, { site })); } catch (e) { return asError(e); } }
266
- );
267
-
268
- server.tool(
269
- "site_widget_set",
270
- "SCHREIBEND: ein Widget aktivieren/deaktivieren und/oder seine Optionen setzen – MIT DEMSELBEN Schema-Sanitizer "
271
- + "wie das Dashboard (unbekannte/ungültige Felder werden abgewiesen). `optionen` muss zum schema aus site_widgets "
272
- + "passen; Listen-Widgets (z. B. notice) erwarten { items: [ { id?, name?, aktiv?, ...Felder } ] }. Beispiel notice: "
273
- + "optionen={ items:[{ titel:'Sommerpause', text:'**Geschlossen** bis 1.9.', darstellung:'modal', seiten:['index.html'], "
274
- + "haeufigkeit:'tage', ruheTage:3 }] }. Greift beim nächsten Veröffentlichen (publishNeeded). Vorher mit dem Nutzer "
275
- + "bestätigen (verändert die Website). Ergänzt die Einzel-Tools site_legal_options/site_embed_consent, ersetzt sie nicht. "
276
- + "Betreuer-Token gilt für betreute Kunden.",
277
- { site: z.string().optional().describe(SITE_DESC),
278
- widget: z.string().describe("Widget-Name aus site_widgets (z. B. 'notice', 'consent', 'a11y', 'google-reviews', 'ps-impressum', 'ps-datenschutz')."),
279
- aktiv: z.boolean().optional().describe("Widget an/aus. Weglassen = unverändert (nur optionen setzen)."),
280
- optionen: z.any().optional().describe("Konfig-Objekt gemäß schema aus site_widgets. Listen-Widgets: { items: [...] }. Weglassen = aktiv-Status unverändert lassen.") },
281
- async ({ site, widget, aktiv, optionen }) => {
282
- try { return asText(await apiWidgetSet(cfg, { site, widget, aktiv, optionen })); } catch (e) { return asError(e); }
283
- }
284
- );
285
-
286
- // ---- Datei-API: kleine Änderungen am Entwurf direkt auf dem Server (Konzept §7) ----
287
- const FILE_SMALL = "Für kleine Änderungen am Entwurf ohne lokale Kopie; für größere Umbauten site_export (which='draft') → lokal bearbeiten → site_deploy. ";
288
- server.tool(
289
- "site_files",
290
- "LESEND: Listet die Dateien des Entwurfs einer Website (Pfad, Größe, Typ, ob bearbeitbar) und die aktuelle draftRev. "
291
- + FILE_SMALL + "Ruf VORHER site_status auf; zeigt es kürzliche Änderungen anderer (recentEdits), sag das dem Nutzer, bevor du etwas änderst.",
292
- { site: z.string().optional().describe(SITE_DESC) },
293
- async ({ site }) => { try { return asText(await apiFiles(cfg, { site })); } catch (e) { return asError(e); } }
294
- );
295
- server.tool(
296
- "site_file_get",
297
- "LESEND: Liefert den Quelltext einer Datei des Entwurfs (Text; Binär als Base64, max. 5 MB) samt draftRev. "
298
- + "Der Block overlay nennt Dashboard-Änderungen, die im Entwurf ÜBER diesem Quelltext liegen (sie gewinnen sichtbar) – "
299
- + "berücksichtige sie, bevor du Text änderst. Merke dir draftRev: du brauchst sie als baseRev für site_file_put. " + FILE_SMALL,
300
- { site: z.string().optional().describe(SITE_DESC), path: z.string().describe("Relativer Pfad, z. B. 'index.html' oder 'css/style.css' (aus site_files).") },
301
- async ({ site, path }) => { try { return asText(await apiFileGet(cfg, { site, path })); } catch (e) { return asError(e); } }
302
- );
303
- server.tool(
304
- "site_file_put",
305
- "SCHREIBEND (Entwurf, nicht live; nur Betreuer-Token): Ersetzt den Inhalt einer Text-Datei (HTML einer bestehenden Seite, "
306
- + "CSS, SVG, JSON, TXT …). PFLICHT: baseRev = draftRev aus deinem letzten site_file_get/site_status. Antwortet der Server mit "
307
- + "conflict:true (409), hat jemand anderes dieselbe Datei inzwischen geändert: NICHT erzwingen – Datei neu holen, Änderung neu "
308
- + "anwenden, mit der neuen draftRev erneut senden; die Antwort listet wer/wann/was. Prüfe danach overlay (umgehängte/deaktivierte "
309
- + "Dashboard-Änderungen), sanitized und warnings und berichte sie dem Nutzer. Rechtstexte (Impressum/Datenschutz) nicht "
310
- + "umformulieren – der Server lehnt das ab. Danach: site_status zeigt publishNeeded, live erst mit site_publish (vorher "
311
- + "bestätigen lassen). " + FILE_SMALL,
312
- { site: z.string().optional().describe(SITE_DESC),
313
- path: z.string().describe("Relativer Pfad der Datei (bestehende Seite oder Text-Asset)."),
314
- content: z.string().describe("Vollständiger neuer Dateiinhalt (Text, UTF-8, max. 1 MB)."),
315
- baseRev: z.number().int().describe("draftRev, auf der deine Änderung beruht (aus site_file_get/site_status)."),
316
- overlay: z.enum(["keep", "yield"]).default("keep").describe("keep = Dashboard-Änderungen bleiben über dem Quelltext; yield = wo deine Datei denselben Inhalt geändert hat, gewinnt die Datei.") },
317
- async ({ site, path, content, baseRev, overlay }) => { try { return asText(await apiFilePut(cfg, { site, path, content, baseRev, overlay })); } catch (e) { return asError(e); } }
318
- );
319
- server.tool(
320
- "site_file_delete",
321
- "SCHREIBEND (Entwurf; nur Betreuer-Token): Markiert eine Text-Datei im Entwurf als gelöscht (rückgängig machbar; Seiten "
322
- + "löschen geht über das Dashboard). baseRev Pflicht, confirmDestructive:true erst NACH Rückfrage beim Nutzer. Prüfe vorher, "
323
- + "ob die Datei noch verlinkt ist. " + FILE_SMALL,
324
- { site: z.string().optional().describe(SITE_DESC), path: z.string(), baseRev: z.number().int(),
325
- confirmDestructive: z.boolean().default(false).describe("true erst nach ausdrücklicher Bestätigung durch den Nutzer.") },
326
- async ({ site, path, baseRev, confirmDestructive }) => { try { return asText(await apiFileDelete(cfg, { site, path, baseRev, confirmDestructive })); } catch (e) { return asError(e); } }
327
- );
328
- server.tool(
329
- "site_file_rename",
330
- "SCHREIBEND (Entwurf; nur Betreuer-Token): Benennt eine Text-Datei (kein HTML) um. Verweise in anderen Dateien werden NICHT "
331
- + "angepasst – die musst du selbst per site_file_put nachziehen. baseRev Pflicht. " + FILE_SMALL,
332
- { site: z.string().optional().describe(SITE_DESC), from: z.string(), to: z.string(), baseRev: z.number().int() },
333
- async ({ site, from, to, baseRev }) => { try { return asText(await apiFileRename(cfg, { site, from, to, baseRev })); } catch (e) { return asError(e); } }
334
- );
335
-
336
- server.tool(
337
- "site_lock",
338
- "Setzt eine sanfte Sperre „in Bearbeitung durch <dich>“ (2 h, verlängert sich bei jeder deiner Änderungen). Blockiert "
339
- + "niemanden – andere sehen nur einen Hinweis und bekommen beim Speichern eine Warnung. Vor einer längeren Bearbeitungsserie "
340
- + "setzen. Ist schon jemand anderes in Bearbeitung, kommt conflict/409 mit dem Hinweis – takeover:true NUR als Betreuer und nur "
341
- + "nach Rückfrage beim Nutzer (die andere Person sieht die Übernahme).",
342
- { site: z.string().optional().describe(SITE_DESC),
343
- note: z.string().optional().describe("Optionale Notiz, z. B. „Startseite überarbeiten“ (andere sehen sie im Hinweis)."),
344
- takeover: z.boolean().default(false).describe("true = fremde Sperre übernehmen (nur Betreuer, nach Rückfrage).") },
345
- async ({ site, note, takeover }) => { try { return asText(await apiLock(cfg, { site, note, action: takeover ? "takeover" : "lock" })); } catch (e) { return asError(e); } }
346
- );
347
- server.tool(
348
- "site_unlock",
349
- "Gibt deine sanfte Sperre („in Bearbeitung“) frei – am Ende der Bearbeitung aufrufen. Ohne Sperre ein No-op.",
350
- { site: z.string().optional().describe(SITE_DESC) },
351
- async ({ site }) => { try { return asText(await apiLock(cfg, { site, action: "unlock" })); } catch (e) { return asError(e); } }
352
- );
23
+ const server = new McpServer({ name: "prontosite", version: "0.11.2" });
24
+ // Tool-Definitionen: gemeinsame Quelle mit dem Remote-Endpunkt (tools.mjs, Konzept KI-Anbindung §4.2).
25
+ registerTools(server, { z, cfg, mode: "local" });
353
26
 
354
27
  // Den stdio-Server NUR starten, wenn diese Datei direkt ausgeführt wird (als MCP-Server / bin).
355
28
  // Beim bloßen Importieren (Smoke-Test / Tests) NICHT verbinden – sonst würde der Import auf stdin blockieren.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prontosite/mcp",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
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": {
@@ -8,13 +8,14 @@
8
8
  },
9
9
  "main": "index.mjs",
10
10
  "scripts": {
11
- "check": "node --check index.mjs && node --check api.mjs",
11
+ "check": "node --check index.mjs && node --check api.mjs && node --check tools.mjs",
12
12
  "smoke": "node smoke.mjs",
13
13
  "prepublishOnly": "npm run check && npm run smoke"
14
14
  },
15
15
  "files": [
16
16
  "index.mjs",
17
17
  "api.mjs",
18
+ "tools.mjs",
18
19
  "README.md",
19
20
  "CHANGELOG.md"
20
21
  ],
@@ -35,7 +36,7 @@
35
36
  "access": "public"
36
37
  },
37
38
  "dependencies": {
38
- "@modelcontextprotocol/sdk": "^1.0.0",
39
+ "@modelcontextprotocol/sdk": "^1.31.0",
39
40
  "zod": "^3.23.0"
40
41
  }
41
42
  }
package/tools.mjs ADDED
@@ -0,0 +1,411 @@
1
+ /**
2
+ * ProntoSite MCP – gemeinsame Tool-Quelle (docs/konzept-ki-anbindung.md §4.2). EINMAL definiert, benutzt vom
3
+ * lokalen Paket (stdio, index.mjs) UND vom Remote-Endpunkt https://app.prontosite.io/mcp (src/server/mcp-remote.js).
4
+ * Jedes Tool ruft nur api.mjs → die REST-API (lokal über HTTPS, remote über den internen Loopback mit dem Token des
5
+ * Aufrufers) – dieselben Gates, Handler, 409/Guards. Kein Tool ruft Store-Funktionen direkt.
6
+ *
7
+ * mode "local": Export/Deploy mit lokalen Pfaden (outDir/zipPath).
8
+ * mode "remote": Export als Download-Link, Deploy über Upload-Link + deploy_commit (B-K4); account_create und
9
+ * site_domain_connect nur mit Betreuer-Token (Entscheidung 9a).
10
+ * `z` wird vom Aufrufer übergeben (dieselbe zod-Instanz wie sein MCP-SDK).
11
+ */
12
+ import { 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";
13
+
14
+ const asText = obj => ({ content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] });
15
+ const asError = e => ({ isError: true, content: [{ type: "text", text: "Fehler: " + (e?.message || String(e)) }] });
16
+
17
+ /** Alle Tools am MCP-Server registrieren. */
18
+ export function registerTools(server, { z, cfg, mode = "local", remote = null }) {
19
+ const SITE_DESC = mode === "remote"
20
+ ? "Website-Slug (z. B. 'mas-monti') oder stabile Site-ID (s-…). Ohne Angabe die Website des Tokens. Betreuer-Token: "
21
+ + "die gewünschte Website angeben (sites_list zeigt alle)."
22
+ : "Website-Slug (z. B. 'mas-monti'). Ohne Angabe die Standard-Site der Config. Bei Betreuer-"
23
+ + "Config (mehrere Sites) hier die gewünschte Website angeben.";
24
+
25
+ // Firmendaten-Felder (identisch zum Dashboard-Formular / Sanitizer). Nur gesetzte Felder werden geändert.
26
+ const companyObject = z.object({
27
+ name: z.string().optional().describe("Firmenname"),
28
+ legalForm: z.string().optional().describe("Rechtsform (z. B. GmbH, UG, Einzelunternehmen, Freiberufler)"),
29
+ representative: z.string().optional().describe("Inhaber / Vertretungsberechtigte:r"),
30
+ street: z.string().optional().describe("Straße und Hausnummer"),
31
+ zip: z.string().optional().describe("PLZ"),
32
+ city: z.string().optional().describe("Ort"),
33
+ country: z.string().optional().describe("Land"),
34
+ email: z.string().optional().describe("E-Mail-Adresse (Impressum)"),
35
+ phone: z.string().optional().describe("Telefon"),
36
+ fax: z.string().optional().describe("Fax"),
37
+ vatId: z.string().optional().describe("USt-IdNr."),
38
+ registerCourt: z.string().optional().describe("Registergericht"),
39
+ registerNumber: z.string().optional().describe("Handelsregister-Nr."),
40
+ kammer: z.string().optional().describe("Zuständige Kammer (reglementierte Berufe)"),
41
+ berufsbezeichnung: z.string().optional().describe("Berufsbezeichnung (reglementierte Berufe)"),
42
+ aufsichtsbehoerde: z.string().optional().describe("Aufsichtsbehörde (erlaubnispflichtige Gewerbe)"),
43
+ verantwortlichMStV: z.string().optional().describe("Verantwortlich i.S.d. § 18 Abs. 2 MStV (falls abweichend)"),
44
+ berufshaftpflicht: z.string().optional().describe("Berufshaftpflichtversicherung (Name, Ort, Geltungsraum)"),
45
+ social: z.array(z.object({ label: z.string(), href: z.string() })).optional().describe("Social-Links [{label,href}] (href muss http/https sein)"),
46
+ }).describe("Firmendaten fürs Impressum/Footer – Teil-Update: nur gesetzte Felder ändern sich.");
47
+
48
+ server.tool(
49
+ "sites_list",
50
+ "Listet alle erreichbaren ProntoSite-Websites auf (Slug, Plan, letzte Änderung, offener Veröffentlichungs"
51
+ + "bedarf). Nützlich, um im Chat eine Website beim Namen zu nennen. LESEND.",
52
+ {},
53
+ async () => { try { return asText(await sitesList(cfg)); } catch (e) { return asError(e); } }
54
+ );
55
+
56
+ // Kundenkonto anlegen: lokal immer; remote nur mit Betreuer-Token (Entscheidung 9a – nicht im Standard).
57
+ if (mode === "local" || remote?.isManager) {
58
+ server.tool(
59
+ "account_create",
60
+ "SCHREIBEND (nur mit Betreuer-Token: admin/partner/agentur): Legt ein NEUES Kundenkonto an – der Dashboard-"
61
+ + "Weg „Kunde anlegen“. Nutze dieses Tool, BEVOR du eine Website importierst, wenn es für den Kunden noch "
62
+ + "KEIN Konto gibt (z. B. sites_list zeigt den Kunden nicht, oder der Nutzer sagt „für Kunde X gibt es noch "
63
+ + "kein Konto“). Erst Konto anlegen, dann mit der zurückgegebenen Konto-ID (k-…) per site_deploy und "
64
+ + "site=\"<k-id>:new\" die erste Website importieren. Richte KEIN site=new/Erst-Import ein, solange die "
65
+ + "Konto-ID unbekannt ist – dann zuerst dieses Tool. Mit email wird ein Login-Konto angelegt und eine "
66
+ + "Einladungs-Mail verschickt; ohne email ein verwaltetes Konto (ohne Login, Betrieb über den Betreuer). "
67
+ + "Partner/Agentur: das Konto wird DIR zugeordnet; Agentur-Kunden erhalten automatisch den Agentur-Plan "
68
+ + "(Abrechnung über die Agentur). Schutz: bei gleichem Namen/gleicher E-Mail kommt eine Warnung mit der "
69
+ + "bereits vorhandenen Konto-ID statt einer Doppelanlage – mit override:true trotzdem anlegen. "
70
+ + "Bitte VOR dem Aufruf bestätigen lassen (es wird ggf. eine Mail an den Kunden gesendet).",
71
+ { name: z.string().describe("Pflicht: Anzeigename des Kunden (z. B. Firmenname)."),
72
+ email: z.string().optional().describe("Optional: E-Mail des Kunden. Gesetzt → eingeladenes Login-Konto (Invite-Mail); weggelassen → verwaltetes Konto ohne Login."),
73
+ plan: z.enum(["free", "agentur-kunde"]).optional().describe("Optional. Standard richtet sich nach dem Betreuer-Konto (Agentur → agentur-kunde, sonst free). Nur angeben, wenn du den Standard bestätigen willst."),
74
+ note: z.string().optional().describe("Optionale interne Notiz – erscheint im Konto-Verlauf des neuen Kontos."),
75
+ override: z.boolean().default(false).describe("true = eine Namensdublette-Warnung übergehen und trotzdem ein weiteres Konto anlegen. Eine bereits vergebene E-Mail lässt sich NICHT erzwingen.") },
76
+ async ({ name, email, plan, note, override }) => {
77
+ try { return asText(await apiAccountCreate(cfg, { name, email, plan, note, override })); } catch (e) { return asError(e); }
78
+ }
79
+ );
80
+ }
81
+ server.tool(
82
+ "site_status",
83
+ "Status einer ProntoSite-Website: Plan, Slug, Live-URL, letzte Änderungen, offener Veröffentlichungs"
84
+ + "bedarf, draftRev und recentEdits (wer hat zuletzt wo geändert). VOR Änderungen lesen – draftRev ist die baseRev für "
85
+ + "site_file_put. LESEND – ändert nichts.",
86
+ { site: z.string().optional().describe(SITE_DESC) },
87
+ async ({ site }) => { try { return asText(await apiStatus(cfg, { site })); } catch (e) { return asError(e); } }
88
+ );
89
+
90
+ if (mode === "local") {
91
+ server.tool(
92
+ "site_export",
93
+ "Exportiert eine Website als ZIP in ein lokales Verzeichnis. which: 'published' (Live-Stand, Standard), "
94
+ + "'draft' (Entwurf) oder 'original' (flacher Stand ohne ProntoSite-Zutaten). LESEND.",
95
+ { site: z.string().optional().describe(SITE_DESC),
96
+ which: z.enum(["published", "draft", "original"]).default("published"),
97
+ outDir: z.string().default(".").describe("Zielordner für die ZIP-Datei (lokal).") },
98
+ async ({ site, which, outDir }) => { try { return asText(await apiExport(cfg, { site, which, outDir })); } catch (e) { return asError(e); } }
99
+ );
100
+
101
+
102
+ server.tool(
103
+ "site_deploy",
104
+ "SCHREIBEND: Übergibt eine lokale ZIP-Datei an ProntoSite (Import). Mit publish=true wird die Website "
105
+ + "DIREKT LIVE geschaltet und der bestehende Live-Stand überschrieben. ERST-IMPORT (neue Website): "
106
+ + "Einzel-Config site='new'; Multi-Site/Betreuer site='<label>:new' (legt im Konto DES LABELS an). "
107
+ + "WICHTIG beim ERST-IMPORT: Frag den Nutzer VOR dem Deploy nach der gewünschten Adresse "
108
+ + "(„Unter welcher Adresse soll die Website laufen?“, z. B. 'wuerttemberg' → wuerttemberg.prontosite.io) "
109
+ + "und übergib sie als slug – ohne slug wird die Adresse nur aus Konto-/Seitenname ABGELEITET (die Antwort "
110
+ + "nennt dann die abgeleitete Adresse; sie ist später änderbar). Bitte VOR dem Aufruf bestätigen lassen. "
111
+ + "REIHENFOLGE beim Erst-Import: zuerst Konto (account_create) → Firmendaten (company hier mitgeben ODER "
112
+ + "site_company_set) → Import; so stehen Impressum/Datenschutz sofort korrekt (ein Firmendaten-Set nach dem "
113
+ + "Import lässt die Rechts-Automatik automatisch neu laufen – kein zweiter Import nötig). "
114
+ + "WEBSITE-NAME: wird aus der Startseite abgeleitet (og:site_name → Titel-Präfix vor dem ersten Trenner → "
115
+ + "Header-H1/Logo-alt); mit name=… explizit setzen (Betreuer), falls die Ableitung danebenliegt. "
116
+ + "MARKER (wenn du das Template lokal baust): markiere im HTML mit dem Attribut data-ps, wo Bausteine "
117
+ + "erscheinen sollen – data-ps=\"formular:kontakt\" (verwaltetes Kontaktformular), data-ps=\"impressum\" bzw. "
118
+ + "data-ps=\"datenschutz\" (Rechtstext-Seite) und data-ps=\"link-impressum\" / data-ps=\"link-datenschutz\" "
119
+ + "(Footer-Links auf die Rechtstexte). Beim Import legt ProntoSite die Bausteine automatisch an. "
120
+ + "PFLICHT-MARKER: (a) Rechtsseiten impressum/index.html + datenschutz/index.html tragen IMMER den "
121
+ + "data-ps-Marker als Inhaltsbereich UND eine EIGENE <h1>; schreib KEINEN Rechtstext von Hand hinein. Weil "
122
+ + "die Seite ihre eigene H1 hat, die Widget-Überschrift ausschalten – site_legal_options mit showTitle:false "
123
+ + "(bei ?markers=auto setzt ProntoSite das automatisch, sobald die Rechtsseite eine eigene H1 hat). "
124
+ + "(b) Footer IMMER mit allen vier Markern link-impressum, link-datenschutz, cookie-einstellungen, "
125
+ + "cookie-richtlinie (sonst hängt das Consent-Widget einen ungestylten Block an den Seitenfuß; mehrsprachig "
126
+ + "je Sprach-Footer dieselben Marker). (c) Mobile-Menü-Toggle braucht aria-controls (Menü rendert als Drawer). "
127
+ + "Syntax-Referenz: https://prontosite.io/docs/marker-shortcodes",
128
+ { site: z.string().optional().describe(SITE_DESC + " Erst-Import: 'new' (Einzel) bzw. '<label>:new' (Multi-Site). Betreuer-Token: '<Konto-ID>:new' (k-…) legt im betreuten Konto an – die Konto-ID im Label ersetzt den customer-Parameter. sites_list zeigt betreute Konten OHNE Website (needsFirstImport) samt ihrer k-ID."),
129
+ slug: z.string().optional().describe("Erst-Import: gewünschte Web-Adresse (Subdomain-Label, z. B. 'wuerttemberg' → wuerttemberg.prontosite.io). Beim Erst-Import den Nutzer danach FRAGEN; wird sie weggelassen, leitet ProntoSite eine Adresse ab. Bei Updates ignoriert."),
130
+ name: z.string().optional().describe("Erst-Import: expliziter Website-Name (überschreibt die Ableitung aus der Startseite). Nützlich, wenn der Titel einen Ortszusatz trägt (z. B. 'Physio Phoenix – Bad Honnef Süd' → name='Physio Phoenix'). Bei Updates ignoriert."),
131
+ customer: z.string().optional().describe("Betreuer-Token + Erst-Import: Ziel-Konto-ID (k-…) für die neue Website. Alternativ direkt im site-Argument als '<Konto-ID>:new'."),
132
+ zipPath: z.string().describe("Pfad zur ZIP-Datei der Website (lokal)."),
133
+ publish: z.boolean().default(false).describe("true = nach dem Import direkt veröffentlichen (LIVE)."),
134
+ resetOverlay: z.boolean().default(false).describe("true = bisherige Dashboard-Änderungen verwerfen."),
135
+ legal: z.boolean().optional().describe("Rechts-Automatik (Impressum/Datenschutz): true erzwingt, false schaltet ab."),
136
+ company: companyObject.optional().describe("Optional: Firmendaten VOR dem Deploy setzen (ein Schritt) – Impressum/Datenschutz nutzen sie dann direkt. Teil-Update."),
137
+ siteOverride: z.boolean().default(false).describe("Nur mit company: true = Firmendaten NUR für diese Website (statt konto-weit).") },
138
+ async ({ site, slug, name, customer, zipPath, publish, resetOverlay, legal, company, siteOverride }) => {
139
+ try { return asText(await apiDeploy(cfg, { site, slug, name, customer, zipPath, publish, resetOverlay, legal, company, siteOverride })); } catch (e) { return asError(e); }
140
+ }
141
+ );
142
+
143
+ } else {
144
+ registerRemoteTransferTools(server, { z, cfg, remote, SITE_DESC, asText, asError });
145
+ }
146
+ server.tool(
147
+ "site_publish",
148
+ "SCHREIBEND: Veröffentlicht den aktuellen Stand einer Website ERNEUT (ohne ZIP) – z. B. um Plattform-"
149
+ + "Aktualisierungen live zu bringen. Meldet die Antwort nothing:true (keine erkannte Änderung), aber du "
150
+ + "willst trotzdem neu ausliefern, setze force:true. Bitte VOR dem Aufruf bestätigen lassen – und die richtige Website prüfen.",
151
+ { site: z.string().optional().describe(SITE_DESC),
152
+ force: z.boolean().default(false).describe("true = trotzdem veröffentlichen, auch ohne erkannte Änderung (wie der Menüpunkt Erneut veröffentlichen).") },
153
+ async ({ site, force }) => { try { return asText(await apiPublish(cfg, { site, force })); } catch (e) { return asError(e); } }
154
+ );
155
+
156
+ server.tool(
157
+ "site_company_get",
158
+ "Firmendaten (Impressum/Footer) einer Website lesen: effektiver Stand + konto-weite Angaben + ob eine "
159
+ + "Website-Abweichung gesetzt ist. LESEND – ändert nichts.",
160
+ { site: z.string().optional().describe(SITE_DESC) },
161
+ async ({ site }) => { try { return asText(await apiCompanyGet(cfg, { site })); } catch (e) { return asError(e); } }
162
+ );
163
+
164
+ server.tool(
165
+ "site_company_set",
166
+ "SCHREIBEND: Firmendaten des Kontos setzen/ergänzen (Name, Rechtsform, Inhaber, Anschrift, Kontakt, "
167
+ + "USt-IdNr., Register, Kammer/Beruf, Aufsicht, § 18 MStV, Social). Diese Daten füllen beim nächsten Deploy "
168
+ + "automatisch Impressum & Datenschutz. TEIL-UPDATE: nur die übergebenen Felder ändern sich. Du darfst die "
169
+ + "Angaben aus jeder Vorlage extrahieren (Impressum-Text, Visitenkarte, Freitext) und strukturiert übergeben. "
170
+ + "Default konto-weit; siteOverride=true nur, wenn DIESE eine Website abweichende Angaben braucht. NACH EINEM "
171
+ + "IMPORT lässt dieser Aufruf die Rechts-Automatik AUTOMATISCH neu laufen (Entwurf) – Impressum/Datenschutz "
172
+ + "übernehmen die Firmendaten sofort, ein zweiter Import ist NICHT nötig (die Antwort nennt die neu erzeugten "
173
+ + "Rechtstexte unter legal.refreshed); danach site_publish zum Live-Schalten. Bitte VOR dem Speichern die "
174
+ + "erkannten Felder kurz zusammenfassen und bestätigen lassen.",
175
+ { site: z.string().optional().describe(SITE_DESC),
176
+ company: companyObject,
177
+ siteOverride: z.boolean().default(false).describe("true = Firmendaten NUR für diese Website (statt konto-weit).") },
178
+ async ({ site, company, siteOverride }) => {
179
+ try { return asText(await apiCompanySet(cfg, { site, company, siteOverride })); } catch (e) { return asError(e); }
180
+ }
181
+ );
182
+
183
+ const legalOptShape = z.object({
184
+ showTitle: z.boolean().optional().describe("Widget-Überschrift des Rechtstextes anzeigen (Default an). Bringt die Rechtsseite ihre EIGENE <h1> mit (Pflicht-Marker-Konvention), hier showTitle:false setzen, sonst doppelte Überschrift."),
185
+ showDate: z.boolean().optional().describe("Stand-Datum anzeigen (Default an; nur wo vorhanden, i. d. R. Datenschutz)."),
186
+ draftNotice: z.boolean().optional().describe("Entwurfs-Hinweis in der VERÖFFENTLICHTEN Seite anzeigen (Default an)."),
187
+ }).describe("Anzeige-Optionen einer Rechtstext-Rolle – Teil-Update, nur gesetzte Booleans wirken.");
188
+
189
+ server.tool(
190
+ "site_legal_options",
191
+ "LESEND/SCHREIBEND: Anzeige-Optionen der Rechtstext-Widgets je Rolle (impressum/datenschutz): showTitle "
192
+ + "(Überschrift), showDate (Stand-Datum), draftNotice (Entwurfs-Hinweis in der Live-Ausgabe). OHNE impressum/"
193
+ + "datenschutz nur LESEN. Teil-Update (nur gesetzte Felder). HINWEIS: draftNotice:false blendet den rechtlich "
194
+ + "empfohlenen Entwurfs-Hinweis live aus (protokolliert) – bitte vorher bestätigen lassen. "
195
+ + "WICHTIG: Rechtsseiten sollen laut Konvention ihre EIGENE <h1> mitbringen (Marker data-ps=\"impressum\"/"
196
+ + "\"datenschutz\" als Inhaltsbereich) – dann hier showTitle:false setzen, damit keine doppelte Überschrift "
197
+ + "entsteht. MARKER (beim lokalen Bauen): Rechtstext-Seiten und -Links im Template mit data-ps markieren – "
198
+ + "data-ps=\"impressum\" / data-ps=\"datenschutz\" (Seite) bzw. data-ps=\"link-impressum\" / "
199
+ + "data-ps=\"link-datenschutz\" (Footer-Links). Syntax-Referenz: https://prontosite.io/docs/marker-shortcodes",
200
+ { site: z.string().optional().describe(SITE_DESC),
201
+ impressum: legalOptShape.optional(),
202
+ datenschutz: legalOptShape.optional() },
203
+ async ({ site, impressum, datenschutz }) => {
204
+ try { return asText(await apiLegalOptions(cfg, { site, impressum, datenschutz })); } catch (e) { return asError(e); }
205
+ }
206
+ );
207
+
208
+ server.tool(
209
+ "site_domain_status",
210
+ "LESEND: Kundendomain-Status einer Website – verbundene Domains (kanonisch/Weiterleitung), Zertifikat-"
211
+ + "Gültigkeit und offene Verbindungs-Jobs. Mit `check` stattdessen NUR eine Live-DNS-Prüfung der angegebenen "
212
+ + "Domain (A-Record für @ und www muss auf den Server zeigen, kein AAAA) – ideal VOR dem Verbinden.",
213
+ { site: z.string().optional().describe(SITE_DESC),
214
+ check: z.string().optional().describe("Domain (z. B. ihre-firma.de) NUR auf DNS-Bereitschaft prüfen, nichts verbinden.") },
215
+ async ({ site, check }) => { try { return asText(await apiDomainStatus(cfg, { site, check })); } catch (e) { return asError(e); } }
216
+ );
217
+
218
+ // Domain verbinden ist kundenwirksam: remote nur mit Betreuer-Token (Entscheidung 9a).
219
+ if (mode === "local" || remote?.isManager) {
220
+ server.tool(
221
+ "site_domain_connect",
222
+ "SCHREIBEND: eigene Domain mit der Website verbinden oder trennen. Beim Verbinden werden nginx-Serverblock "
223
+ + "und HTTPS-Zertifikat (Let's Encrypt) automatisch eingerichtet und die Domain als kanonische Adresse gesetzt "
224
+ + "(inkl. Republish für Canonical/Sitemap). VORAUSSETZUNG: der DNS zeigt bereits auf den Server – vorher IMMER "
225
+ + "site_domain_status mit `check` aufrufen und das Ergebnis bestätigen lassen. Nur ab Starter-Plan (Betreuer-/"
226
+ + "Operator-Token ausgenommen); eine Domain kann nur an EINER Website hängen. Läuft asynchron über eine Job-"
227
+ + "Queue – danach site_domain_status pollen. action='disconnect' trennt die Domain wieder.",
228
+ { site: z.string().optional().describe(SITE_DESC),
229
+ domain: z.string().describe("Die zu verbindende/trennende Domain, z. B. ihre-firma.de (mit oder ohne www)."),
230
+ canonical: z.enum(["www", "apex"]).default("www").describe("Kanonische Adresse: 'www' (mit www) oder 'apex' (ohne www). Nur beim Verbinden relevant."),
231
+ action: z.enum(["connect", "disconnect"]).default("connect").describe("connect = verbinden, disconnect = trennen.") },
232
+ async ({ site, domain, canonical, action }) => {
233
+ try { return asText(await apiDomainConnect(cfg, { site, domain, canonical, action })); } catch (e) { return asError(e); }
234
+ }
235
+ );
236
+ }
237
+ server.tool(
238
+ "site_embeds",
239
+ "LESEND: erkannte eingebettete Inhalte Dritter (iframes) einer Website – je Embed Host, Seite(n) und "
240
+ + "aktuelle Consent-Kategorie (`gated` = lädt erst nach Einwilligung / Zwei-Klick, `notwendig` = technisch "
241
+ + "erforderlich, lädt ohne Einwilligung). Basis für site_embed_consent.",
242
+ { site: z.string().optional().describe(SITE_DESC) },
243
+ async ({ site }) => { try { return asText(await apiEmbeds(cfg, { site })); } catch (e) { return asError(e); } }
244
+ );
245
+
246
+ server.tool(
247
+ "site_embed_consent",
248
+ "SCHREIBEND: ein Embed als `notwendig` (technisch erforderlich, § 25 Abs. 2 Nr. 2 TDDDG) oder zurück auf "
249
+ + "`gated` einstufen. Bei `notwendig` erklärt der Kunde/Betreuer, dass der Dienst für die vom Besucher "
250
+ + "gewünschte Funktion (z. B. Reservierung) UNBEDINGT ERFORDERLICH ist und übernimmt die Verantwortung – "
251
+ + "das MUSS mit dem Nutzer bestätigt werden (confirmed:true; sonst lehnt der Server mit dem Verantwortungs-"
252
+ + "Text ab). Dann lädt das Embed OHNE Einwilligung, aber nur auf der angegebenen Seite; der Datenschutz-Text "
253
+ + "passt sich automatisch an. Greift beim nächsten Veröffentlichen. Betreuer-Token gilt für betreute Kunden.",
254
+ { site: z.string().optional().describe(SITE_DESC),
255
+ host: z.string().describe("Embed-Host/Key aus site_embeds (z. B. 'resmio', 'youtube' oder ein Hostname)."),
256
+ page: z.string().optional().describe("Seiten-Datei, auf die die Einstufung beschränkt ist (z. B. 'reservierung.html'); leer = alle Seiten mit diesem Embed."),
257
+ category: z.enum(["notwendig", "gated"]).default("notwendig").describe("notwendig = technisch erforderlich (ohne Einwilligung); gated = zurück hinter das Consent-/Zwei-Klick-Gate."),
258
+ confirmed: z.boolean().default(false).describe("MUSS true sein, um 'notwendig' zu setzen (Verantwortungs-Bestätigung). Vorher den Nutzer explizit fragen.") },
259
+ async ({ site, host, page, category, confirmed }) => {
260
+ try { return asText(await apiEmbedConsent(cfg, { site, host, page, category, confirmed })); } catch (e) { return asError(e); }
261
+ }
262
+ );
263
+
264
+ server.tool(
265
+ "site_widgets",
266
+ "LESEND: Katalog der verwalteten Widgets einer Website – je Widget name, Titel, aktiv, aktuelle optionen und "
267
+ + "das Konfig-schema (Feldtypen/Defaults). Widgets sind in JEDEM Plan (auch Free) verfügbar. Bekannte Widgets: "
268
+ + "consent (Cookie-Banner), a11y (Barrierefreiheit-Hilfen), notice (Pop-Ups/Hinweise – Modal/Banner/inline, "
269
+ + "Listen-Widget optionen.items[]), google-reviews, ps-impressum, ps-datenschutz. Basis für site_widget_set.",
270
+ { site: z.string().optional().describe(SITE_DESC) },
271
+ async ({ site }) => { try { return asText(await apiWidgets(cfg, { site })); } catch (e) { return asError(e); } }
272
+ );
273
+
274
+ server.tool(
275
+ "site_widget_set",
276
+ "SCHREIBEND: ein Widget aktivieren/deaktivieren und/oder seine Optionen setzen – MIT DEMSELBEN Schema-Sanitizer "
277
+ + "wie das Dashboard (unbekannte/ungültige Felder werden abgewiesen). `optionen` muss zum schema aus site_widgets "
278
+ + "passen; Listen-Widgets (z. B. notice) erwarten { items: [ { id?, name?, aktiv?, ...Felder } ] }. Beispiel notice: "
279
+ + "optionen={ items:[{ titel:'Sommerpause', text:'**Geschlossen** bis 1.9.', darstellung:'modal', seiten:['index.html'], "
280
+ + "haeufigkeit:'tage', ruheTage:3 }] }. Greift beim nächsten Veröffentlichen (publishNeeded). Vorher mit dem Nutzer "
281
+ + "bestätigen (verändert die Website). Ergänzt die Einzel-Tools site_legal_options/site_embed_consent, ersetzt sie nicht. "
282
+ + "Betreuer-Token gilt für betreute Kunden.",
283
+ { site: z.string().optional().describe(SITE_DESC),
284
+ widget: z.string().describe("Widget-Name aus site_widgets (z. B. 'notice', 'consent', 'a11y', 'google-reviews', 'ps-impressum', 'ps-datenschutz')."),
285
+ aktiv: z.boolean().optional().describe("Widget an/aus. Weglassen = unverändert (nur optionen setzen)."),
286
+ optionen: z.any().optional().describe("Konfig-Objekt gemäß schema aus site_widgets. Listen-Widgets: { items: [...] }. Weglassen = aktiv-Status unverändert lassen.") },
287
+ async ({ site, widget, aktiv, optionen }) => {
288
+ try { return asText(await apiWidgetSet(cfg, { site, widget, aktiv, optionen })); } catch (e) { return asError(e); }
289
+ }
290
+ );
291
+
292
+ // ---- Datei-API: kleine Änderungen am Entwurf direkt auf dem Server (Konzept §7) ----
293
+ const FILE_SMALL = "Für kleine Änderungen am Entwurf ohne lokale Kopie; für größere Umbauten site_export (which='draft') → lokal bearbeiten → site_deploy. ";
294
+ server.tool(
295
+ "site_files",
296
+ "LESEND: Listet die Dateien des Entwurfs einer Website (Pfad, Größe, Typ, ob bearbeitbar) und die aktuelle draftRev. "
297
+ + FILE_SMALL + "Ruf VORHER site_status auf; zeigt es kürzliche Änderungen anderer (recentEdits), sag das dem Nutzer, bevor du etwas änderst.",
298
+ { site: z.string().optional().describe(SITE_DESC) },
299
+ async ({ site }) => { try { return asText(await apiFiles(cfg, { site })); } catch (e) { return asError(e); } }
300
+ );
301
+ server.tool(
302
+ "site_file_get",
303
+ "LESEND: Liefert den Quelltext einer Datei des Entwurfs (Text; Binär als Base64, max. 5 MB) samt draftRev. "
304
+ + "Der Block overlay nennt Dashboard-Änderungen, die im Entwurf ÜBER diesem Quelltext liegen (sie gewinnen sichtbar) – "
305
+ + "berücksichtige sie, bevor du Text änderst. Merke dir draftRev: du brauchst sie als baseRev für site_file_put. " + FILE_SMALL,
306
+ { site: z.string().optional().describe(SITE_DESC), path: z.string().describe("Relativer Pfad, z. B. 'index.html' oder 'css/style.css' (aus site_files).") },
307
+ async ({ site, path }) => { try { return asText(await apiFileGet(cfg, { site, path })); } catch (e) { return asError(e); } }
308
+ );
309
+ server.tool(
310
+ "site_file_put",
311
+ "SCHREIBEND (Entwurf, nicht live; nur Betreuer-Token): Ersetzt den Inhalt einer Text-Datei (HTML einer bestehenden Seite, "
312
+ + "CSS, SVG, JSON, TXT …). PFLICHT: baseRev = draftRev aus deinem letzten site_file_get/site_status. Antwortet der Server mit "
313
+ + "conflict:true (409), hat jemand anderes dieselbe Datei inzwischen geändert: NICHT erzwingen – Datei neu holen, Änderung neu "
314
+ + "anwenden, mit der neuen draftRev erneut senden; die Antwort listet wer/wann/was. Prüfe danach overlay (umgehängte/deaktivierte "
315
+ + "Dashboard-Änderungen), sanitized und warnings und berichte sie dem Nutzer. Rechtstexte (Impressum/Datenschutz) nicht "
316
+ + "umformulieren – der Server lehnt das ab. Danach: site_status zeigt publishNeeded, live erst mit site_publish (vorher "
317
+ + "bestätigen lassen). " + FILE_SMALL,
318
+ { site: z.string().optional().describe(SITE_DESC),
319
+ path: z.string().describe("Relativer Pfad der Datei (bestehende Seite oder Text-Asset)."),
320
+ content: z.string().describe("Vollständiger neuer Dateiinhalt (Text, UTF-8, max. 1 MB)."),
321
+ baseRev: z.number().int().describe("draftRev, auf der deine Änderung beruht (aus site_file_get/site_status)."),
322
+ overlay: z.enum(["keep", "yield"]).default("keep").describe("keep = Dashboard-Änderungen bleiben über dem Quelltext; yield = wo deine Datei denselben Inhalt geändert hat, gewinnt die Datei.") },
323
+ async ({ site, path, content, baseRev, overlay }) => { try { return asText(await apiFilePut(cfg, { site, path, content, baseRev, overlay })); } catch (e) { return asError(e); } }
324
+ );
325
+ server.tool(
326
+ "site_file_delete",
327
+ "SCHREIBEND (Entwurf; nur Betreuer-Token): Markiert eine Text-Datei im Entwurf als gelöscht (rückgängig machbar; Seiten "
328
+ + "löschen geht über das Dashboard). baseRev Pflicht, confirmDestructive:true erst NACH Rückfrage beim Nutzer. Prüfe vorher, "
329
+ + "ob die Datei noch verlinkt ist. " + FILE_SMALL,
330
+ { site: z.string().optional().describe(SITE_DESC), path: z.string(), baseRev: z.number().int(),
331
+ confirmDestructive: z.boolean().default(false).describe("true erst nach ausdrücklicher Bestätigung durch den Nutzer.") },
332
+ async ({ site, path, baseRev, confirmDestructive }) => { try { return asText(await apiFileDelete(cfg, { site, path, baseRev, confirmDestructive })); } catch (e) { return asError(e); } }
333
+ );
334
+ server.tool(
335
+ "site_file_rename",
336
+ "SCHREIBEND (Entwurf; nur Betreuer-Token): Benennt eine Text-Datei (kein HTML) um. Verweise in anderen Dateien werden NICHT "
337
+ + "angepasst – die musst du selbst per site_file_put nachziehen. baseRev Pflicht. " + FILE_SMALL,
338
+ { site: z.string().optional().describe(SITE_DESC), from: z.string(), to: z.string(), baseRev: z.number().int() },
339
+ async ({ site, from, to, baseRev }) => { try { return asText(await apiFileRename(cfg, { site, from, to, baseRev })); } catch (e) { return asError(e); } }
340
+ );
341
+
342
+ server.tool(
343
+ "site_lock",
344
+ "Setzt eine sanfte Sperre „in Bearbeitung durch <dich>“ (2 h, verlängert sich bei jeder deiner Änderungen). Blockiert "
345
+ + "niemanden – andere sehen nur einen Hinweis und bekommen beim Speichern eine Warnung. Vor einer längeren Bearbeitungsserie "
346
+ + "setzen. Ist schon jemand anderes in Bearbeitung, kommt conflict/409 mit dem Hinweis – takeover:true NUR als Betreuer und nur "
347
+ + "nach Rückfrage beim Nutzer (die andere Person sieht die Übernahme).",
348
+ { site: z.string().optional().describe(SITE_DESC),
349
+ note: z.string().optional().describe("Optionale Notiz, z. B. „Startseite überarbeiten“ (andere sehen sie im Hinweis)."),
350
+ takeover: z.boolean().default(false).describe("true = fremde Sperre übernehmen (nur Betreuer, nach Rückfrage).") },
351
+ async ({ site, note, takeover }) => { try { return asText(await apiLock(cfg, { site, note, action: takeover ? "takeover" : "lock" })); } catch (e) { return asError(e); } }
352
+ );
353
+ server.tool(
354
+ "site_unlock",
355
+ "Gibt deine sanfte Sperre („in Bearbeitung“) frei – am Ende der Bearbeitung aufrufen. Ohne Sperre ein No-op.",
356
+ { site: z.string().optional().describe(SITE_DESC) },
357
+ async ({ site }) => { try { return asText(await apiLock(cfg, { site, action: "unlock" })); } catch (e) { return asError(e); } }
358
+ );
359
+ }
360
+
361
+ /** Remote: Export als Download-Link, Deploy per Upload-Link (der Remote-Server kann keine lokalen Pfade lesen). */
362
+ function registerRemoteTransferTools(server, { z, cfg, remote, SITE_DESC, asText, asError }) {
363
+ server.tool(
364
+ "site_export",
365
+ "LESEND: Exportiert eine Website als ZIP und liefert einen DOWNLOAD-LINK (15 Minuten gültig, nur EINMAL abrufbar). "
366
+ + "which: 'published' (Live-Stand, Standard), 'draft' (Entwurf – für lokales Bearbeiten und späteres deploy_upload_begin) oder "
367
+ + "'original' (flacher Stand ohne ProntoSite-Zutaten). Den Link mit curl/Browser laden, z. B. curl -o site.zip '<downloadUrl>'.",
368
+ { site: z.string().optional().describe(SITE_DESC),
369
+ which: z.enum(["published", "draft", "original"]).default("published") },
370
+ async ({ site, which }) => {
371
+ try {
372
+ const { buf, name, slug } = await apiExportFetch(cfg, { site, which });
373
+ return asText({ ...remote.storeDownload(buf, name), which, site: slug });
374
+ } catch (e) { return asError(e); }
375
+ }
376
+ );
377
+ server.tool(
378
+ "deploy_upload_begin",
379
+ "SCHREIBEND (Schritt 1 von 2): ZIP-Deploy vorbereiten. Liefert einen UPLOAD-LINK (HTTP PUT, 15 Minuten, einmalig, max. "
380
+ + "100 MB) für die ZIP-Datei der Website, z. B. curl -T site.zip '<uploadUrl>'. Danach deploy_commit mit der uploadId. "
381
+ + "Ein Deploy ersetzt das Original der Website (rückholbar über die Re-Import-Zeitleiste); mit publish=true geht der Stand "
382
+ + "DIREKT LIVE. Ab dem Starter-Tarif. Bitte VOR dem Aufruf bestätigen lassen – und die richtige Website prüfen. "
383
+ + "Für kleine Änderungen ohne ZIP: site_file_put. Exportiere vorher mit site_export (which='draft'): die ZIP trägt dann "
384
+ + "ihren Stand (draftRev) und der Server meldet 409, wenn der Entwurf inzwischen geändert wurde.",
385
+ { site: z.string().optional().describe(SITE_DESC + (remote?.isManager ? " Erst-Import (Betreuer): '<Konto-ID>:new' (k-…)." : "")),
386
+ slug: z.string().optional().describe("Erst-Import: gewünschte Web-Adresse (Subdomain-Label). Bei Updates ignoriert."),
387
+ name: z.string().optional().describe("Erst-Import: expliziter Website-Name. Bei Updates ignoriert."),
388
+ publish: z.boolean().default(false).describe("true = nach dem Import direkt veröffentlichen (LIVE)."),
389
+ resetOverlay: z.boolean().default(false).describe("true = bisherige Dashboard-Änderungen verwerfen."),
390
+ legal: z.boolean().optional().describe("Rechts-Automatik (Impressum/Datenschutz): true erzwingt, false schaltet ab.") },
391
+ async ({ site, slug, name, publish, resetOverlay, legal }) => {
392
+ try { return asText(remote.beginUpload({ site, slug, name, publish, resetOverlay, legal })); } catch (e) { return asError(e); }
393
+ }
394
+ );
395
+ server.tool(
396
+ "deploy_commit",
397
+ "SCHREIBEND (Schritt 2 von 2): übernimmt die per deploy_upload_begin hochgeladene ZIP und führt den Deploy aus (Optionen aus "
398
+ + "Schritt 1). Antwort wie beim Deploy: Bericht, ggf. 409 (Entwurf inzwischen geändert – nicht erzwingen, neu exportieren). "
399
+ + "Der Upload-Link ist danach verbraucht.",
400
+ { uploadId: z.string().describe("uploadId aus deploy_upload_begin.") },
401
+ async ({ uploadId }) => {
402
+ let up = null;
403
+ try {
404
+ up = remote.takeUpload(uploadId);
405
+ const o = up.options;
406
+ return asText(await apiDeploy(cfg, { site: o.site, slug: o.slug, name: o.name, zipPath: up.path, publish: o.publish,
407
+ resetOverlay: o.resetOverlay, legal: o.legal }));
408
+ } catch (e) { return asError(e); } finally { if (up) up.done(); }
409
+ }
410
+ );
411
+ }