@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 +8 -0
- package/api.mjs +36 -29
- package/index.mjs +5 -332
- package/package.json +4 -3
- package/tools.mjs +411 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
136
|
-
export async function
|
|
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)
|
|
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
|
-
|
|
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
|
|
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.
|
|
23
|
-
|
|
24
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|