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