@bettercms-ai/mcp 0.9.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/dist/index.js ADDED
@@ -0,0 +1,1266 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { realpathSync } from "fs";
5
+ import { fileURLToPath } from "url";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+
8
+ // src/config.ts
9
+ import { homedir } from "os";
10
+ import { join } from "path";
11
+ var DEFAULT_API_URL = "https://api.bettercms.ai";
12
+ function loadConfig(env = process.env) {
13
+ const apiUrl = (env.BETTERCMS_API_URL?.trim() || DEFAULT_API_URL).replace(/\/+$/, "");
14
+ return {
15
+ apiUrl,
16
+ deviceBaseUrl: `${apiUrl}/api/v1/auth/device`,
17
+ managementBaseUrl: `${apiUrl}/api/v1`,
18
+ credentialsPath: env.BETTERCMS_MCP_CREDENTIALS?.trim() || join(homedir(), ".bettercms", "mcp-credentials.json"),
19
+ clientName: env.BETTERCMS_MCP_CLIENT_NAME?.trim() || "BetterCMS MCP"
20
+ };
21
+ }
22
+
23
+ // src/token-store.ts
24
+ import { mkdir, readFile, writeFile } from "fs/promises";
25
+ import { dirname } from "path";
26
+ var FileTokenStore = class {
27
+ constructor(path, key) {
28
+ this.path = path;
29
+ this.key = key;
30
+ this.pendingKey = `${key}::pending`;
31
+ }
32
+ path;
33
+ key;
34
+ /** Pending authorizations live under a sibling key so they never shadow creds. */
35
+ pendingKey;
36
+ async readAll() {
37
+ try {
38
+ const raw = await readFile(this.path, "utf-8");
39
+ const parsed = JSON.parse(raw);
40
+ return parsed && typeof parsed === "object" ? parsed : {};
41
+ } catch {
42
+ return {};
43
+ }
44
+ }
45
+ async writeAll(all) {
46
+ await mkdir(dirname(this.path), { recursive: true });
47
+ await writeFile(this.path, JSON.stringify(all, null, 2), { mode: 384 });
48
+ }
49
+ async read() {
50
+ const all = await this.readAll();
51
+ return all[this.key] ?? null;
52
+ }
53
+ async write(creds) {
54
+ const all = await this.readAll();
55
+ all[this.key] = creds;
56
+ await this.writeAll(all);
57
+ }
58
+ async clear() {
59
+ const all = await this.readAll();
60
+ delete all[this.key];
61
+ await this.writeAll(all);
62
+ }
63
+ async readPending() {
64
+ const all = await this.readAll();
65
+ return all[this.pendingKey] ?? null;
66
+ }
67
+ async writePending(pending) {
68
+ const all = await this.readAll();
69
+ all[this.pendingKey] = pending;
70
+ await this.writeAll(all);
71
+ }
72
+ async clearPending() {
73
+ const all = await this.readAll();
74
+ delete all[this.pendingKey];
75
+ await this.writeAll(all);
76
+ }
77
+ };
78
+
79
+ // src/device-auth.ts
80
+ var EXPIRY_SKEW_MS = 6e4;
81
+ var GRACE_POLL_MS = 25e3;
82
+ var DeviceAuthError = class extends Error {
83
+ constructor(message) {
84
+ super(message);
85
+ this.name = "DeviceAuthError";
86
+ }
87
+ };
88
+ var DeviceAuthPendingError = class extends Error {
89
+ verificationUri;
90
+ verificationUriComplete;
91
+ userCode;
92
+ expiresAt;
93
+ constructor(pending) {
94
+ super("Authorization pending \u2014 approve in the browser, then retry.");
95
+ this.name = "DeviceAuthPendingError";
96
+ this.verificationUri = pending.verificationUri;
97
+ this.verificationUriComplete = pending.verificationUriComplete;
98
+ this.userCode = pending.userCode;
99
+ this.expiresAt = pending.expiresAt;
100
+ }
101
+ };
102
+ var DeviceAuthClient = class {
103
+ constructor(config, store, deps = {}) {
104
+ this.config = config;
105
+ this.store = store;
106
+ this.fetchImpl = deps.fetch ?? globalThis.fetch;
107
+ this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
108
+ this.log = deps.log ?? ((m) => process.stderr.write(`${m}
109
+ `));
110
+ this.now = deps.now ?? (() => Date.now());
111
+ }
112
+ config;
113
+ store;
114
+ fetchImpl;
115
+ sleep;
116
+ log;
117
+ now;
118
+ inFlight = null;
119
+ refreshInFlight = null;
120
+ /** Return a valid access token, doing the least work necessary. Single-flighted. */
121
+ async getAccessToken() {
122
+ if (this.inFlight) return this.inFlight;
123
+ this.inFlight = this.resolveToken().finally(() => {
124
+ this.inFlight = null;
125
+ });
126
+ return this.inFlight;
127
+ }
128
+ async resolveToken() {
129
+ const creds = await this.store.read();
130
+ if (creds && creds.accessTokenExpiresAt - this.now() > EXPIRY_SKEW_MS) {
131
+ return creds.accessToken;
132
+ }
133
+ if (creds?.refreshToken) {
134
+ const refreshed = await this.refresh();
135
+ if (refreshed) return refreshed;
136
+ }
137
+ return this.runDeviceFlow();
138
+ }
139
+ /**
140
+ * Resume a still-live authorization if one is persisted, otherwise start a
141
+ * fresh one; then grace-poll. Throws {@link DeviceAuthPendingError} (carrying
142
+ * the activation link) if the user hasn't approved within the grace window.
143
+ */
144
+ async runDeviceFlow() {
145
+ let pending = await this.store.readPending();
146
+ if (pending && pending.expiresAt - this.now() <= EXPIRY_SKEW_MS) {
147
+ await this.store.clearPending();
148
+ pending = null;
149
+ }
150
+ if (!pending) {
151
+ pending = await this.startDeviceFlow();
152
+ }
153
+ const graceDeadline = Math.min(this.now() + GRACE_POLL_MS, pending.expiresAt);
154
+ const token = await this.pollForApproval(pending, graceDeadline);
155
+ if (token) return token;
156
+ throw new DeviceAuthPendingError(pending);
157
+ }
158
+ /** Request a fresh device code, persist it as pending, and log a breadcrumb. */
159
+ async startDeviceFlow() {
160
+ const start = await this.fetchImpl(`${this.config.deviceBaseUrl}/code`, {
161
+ method: "POST",
162
+ headers: { "Content-Type": "application/json" },
163
+ body: JSON.stringify({ client_name: this.config.clientName })
164
+ });
165
+ if (!start.ok) {
166
+ throw new DeviceAuthError(
167
+ `Failed to start device authorization (HTTP ${start.status}).`
168
+ );
169
+ }
170
+ const code = await start.json();
171
+ const pending = {
172
+ deviceCode: code.device_code,
173
+ userCode: code.user_code,
174
+ verificationUri: code.verification_uri,
175
+ verificationUriComplete: code.verification_uri_complete ?? `${code.verification_uri}?code=${encodeURIComponent(code.user_code)}`,
176
+ intervalSeconds: code.interval,
177
+ expiresAt: this.now() + code.expires_in * 1e3
178
+ };
179
+ await this.store.writePending(pending);
180
+ this.log("");
181
+ this.log("\u250C\u2500 BetterCMS authorization required \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
182
+ this.log(`\u2502 Visit: ${pending.verificationUri}`);
183
+ this.log(`\u2502 Enter code: ${pending.userCode}`);
184
+ this.log(`\u2502 Or open: ${pending.verificationUriComplete}`);
185
+ this.log("\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
186
+ return pending;
187
+ }
188
+ /**
189
+ * Poll the token endpoint until `deadline`. Returns the access token on
190
+ * approval, or null if the deadline passes while still pending. Throws
191
+ * {@link DeviceAuthError} on a terminal outcome (denied / expired).
192
+ */
193
+ async pollForApproval(pending, deadline) {
194
+ let intervalMs = pending.intervalSeconds * 1e3;
195
+ while (this.now() < deadline) {
196
+ await this.sleep(intervalMs);
197
+ if (this.now() >= deadline) break;
198
+ const res = await this.fetchImpl(`${this.config.deviceBaseUrl}/token`, {
199
+ method: "POST",
200
+ headers: { "Content-Type": "application/json" },
201
+ body: JSON.stringify({
202
+ device_code: pending.deviceCode,
203
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
204
+ })
205
+ });
206
+ if (res.ok) {
207
+ const body = await res.json();
208
+ await this.store.clearPending();
209
+ this.log("[bettercms-mcp] authorized \u2713");
210
+ return this.persist(body);
211
+ }
212
+ const err = await res.json().catch(() => ({}));
213
+ switch (err.error) {
214
+ case "authorization_pending":
215
+ continue;
216
+ case "slow_down":
217
+ intervalMs += 5e3;
218
+ continue;
219
+ case "access_denied":
220
+ await this.store.clearPending();
221
+ throw new DeviceAuthError("Authorization was denied.");
222
+ case "expired_token":
223
+ await this.store.clearPending();
224
+ throw new DeviceAuthError("The device code expired before approval. Try again.");
225
+ default:
226
+ throw new DeviceAuthError(
227
+ `Device authorization failed: ${err.error ?? `HTTP ${res.status}`}.`
228
+ );
229
+ }
230
+ }
231
+ return null;
232
+ }
233
+ /**
234
+ * Exchange the stored refresh token for a new access token. Single-flighted:
235
+ * the device `/refresh` endpoint is single-use (it rotates the refresh token
236
+ * and revokes the prior access key), so a burst of concurrent 401s must NOT
237
+ * each fire their own refresh — the first would rotate, and the rest would
238
+ * send the now-stale token, get `invalid_grant`, and wipe the freshly-minted
239
+ * credentials. Collapsing them into one in-flight rotation keeps the session
240
+ * alive without a needless re-auth.
241
+ */
242
+ async refresh() {
243
+ if (this.refreshInFlight) return this.refreshInFlight;
244
+ this.refreshInFlight = this.doRefresh().finally(() => {
245
+ this.refreshInFlight = null;
246
+ });
247
+ return this.refreshInFlight;
248
+ }
249
+ /**
250
+ * Forget the cached credentials and start a fresh device flow. Called when the
251
+ * bound project was deleted server-side (a key bound to a dead project can never
252
+ * succeed again) — clearing lets the user re-authorize against a LIVE project.
253
+ * Returns a new token if approval is fast, else throws {@link DeviceAuthPendingError}
254
+ * carrying the activation link (the next tool call resumes into the new project).
255
+ */
256
+ async resetAndReauthorize() {
257
+ await this.store.clear();
258
+ await this.store.clearPending();
259
+ return this.getAccessToken();
260
+ }
261
+ async doRefresh() {
262
+ const creds = await this.store.read();
263
+ if (!creds?.refreshToken) return null;
264
+ let res;
265
+ try {
266
+ res = await this.fetchImpl(`${this.config.deviceBaseUrl}/refresh`, {
267
+ method: "POST",
268
+ headers: { "Content-Type": "application/json" },
269
+ body: JSON.stringify({ refresh_token: creds.refreshToken })
270
+ });
271
+ } catch {
272
+ return null;
273
+ }
274
+ if (res.ok) {
275
+ const body = await res.json();
276
+ return this.persist(body);
277
+ }
278
+ const err = await res.json().catch(() => ({}));
279
+ if (err.error === "invalid_grant" || res.status === 401 || res.status === 403) {
280
+ await this.store.clear();
281
+ }
282
+ return null;
283
+ }
284
+ async persist(body) {
285
+ const creds = {
286
+ accessToken: body.access_token,
287
+ refreshToken: body.refresh_token,
288
+ accessTokenExpiresAt: this.now() + body.expires_in * 1e3,
289
+ workspaceId: body.workspace_id,
290
+ projectId: body.project_id
291
+ };
292
+ await this.store.write(creds);
293
+ return creds.accessToken;
294
+ }
295
+ };
296
+
297
+ // src/server.ts
298
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
299
+ import { BetterCMS } from "@bettercms-ai/sdk";
300
+
301
+ // src/tools.ts
302
+ import { z } from "zod";
303
+ import { BetterCMSError } from "@bettercms-ai/sdk";
304
+ var fieldType = z.enum([
305
+ "text",
306
+ "richtext",
307
+ "image",
308
+ "boolean",
309
+ "number",
310
+ "select",
311
+ "reference",
312
+ "multi-reference",
313
+ "array",
314
+ "date",
315
+ "datetime",
316
+ "group",
317
+ // Non-Repeatable Zone: one nested object of fields
318
+ "repeater"
319
+ // Repeatable Zone: an array of nested field-objects
320
+ ]);
321
+ var slug = z.string().regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only");
322
+ var fieldKey = z.string().regex(/^[a-zA-Z0-9_]+$/, "letters, numbers, and underscores only");
323
+ var fieldShape = {
324
+ key: fieldKey.describe("machine field key, e.g. 'title' or 'blog_hero'"),
325
+ label: z.string().min(1).describe("human label shown in the editor"),
326
+ type: fieldType,
327
+ required: z.boolean().optional(),
328
+ options: z.array(z.string()).optional().describe("choices when type is 'select'"),
329
+ config: z.record(z.string(), z.unknown()).optional().describe(
330
+ "per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, array {itemType: 'text'|'number'|'date'}, date {includeTime}"
331
+ ),
332
+ fields: z.array(z.lazy(() => fieldObject)).optional().describe(
333
+ "NESTED child fields \u2014 REQUIRED for type 'group' (one nested object, a Non-Repeatable Zone like blog_hero \u2192 heading, description, hero_image) and type 'repeater' (a repeatable array of such objects, a Repeatable Zone / section-list like testimonials \u2192 quote, author). A section with repeating items is a 'repeater'; a fixed grouped block is a 'group'. Recurse to any depth \u2014 do NOT flatten zones into separate top-level fields."
334
+ )
335
+ };
336
+ var fieldObject = z.object(fieldShape);
337
+ function toFields(fs) {
338
+ return (fs ?? []).map(toField);
339
+ }
340
+ function toField(f) {
341
+ const base = {
342
+ key: f.key,
343
+ label: f.label,
344
+ ...f.required !== void 0 ? { required: f.required } : {}
345
+ };
346
+ if (f.type === "group") {
347
+ return { ...base, type: "array", config: { zones: { nonRepeatable: toFields(f.fields) } } };
348
+ }
349
+ if (f.type === "repeater") {
350
+ return { ...base, type: "array", config: { zones: { repeatable: { fields: toFields(f.fields) } } } };
351
+ }
352
+ if (f.type === "array" && f.config && typeof f.config === "object" && "zones" in f.config) {
353
+ const zones = f.config.zones ?? {};
354
+ return {
355
+ ...base,
356
+ type: "array",
357
+ config: {
358
+ zones: {
359
+ ...zones.nonRepeatable ? { nonRepeatable: toFields(zones.nonRepeatable) } : {},
360
+ ...zones.repeatable ? {
361
+ repeatable: {
362
+ fields: toFields(zones.repeatable.fields),
363
+ ...zones.repeatable.minItems !== void 0 ? { minItems: zones.repeatable.minItems } : {},
364
+ ...zones.repeatable.maxItems !== void 0 ? { maxItems: zones.repeatable.maxItems } : {}
365
+ }
366
+ } : {}
367
+ }
368
+ }
369
+ };
370
+ }
371
+ return {
372
+ ...base,
373
+ type: f.type,
374
+ ...f.options ? { options: f.options } : {},
375
+ ...f.config ? { config: f.config } : {}
376
+ };
377
+ }
378
+ function ok(summary, data) {
379
+ return {
380
+ content: [
381
+ { type: "text", text: summary },
382
+ { type: "text", text: JSON.stringify(data, null, 2) }
383
+ ]
384
+ };
385
+ }
386
+ function fail(message) {
387
+ return { content: [{ type: "text", text: message }], isError: true };
388
+ }
389
+ function authPrompt(err) {
390
+ const text = [
391
+ "\u{1F510} BetterCMS authorization required \u2014 you're not signed in yet.",
392
+ "",
393
+ `1. Open this link and approve: ${err.verificationUriComplete}`,
394
+ ` (or visit ${err.verificationUri} and enter code ${err.userCode})`,
395
+ "2. Once approved, run this tool again \u2014 it resumes automatically and completes your request."
396
+ ].join("\n");
397
+ return { content: [{ type: "text", text }], isError: true };
398
+ }
399
+ function buildToolDefs(deps) {
400
+ async function withClient(fn) {
401
+ const token = await deps.auth.getAccessToken();
402
+ try {
403
+ return await fn(deps.createClient(token));
404
+ } catch (err) {
405
+ if (err instanceof BetterCMSError && err.status === 401) {
406
+ const next = await deps.auth.refresh() ?? await deps.auth.getAccessToken();
407
+ return await fn(deps.createClient(next));
408
+ }
409
+ if (err instanceof BetterCMSError && err.status === 409 && err.bodyCode === "PROJECT_DELETED") {
410
+ const next = await deps.auth.resetAndReauthorize();
411
+ return await fn(deps.createClient(next));
412
+ }
413
+ throw err;
414
+ }
415
+ }
416
+ function guard(fn) {
417
+ return async (args) => {
418
+ try {
419
+ return await fn(args);
420
+ } catch (err) {
421
+ if (err instanceof DeviceAuthPendingError) {
422
+ return authPrompt(err);
423
+ }
424
+ if (err instanceof BetterCMSError) {
425
+ return fail(`BetterCMS error (${err.status} ${err.code}): ${err.message}`);
426
+ }
427
+ return fail(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
428
+ }
429
+ };
430
+ }
431
+ const createPageInput = z.object({
432
+ title: z.string().min(1).describe("display title of the page, e.g. 'Home'"),
433
+ slug: slug.describe("URL-safe path segment, unique per project, e.g. 'home'"),
434
+ pageType: z.enum(["singleton", "dynamic"]).default("singleton").describe(
435
+ "'singleton' = exactly one entry (Home, About, Contact); 'dynamic' = many entries sharing this schema (Blog posts, Products). Defaults to singleton."
436
+ ),
437
+ fields: z.array(fieldObject).optional().describe("the page's typed schema fields"),
438
+ metaTitle: z.string().optional().describe("SEO meta title"),
439
+ metaDescription: z.string().optional().describe("SEO meta description")
440
+ });
441
+ const addFieldInput = z.object({
442
+ modelId: z.string().min(1).describe("id of the content model to extend"),
443
+ ...fieldShape
444
+ });
445
+ const addPageFieldInput = z.object({
446
+ pageId: z.string().min(1).describe("id of the page to extend (from list_pages / create_page)"),
447
+ ...fieldShape
448
+ });
449
+ const uploadAssetInput = z.object({
450
+ localPath: z.string().min(1).optional().describe("absolute path to a local file (e.g. a repo image); provide this OR url"),
451
+ url: z.string().url().optional().describe("remote image URL to ingest; provide this OR localPath"),
452
+ filename: z.string().optional().describe("override the stored filename"),
453
+ altText: z.string().optional().describe("accessibility alt text"),
454
+ caption: z.string().optional(),
455
+ folderId: z.string().optional().describe("target Media Library folder (defaults to project root)")
456
+ });
457
+ const createEntryInput = z.object({
458
+ contentModelId: z.string().min(1).describe("id of the model this entry belongs to"),
459
+ slug: slug.optional(),
460
+ status: z.enum(["draft", "published"]).optional().describe("defaults to draft"),
461
+ data: z.record(z.string(), z.unknown()).optional().describe("field values keyed by field key")
462
+ });
463
+ const getPageInput = z.object({
464
+ pageId: z.string().min(1).describe("page id (from list_pages / create_page)")
465
+ });
466
+ const setPageContentInput = z.object({
467
+ pageId: z.string().min(1).describe("id of the page to write values to"),
468
+ data: z.record(z.string(), z.unknown()).describe(
469
+ "field values keyed by field key. A nested 'array' (zone) value is an OBJECT { nonRepeatable: { childKey: value, \u2026 }, repeatable: [ { childKey: value }, \u2026 ] } \u2014 nonRepeatable holds the fixed-block values, repeatable is the list of item objects (omit a zone you didn't define). A primitive 'array' (itemType) is a plain list. An 'image' value is an asset URL or asset id (from upload_asset) \u2014 the server resolves it to { id, url, name, altText }. Read get_page first to see each field's zones."
470
+ ),
471
+ status: z.enum(["draft", "published"]).optional().describe("omit to leave status unchanged")
472
+ });
473
+ const getEntryInput = z.object({
474
+ entryId: z.string().min(1).describe("content entry id")
475
+ });
476
+ const listEntriesInput = z.object({
477
+ modelId: z.string().optional().describe("filter by content model id"),
478
+ pageId: z.string().optional().describe("filter by page id (a singleton page has one entry)"),
479
+ status: z.enum(["draft", "published"]).optional()
480
+ });
481
+ const updateEntryInput = z.object({
482
+ entryId: z.string().min(1).describe("content entry id"),
483
+ data: z.record(z.string(), z.unknown()).optional().describe("field values keyed by field key"),
484
+ status: z.enum(["draft", "published"]).optional(),
485
+ slug: slug.optional()
486
+ });
487
+ const deletePageInput = z.object({
488
+ pageId: z.string().min(1).describe("id of the page to delete (from list_pages)")
489
+ });
490
+ const deleteEntryInput = z.object({
491
+ entryId: z.string().min(1).describe("id of the content entry to delete (from list_entries)")
492
+ });
493
+ const deleteModelInput = z.object({
494
+ modelId: z.string().min(1).describe("id of the content model to delete (from list_content_models)")
495
+ });
496
+ const getFormInput = z.object({
497
+ formId: z.string().min(1).describe("form id (from list_forms)")
498
+ });
499
+ const formFieldObject = z.object({
500
+ key: z.string().min(1).describe("machine key for the submitted value, e.g. 'email'"),
501
+ label: z.string().min(1).describe("field label shown to the visitor"),
502
+ type: z.enum([
503
+ "text",
504
+ "email",
505
+ "textarea",
506
+ "select",
507
+ "checkbox",
508
+ "number",
509
+ "phone",
510
+ "date",
511
+ "url",
512
+ "consent",
513
+ "hidden"
514
+ ]),
515
+ placeholder: z.string().optional(),
516
+ required: z.boolean().optional(),
517
+ options: z.array(z.string()).optional().describe("choices when type is 'select'"),
518
+ defaultValue: z.string().optional(),
519
+ showIf: z.object({ field: z.string(), equals: z.string() }).optional().describe("show this field only when another field equals a value")
520
+ });
521
+ const formSettingsShape = {
522
+ description: z.string().optional(),
523
+ submitLabel: z.string().optional().describe("submit button label (default 'Submit')"),
524
+ successMessage: z.string().optional(),
525
+ redirectUrl: z.string().url().optional().describe("URL to redirect to on success")
526
+ };
527
+ const createFormInput = z.object({
528
+ name: z.string().min(1).describe("human form name (used by getForm('Name'))"),
529
+ fields: z.array(formFieldObject).default([]).describe("the form's fields"),
530
+ ...formSettingsShape
531
+ });
532
+ const updateFormInput = z.object({
533
+ formId: z.string().min(1).describe("form id (from list_forms)"),
534
+ name: z.string().optional(),
535
+ fields: z.array(formFieldObject).optional().describe("REPLACES the field array \u2014 include all fields to keep"),
536
+ ...formSettingsShape
537
+ });
538
+ const blockObject = z.object({
539
+ type: z.enum(["heading", "text", "image", "button", "spacer", "video", "columns"]).describe("block type; 'columns' nests child blocks in props.columns"),
540
+ id: z.string().min(1).describe("stable unique block id"),
541
+ props: z.record(z.string(), z.unknown()).describe(
542
+ "per-type props: heading {text, level}; text {text}; image {src, alt}; button {text, href}; spacer {height}; video {url}; columns {columns: block[][], gap}"
543
+ )
544
+ });
545
+ const componentPropObject = z.object({
546
+ key: z.string().min(1),
547
+ label: z.string().min(1),
548
+ target: z.object({ blockId: z.string().min(1), path: z.string().min(1) }),
549
+ type: z.enum(["text", "richtext", "image", "url", "boolean"]),
550
+ defaultValue: z.unknown().optional()
551
+ });
552
+ const componentCategory = z.enum([
553
+ "navbar",
554
+ "footer",
555
+ "button",
556
+ "section",
557
+ "slider",
558
+ "tabs",
559
+ "form",
560
+ "custom"
561
+ ]);
562
+ const createComponentInput = z.object({
563
+ name: z.string().min(1),
564
+ slug: slug.describe("url-safe unique slug (lowercase letters/numbers/hyphens)"),
565
+ category: componentCategory.optional().describe("defaults to 'custom'"),
566
+ description: z.string().optional(),
567
+ blockJson: z.array(blockObject).default([]).describe("the component's block tree"),
568
+ props: z.array(componentPropObject).default([]).describe("overridable fields")
569
+ });
570
+ const updateComponentInput = z.object({
571
+ componentId: z.string().min(1).describe("component id (from list_components)"),
572
+ name: z.string().optional(),
573
+ category: componentCategory.optional(),
574
+ description: z.string().optional(),
575
+ blockJson: z.array(blockObject).optional().describe("REPLACES the block tree"),
576
+ props: z.array(componentPropObject).optional()
577
+ });
578
+ const getComponentInput = z.object({
579
+ componentId: z.string().min(1).describe("component id (from list_components)")
580
+ });
581
+ const defs = [
582
+ {
583
+ name: "list_pages",
584
+ config: {
585
+ title: "List pages in the current project",
586
+ description: "List the pages in the project this MCP key is bound to, each with its full field SCHEMA, pageType (singleton|dynamic), and status. Use it to verify WHERE content lands and to read field keys/types before set_page_content / add_page_field.",
587
+ inputSchema: {}
588
+ },
589
+ handler: guard(
590
+ async () => withClient(async (client) => {
591
+ const pages = await client.listPages();
592
+ return ok(
593
+ `${pages.length} page(s) in the bound project.`,
594
+ pages.map((p) => ({
595
+ id: p.id,
596
+ title: p.title,
597
+ slug: p.slug,
598
+ pageType: p.pageType,
599
+ status: p.status,
600
+ fields: p.fields
601
+ }))
602
+ );
603
+ })
604
+ )
605
+ },
606
+ {
607
+ name: "get_page",
608
+ config: {
609
+ title: "Get a page (with its field schema)",
610
+ description: "Get one page by id INCLUDING its full field schema (keys, types, nested group/repeater children) and pageType. Read this before set_page_content so you write correctly-keyed values, or before add_page_field so you know the existing keys.",
611
+ inputSchema: getPageInput.shape
612
+ },
613
+ handler: guard(
614
+ async (args) => withClient(async (client) => {
615
+ const page = await client.getPage(args.pageId);
616
+ return ok(
617
+ `Page '${page.title}' (${page.pageType ?? "page"}, ${page.fields.length} field(s)).`,
618
+ page
619
+ );
620
+ })
621
+ )
622
+ },
623
+ {
624
+ name: "create_page",
625
+ config: {
626
+ title: "Create a page",
627
+ description: "Create a page with its own typed schema. Supports pageType 'singleton' (exactly one entry \u2014 Home, About, Contact) and 'dynamic' (many entries sharing the schema \u2014 Blog posts, Products). Project-scoped from the key. Additive \u2014 does not delete or overwrite existing pages. FIRST read the page's real markup and DECOMPOSE it into a destructured tree: each visual section becomes a nested field \u2014 a fixed grouped block \u2192 type 'group', a repeating list of items (cards, testimonials, features, FAQs) \u2192 type 'repeater' \u2014 each carrying its own child `fields`. Do NOT flatten sections into many flat top-level fields. Build the full nested tree, then call this once.",
628
+ inputSchema: createPageInput.shape
629
+ },
630
+ handler: guard(
631
+ async (args) => withClient(async (client) => {
632
+ const page = await client.createPage({
633
+ title: args.title,
634
+ slug: args.slug,
635
+ pageType: args.pageType ?? "singleton",
636
+ ...args.fields ? { fields: args.fields.map(toField) } : {},
637
+ ...args.metaTitle !== void 0 ? { metaTitle: args.metaTitle } : {},
638
+ ...args.metaDescription !== void 0 ? { metaDescription: args.metaDescription } : {}
639
+ });
640
+ return ok(
641
+ `Created ${page.pageType ?? "page"} page '${page.title}' (id ${page.id}, slug ${page.slug}) with ${page.fields.length} field(s).`,
642
+ page
643
+ );
644
+ })
645
+ )
646
+ },
647
+ {
648
+ name: "add_field",
649
+ config: {
650
+ title: "Add a field to a content model",
651
+ description: "Append a field to an existing content model. Reads the model's current fields and adds yours (read-modify-write) \u2014 never removes existing fields. For a section/zone, add ONE 'group' (fixed block) or 'repeater' (repeating list) field carrying its child `fields` \u2014 don't add the zone's inner fields as separate top-level fields.",
652
+ inputSchema: addFieldInput.shape
653
+ },
654
+ handler: guard(
655
+ async (args) => withClient(async (client) => {
656
+ const model = await client.getModel(args.modelId);
657
+ if (model.fields.some((f) => f.key === args.key)) {
658
+ return fail(`Field '${args.key}' already exists on model '${model.name}'.`);
659
+ }
660
+ const updated = await client.updateModel(args.modelId, {
661
+ fields: [...model.fields, toField(args)]
662
+ });
663
+ return ok(
664
+ `Added field '${args.key}' to '${updated.name}'. Model now has ${updated.fields.length} field(s).`,
665
+ updated
666
+ );
667
+ })
668
+ )
669
+ },
670
+ {
671
+ name: "add_page_field",
672
+ config: {
673
+ title: "Add a field to a page",
674
+ description: "Append a field to an existing page's schema (Home, About, a blog template, etc.). Additive \u2014 the API rejects a key that already exists and never overwrites or retypes existing fields. Use this when the target is a page (singleton or dynamic); use add_field when the target is a content model. For a section/zone, add ONE 'group' (fixed block) or 'repeater' (repeating list) field carrying its child `fields` \u2014 don't add the zone's inner fields as separate top-level fields.",
675
+ inputSchema: addPageFieldInput.shape
676
+ },
677
+ handler: guard(
678
+ async (args) => withClient(async (client) => {
679
+ const page = await client.addPageFields(args.pageId, { addFields: [toField(args)] });
680
+ return ok(
681
+ `Added field '${args.key}' to page '${page.title}'. Page now has ${page.fields.length} field(s).`,
682
+ page
683
+ );
684
+ })
685
+ )
686
+ },
687
+ {
688
+ name: "create_entry",
689
+ config: {
690
+ title: "Create a content entry",
691
+ description: "Create a content entry under a model. If data/status are provided they are applied in a follow-up update (entries are created as empty drafts).",
692
+ inputSchema: createEntryInput.shape
693
+ },
694
+ handler: guard(
695
+ async (args) => withClient(async (client) => {
696
+ const created = await client.createEntry({
697
+ contentModelId: args.contentModelId,
698
+ ...args.slug !== void 0 ? { slug: args.slug } : {}
699
+ });
700
+ const needsUpdate = args.data !== void 0 || args.status !== void 0;
701
+ const entry = needsUpdate ? await client.updateEntry(created.id, {
702
+ ...args.data !== void 0 ? { data: args.data } : {},
703
+ ...args.status !== void 0 ? { status: args.status } : {}
704
+ }) : created;
705
+ return ok(
706
+ `Created entry '${entry.slug}' (id ${entry.id}, status ${entry.status}).`,
707
+ entry
708
+ );
709
+ })
710
+ )
711
+ },
712
+ {
713
+ name: "set_page_content",
714
+ config: {
715
+ title: "Set a page's field values (content)",
716
+ description: "Set a page's field VALUES \u2014 the actual content. For a SINGLETON page (Home, About, Site Settings) this creates or updates its one entry, so call it again to edit. `data` is keyed by field key: a nested 'array' (zone) value is an OBJECT { nonRepeatable: { childKey: value }, repeatable: [ { childKey: value } ] }; a primitive 'array' is a plain list; an 'image' value is an asset URL. Read the schema first with get_page. This is how you populate Home/About/Settings \u2014 create_entry is for dynamic collections only.",
717
+ inputSchema: setPageContentInput.shape
718
+ },
719
+ handler: guard(
720
+ async (args) => withClient(async (client) => {
721
+ const entry = await client.setPageContent(args.pageId, {
722
+ data: args.data,
723
+ ...args.status !== void 0 ? { status: args.status } : {}
724
+ });
725
+ return ok(
726
+ `Set content on page ${args.pageId} (entry ${entry.id}, status ${entry.status}).`,
727
+ entry
728
+ );
729
+ })
730
+ )
731
+ },
732
+ {
733
+ name: "list_entries",
734
+ config: {
735
+ title: "List content entries (incl. drafts)",
736
+ description: "List content entries \u2014 including drafts \u2014 filtered by model and/or page. Use it to SEE existing content before editing. For a singleton page, pass its pageId to get its single entry.",
737
+ inputSchema: listEntriesInput.shape
738
+ },
739
+ handler: guard(
740
+ async (args) => withClient(async (client) => {
741
+ const entries = await client.listEntries({
742
+ ...args.modelId ? { modelId: args.modelId } : {},
743
+ ...args.pageId ? { pageId: args.pageId } : {},
744
+ ...args.status ? { status: args.status } : {}
745
+ });
746
+ return ok(`${entries.length} entr(y/ies).`, entries);
747
+ })
748
+ )
749
+ },
750
+ {
751
+ name: "get_entry",
752
+ config: {
753
+ title: "Get a content entry (with its values)",
754
+ description: "Get one content entry by id INCLUDING its `data` (field values), even when draft.",
755
+ inputSchema: getEntryInput.shape
756
+ },
757
+ handler: guard(
758
+ async (args) => withClient(async (client) => {
759
+ const entry = await client.getEntry(args.entryId);
760
+ return ok(`Entry '${entry.slug}' (id ${entry.id}, status ${entry.status}).`, entry);
761
+ })
762
+ )
763
+ },
764
+ {
765
+ name: "update_entry",
766
+ config: {
767
+ title: "Update a content entry's values",
768
+ description: "Update a content entry's `data` (field values) and/or status by id. `data` is keyed by field key; a nested 'array' (zone) value is an object { nonRepeatable: {\u2026}, repeatable: [{\u2026}] }, a primitive 'array' is a plain list. Use this to edit an existing entry; for a singleton page prefer set_page_content.",
769
+ inputSchema: updateEntryInput.shape
770
+ },
771
+ handler: guard(
772
+ async (args) => withClient(async (client) => {
773
+ const entry = await client.updateEntry(args.entryId, {
774
+ ...args.data !== void 0 ? { data: args.data } : {},
775
+ ...args.status !== void 0 ? { status: args.status } : {},
776
+ ...args.slug !== void 0 ? { slug: args.slug } : {}
777
+ });
778
+ return ok(`Updated entry '${entry.slug}' (id ${entry.id}, status ${entry.status}).`, entry);
779
+ })
780
+ )
781
+ },
782
+ {
783
+ name: "upload_asset",
784
+ config: {
785
+ title: "Upload an asset to the Media Library",
786
+ description: "Upload an image/asset from a local file path or a remote URL into the project's Media Library. Returns the asset's stable CDN URL \u2014 put that URL into a content entry's image field. Use this BEFORE creating entries that reference images.",
787
+ inputSchema: uploadAssetInput.shape
788
+ },
789
+ handler: guard(
790
+ async (args) => withClient(async (client) => {
791
+ const asset = await client.uploadAsset(args);
792
+ return ok(
793
+ `Uploaded '${asset.filename}' (id ${asset.id}). Put this URL (or the id ${asset.id}) into an image field to attach it: ${asset.url}`,
794
+ asset
795
+ );
796
+ })
797
+ )
798
+ },
799
+ {
800
+ name: "delete_page",
801
+ config: {
802
+ title: "Delete a page",
803
+ description: "DELETE a page and its content. Destructive but REVERSIBLE \u2014 it soft-deletes (can be restored from the dashboard) and is audit-logged. Use it to remove a page you created by mistake. Confirm with the user before deleting content they may want.",
804
+ inputSchema: deletePageInput.shape
805
+ },
806
+ handler: guard(
807
+ async (args) => withClient(async (client) => {
808
+ const res = await client.deletePage(args.pageId);
809
+ return ok(`Deleted page ${res.id} (soft-delete \u2014 restorable from the dashboard).`, res);
810
+ })
811
+ )
812
+ },
813
+ {
814
+ name: "delete_entry",
815
+ config: {
816
+ title: "Delete a content entry",
817
+ description: "DELETE a single content entry. Destructive but REVERSIBLE (soft-delete, restorable from the dashboard) and audit-logged. Use it to remove content created by mistake.",
818
+ inputSchema: deleteEntryInput.shape
819
+ },
820
+ handler: guard(
821
+ async (args) => withClient(async (client) => {
822
+ const res = await client.deleteEntry(args.entryId);
823
+ return ok(`Deleted entry ${res.id} (soft-delete \u2014 restorable from the dashboard).`, res);
824
+ })
825
+ )
826
+ },
827
+ {
828
+ name: "delete_content_model",
829
+ config: {
830
+ title: "Delete a content model",
831
+ description: "DELETE a content model and its entries. Destructive but REVERSIBLE (soft-delete, restorable from the dashboard) and audit-logged. Confirm with the user first \u2014 this removes all content under the model.",
832
+ inputSchema: deleteModelInput.shape
833
+ },
834
+ handler: guard(
835
+ async (args) => withClient(async (client) => {
836
+ const res = await client.deleteModel(args.modelId);
837
+ return ok(`Deleted content model ${res.id} and its entries (soft-delete \u2014 restorable from the dashboard).`, res);
838
+ })
839
+ )
840
+ },
841
+ // ── Forms (read-only — discover dashboard forms to embed into the site) ──
842
+ {
843
+ name: "list_forms",
844
+ config: {
845
+ title: "List forms in the current project",
846
+ description: "List the forms built in the dashboard for the bound project \u2014 each with its id, name, and field schema. Use it to find a form to add to the user's site: read it, then write `<BcmsForm form={getForm('Name')} />` (from @bettercms-ai/next) into the page/component where the user wants it.",
847
+ inputSchema: {}
848
+ },
849
+ handler: guard(
850
+ async () => withClient(async (client) => {
851
+ const forms = await client.listForms();
852
+ return ok(
853
+ `${forms.length} form(s) in the bound project.`,
854
+ forms.map((f) => ({ id: f.id, name: f.name, fields: f.fields }))
855
+ );
856
+ })
857
+ )
858
+ },
859
+ {
860
+ name: "get_form",
861
+ config: {
862
+ title: "Get a form (with its field schema)",
863
+ description: "Get one form by id INCLUDING its fields (keys, types, required, options, showIf) and settings (submitLabel, successMessage, redirectUrl, turnstileEnabled, honeypotField). Read this before wiring `<BcmsForm>` so you render the right fields.",
864
+ inputSchema: getFormInput.shape
865
+ },
866
+ handler: guard(
867
+ async (args) => withClient(async (client) => {
868
+ const form = await client.getForm(args.formId);
869
+ return ok(`Form '${form.name}' (${form.fields.length} field(s)).`, form);
870
+ })
871
+ )
872
+ },
873
+ {
874
+ name: "create_form",
875
+ config: {
876
+ title: "Create a form",
877
+ description: "Create a form (fields + settings) in the bound project. CONFIRM the fields with the user first. Returns the new form's id \u2014 then embed it with `<BcmsForm form={getForm('Name')} />` from @bettercms-ai/next. Field types: text,email,textarea,select(needs options),checkbox,number,phone,date,url,consent,hidden.",
878
+ inputSchema: createFormInput.shape
879
+ },
880
+ handler: guard(
881
+ async (args) => withClient(async (client) => {
882
+ const form = await client.createForm(args);
883
+ return ok(`Created form '${form.name}' (id ${form.id}). Embed with <BcmsForm form={getForm('${form.name}')} />.`, form);
884
+ })
885
+ )
886
+ },
887
+ {
888
+ name: "update_form",
889
+ config: {
890
+ title: "Update a form",
891
+ description: "Update a form by id \u2014 name, fields, or settings. Read get_form first. Passing `fields` REPLACES the array (include all fields to keep).",
892
+ inputSchema: updateFormInput.shape
893
+ },
894
+ handler: guard(
895
+ async (args) => withClient(async (client) => {
896
+ const { formId, ...input } = args;
897
+ const form = await client.updateForm(formId, input);
898
+ return ok(`Updated form '${form.name}' (id ${form.id}).`, form);
899
+ })
900
+ )
901
+ },
902
+ // ── Components (discover + author reusable symbols) ──
903
+ {
904
+ name: "list_components",
905
+ config: {
906
+ title: "List reusable components",
907
+ description: "List the reusable components in the bound project \u2014 each with id, name, slug, category, blockJson, and props. Use it to find a component to render with `<BcmsBlocks>` from @bettercms-ai/next.",
908
+ inputSchema: {}
909
+ },
910
+ handler: guard(
911
+ async () => withClient(async (client) => {
912
+ const list = await client.listComponents();
913
+ return ok(
914
+ `${list.length} component(s) in the bound project.`,
915
+ list.map((cmp) => ({ id: cmp.id, name: cmp.name, slug: cmp.slug, category: cmp.category }))
916
+ );
917
+ })
918
+ )
919
+ },
920
+ {
921
+ name: "get_component",
922
+ config: {
923
+ title: "Get a component (with its blockJson)",
924
+ description: "Get one component by id INCLUDING its blockJson tree and props. Read this before update_component so you keep the existing blocks.",
925
+ inputSchema: getComponentInput.shape
926
+ },
927
+ handler: guard(
928
+ async (args) => withClient(async (client) => {
929
+ const cmp = await client.getComponent(args.componentId);
930
+ return ok(`Component '${cmp.name}' (${cmp.blockJson.length} block(s)).`, cmp);
931
+ })
932
+ )
933
+ },
934
+ {
935
+ name: "create_component",
936
+ config: {
937
+ title: "Create a reusable component",
938
+ description: "Create a reusable component from a blockJson tree. CONFIRM the structure with the user first. blockJson is an array of blocks (heading/text/image/button/spacer/video/columns); a 'columns' block nests child blocks in props.columns. `props` declares overridable fields. Returns the new id \u2014 render with `<BcmsBlocks>`.",
939
+ inputSchema: createComponentInput.shape
940
+ },
941
+ handler: guard(
942
+ async (args) => withClient(async (client) => {
943
+ const cmp = await client.createComponent(args);
944
+ return ok(`Created component '${cmp.name}' (id ${cmp.id}, slug ${cmp.slug}).`, cmp);
945
+ })
946
+ )
947
+ },
948
+ {
949
+ name: "update_component",
950
+ config: {
951
+ title: "Update a reusable component",
952
+ description: "Update a component by id \u2014 blockJson, props, name, or category. Read get_component first. Passing `blockJson`/`props` REPLACES them. Updating re-bakes every page that embeds this component.",
953
+ inputSchema: updateComponentInput.shape
954
+ },
955
+ handler: guard(
956
+ async (args) => withClient(async (client) => {
957
+ const { componentId, ...input } = args;
958
+ const cmp = await client.updateComponent(componentId, input);
959
+ return ok(`Updated component '${cmp.name}' (id ${cmp.id}).`, cmp);
960
+ })
961
+ )
962
+ }
963
+ ];
964
+ return defs;
965
+ }
966
+ function registerTools(server, deps) {
967
+ for (const def of buildToolDefs(deps)) {
968
+ server.registerTool(def.name, def.config, def.handler);
969
+ }
970
+ }
971
+
972
+ // src/prompts.ts
973
+ import { z as z2 } from "zod";
974
+ var SCHEMA_PROPOSAL_FLOW = `### Whole-project schema design (declarative, confirm-first) \u2192 \`create_page\` / \`add_field\`
975
+ Design the WHOLE project's content schema from its code, and **confirm the
976
+ shape with the user BEFORE creating anything**. Never silently guess.
977
+ 1. **Read the project's code** \u2014 the connected git repo OR, for an uploaded project,
978
+ its source in your current working directory (same thing: pages/routes and their
979
+ components). For each page, identify the editable regions and how they're structured.
980
+ 2. **Destructure every page into a tree**, do NOT flatten. Map each region to:
981
+ - a plain field (text/richtext/image/number/boolean/select/date/datetime/reference),
982
+ - a **group** (Non-Repeatable Zone \u2014 a fixed grouped block, e.g. a hero with
983
+ heading + subheading + image), or
984
+ - a **repeater** (Repeatable Zone / section-list \u2014 a repeating list of items
985
+ such as feature cards, testimonials, FAQs, pricing tiers), each carrying its
986
+ own child \`fields\`. Use \`array\` only for primitive lists (tags, bullet strings).
987
+ Decide pageType per page: singleton (Home/About/Contact) vs dynamic (Blog/Products).
988
+ 3. **Present the proposal for confirmation (REQUIRED gate).** Show the full tree \u2014
989
+ page \u2192 zones \u2192 nested fields, with each zone labelled group vs repeater \u2014 and ask
990
+ the user (AskUserQuestion) to confirm or adjust: which zones repeat, what fields
991
+ each holds, singleton vs dynamic, any missing/extra pages. Iterate until they
992
+ approve. Do NOT call create_page / add_field before this approval.
993
+ 4. **Build it** \u2014 once approved, call \`create_page\` once per page with the full
994
+ nested \`fields\` tree; use \`add_field\`/\`add_page_field\` only to extend later.
995
+ Keep field keys stable and human-readable. Schema only \u2014 no content yet.
996
+ Field object: { key, label, type, required?, options?, config?, fields? }.`;
997
+ var PAGE_FLOW = `### Page authoring \u2192 \`create_page\` tool
998
+ Author a page (the page-first schema). Ask, in order, via AskUserQuestion:
999
+ 1. **Page type** \u2014 singleton (exactly one entry: Home, About, Contact) vs dynamic
1000
+ (many entries sharing the schema: Blog posts, Products).
1001
+ 2. **Identity** \u2014 title; derive a slug (lowercase, a\u2013z 0\u20139 -) and confirm; optional
1002
+ metaTitle/metaDescription.
1003
+ 3. **Fields (loop until done)** \u2014 for each: key (^[a-zA-Z0-9_]+$), label, type,
1004
+ required?. Types (13): text, richtext, image, boolean, number, select (needs
1005
+ \`options: string[]\`), reference / multi-reference (\`config.contentModelId\`,
1006
+ multi adds min/max), array (primitives \u2014 \`config.itemType\`: text|number|date),
1007
+ date (\`config.includeTime\`?), datetime, **group** (Non-Repeatable Zone: ONE
1008
+ nested object \u2014 recurse to collect its \`fields\`, e.g. blog_hero \u2192 heading,
1009
+ description, hero_image), **repeater** (Repeatable Zone: an ARRAY of such objects
1010
+ \u2014 recurse to collect item \`fields\`, e.g. testimonials \u2192 quote, author). Nesting
1011
+ may go several levels deep.
1012
+ 4. **Review** the assembled tree, then call \`create_page\` with
1013
+ { title, slug, pageType, fields, metaTitle?, metaDescription? }. Field object:
1014
+ { key, label, type, required?, options?, config?, fields? }.`;
1015
+ var FIELD_FLOW = `### Add a field \u2192 \`add_field\` (models) / \`add_page_field\` (pages)
1016
+ Append a field to an existing schema. First decide the target: a **content model**
1017
+ or a **page** (Home, About, a blog template). Ask: which target (id \u2014 use
1018
+ \`list_pages\` to find a page id), then the field (key, label, type \u2014 any of the 13
1019
+ above, including nested group/repeater), required?. Confirm, then call:
1020
+ - a model \u2192 \`add_field\` { modelId, key, label, type, ... }
1021
+ - a page \u2192 \`add_page_field\` { pageId, key, label, type, ... }
1022
+ Both are additive: they reject a key that already exists and never retype/overwrite
1023
+ an existing field (edit those in the dashboard).`;
1024
+ var ENTRY_FLOW = `### Create an entry \u2192 \`create_entry\` tool
1025
+ Create a content entry under a model. Ask: which model (id), the field values (data),
1026
+ status (draft/published). Then call \`create_entry\` { contentModelId, data?, status?, slug? }.`;
1027
+ var FORM_FLOW = `### Form authoring \u2192 \`create_form\` / \`update_form\` tools
1028
+ Author a form (then the user embeds it with \`<BcmsForm form={getForm('Name')} />\` from
1029
+ @bettercms-ai/next). Confirm the fields with the user BEFORE creating. Never guess fields.
1030
+ 1. **Discover** \u2014 \`list_forms\` to see existing forms; \`get_form\` to read one before editing.
1031
+ 2. **Collect fields (loop)** \u2014 for each: key (machine key for the value), label, type. Types:
1032
+ text, email, textarea, select (needs \`options: string[]\`), checkbox, number, phone, date,
1033
+ url, consent, hidden. Optional per field: required?, placeholder?, defaultValue?, and
1034
+ \`showIf: { field, equals }\` for conditional display.
1035
+ 3. **Settings** \u2014 name (used by getForm('Name')), submitLabel?, successMessage?, redirectUrl?.
1036
+ 4. **Confirm**, then \`create_form\` { name, fields, ... } (returns the new id), or
1037
+ \`update_form\` { formId, ... } to edit (passing \`fields\` REPLACES the array \u2014 include all).
1038
+ 5. Offer to wire \`<BcmsForm>\` into the page/component where the user wants it.`;
1039
+ var COMPONENT_FLOW = `### Component authoring \u2192 \`create_component\` / \`update_component\` tools
1040
+ Author a reusable component (Webflow-style symbol) the user renders with \`<BcmsBlocks>\`.
1041
+ blockJson is the visual definition; authoring it blind is error-prone, so go slow and
1042
+ confirm. Never guess the layout.
1043
+ 1. **Discover** \u2014 \`list_components\` / \`get_component\` (read before update; keep existing blocks).
1044
+ 2. **Design the blockJson tree** \u2014 an array of blocks, each { type, id, props }. Types:
1045
+ heading {text, level 1-6}, text {text}, image {src, alt}, button {text, href}, spacer
1046
+ {height}, video {url}, and **columns** {columns: array of arrays of blocks, gap} which
1047
+ NESTS child blocks. Give every block a stable unique id.
1048
+ 3. **Props (optional)** \u2014 declare overridable fields: { key, label, target: { blockId, path },
1049
+ type: text|richtext|image|url|boolean, defaultValue? } so instances can be customized.
1050
+ 4. **Confirm the structure** (AskUserQuestion: show the block tree), then \`create_component\`
1051
+ { name, slug, category?, blockJson, props? } (returns the id), or \`update_component\`
1052
+ { componentId, ... } \u2014 note updating re-bakes every page that embeds it.`;
1053
+ function registerPrompts(server) {
1054
+ server.registerPrompt(
1055
+ "studio",
1056
+ {
1057
+ title: "BetterCMS Studio (guided)",
1058
+ description: "One command to author in BetterCMS. Detects what you want \u2014 create a page, add a field, create an entry \u2014 and runs the matching guided flow, then calls the right tool.",
1059
+ argsSchema: {
1060
+ request: z2.string().optional().describe("what you want to do, e.g. 'a blog page with a hero zone'")
1061
+ }
1062
+ },
1063
+ ({ request }) => ({
1064
+ messages: [
1065
+ {
1066
+ role: "user",
1067
+ content: {
1068
+ type: "text",
1069
+ text: `You are the BetterCMS authoring assistant (via the bettercms MCP).
1070
+ ${request ? `The user's request: "${request}".
1071
+ ` : ""}
1072
+ First, **preflight**: confirm the bettercms tools are loaded (\`create_page\`, \`add_field\`,
1073
+ \`create_entry\`). If \`create_page\` is missing and only \`create_model\` shows, the host has a
1074
+ stale cached MCP \u2014 tell the user to run \`rm -rf ~/.npm/_npx\` and restart, then stop.
1075
+
1076
+ Then **route** to the matching sub-flow below based on the request and conversation
1077
+ context. DEFAULT: when setting up a project or designing its schema from the repo (or
1078
+ when intent is unclear), run the **whole-repo schema design** flow \u2014 it proposes the
1079
+ structure and confirms with the user before creating anything. Use the single-page or
1080
+ single-field flows only for targeted follow-ups. If still unsure, ask the user
1081
+ (AskUserQuestion: "Design the schema from my project" / "Create one page" / "Add a field" /
1082
+ "Create an entry" / "Build a form" / "Build a component"). Run flows by asking ONE step at
1083
+ a time, pre-filling sensible defaults from the request but never inventing fields the user
1084
+ didn't imply. Whatever the page/zone/form/component is scoped to follows the MCP key's project.
1085
+
1086
+ ${SCHEMA_PROPOSAL_FLOW}
1087
+
1088
+ ${PAGE_FLOW}
1089
+
1090
+ ${FIELD_FLOW}
1091
+
1092
+ ${ENTRY_FLOW}
1093
+
1094
+ ${FORM_FLOW}
1095
+
1096
+ ${COMPONENT_FLOW}
1097
+
1098
+ This assistant is extensible: when new BetterCMS tools are added, a new sub-flow appears
1099
+ here \u2014 route to it the same way.`
1100
+ }
1101
+ }
1102
+ ]
1103
+ })
1104
+ );
1105
+ server.registerPrompt(
1106
+ "propose_schema",
1107
+ {
1108
+ title: "Design schema from repo (confirm-first)",
1109
+ description: "Read the repository, propose a destructured content schema (pages \u2192 group/repeater zones \u2192 nested fields), confirm it with you, then create it via create_page. Use this to set up a project's schema.",
1110
+ argsSchema: {
1111
+ request: z2.string().optional().describe("optional focus, e.g. 'just the marketing pages' or 'the whole site'")
1112
+ }
1113
+ },
1114
+ ({ request }) => ({
1115
+ messages: [
1116
+ {
1117
+ role: "user",
1118
+ content: {
1119
+ type: "text",
1120
+ text: `Design the BetterCMS content schema for this repository.${request ? ` Focus: "${request}".` : ""}
1121
+
1122
+ Preflight: if \`create_page\` isn't available (only create_model/add_field/create_entry),
1123
+ the host has a stale cached MCP \u2014 tell the user to \`rm -rf ~/.npm/_npx\` and restart, then stop.
1124
+
1125
+ ${SCHEMA_PROPOSAL_FLOW}
1126
+
1127
+ After creating, report each page's id, slug, type, and field count, and the project it
1128
+ landed in. On 409 slug_taken, offer an alternative slug; on 401/403, the MCP key needs
1129
+ (re)authorizing.`
1130
+ }
1131
+ }
1132
+ ]
1133
+ })
1134
+ );
1135
+ server.registerPrompt(
1136
+ "new_page",
1137
+ {
1138
+ title: "New page (guided)",
1139
+ description: "Guided creation of a BetterCMS page (singleton or dynamic) with fields, including nested group (Non-Repeatable Zone) and repeater (Repeatable Zone) fields. Calls create_page.",
1140
+ argsSchema: {
1141
+ request: z2.string().optional().describe("what the page is, e.g. 'a blog page with a hero'")
1142
+ }
1143
+ },
1144
+ ({ request }) => ({
1145
+ messages: [
1146
+ {
1147
+ role: "user",
1148
+ content: {
1149
+ type: "text",
1150
+ text: `Create a BetterCMS page via the \`create_page\` tool.${request ? ` The user wants: "${request}".` : ""}
1151
+
1152
+ Preflight: if \`create_page\` isn't available (only create_model/add_field/create_entry),
1153
+ the host has a stale cached MCP \u2014 tell the user to \`rm -rf ~/.npm/_npx\` and restart, then stop.
1154
+
1155
+ ${PAGE_FLOW}
1156
+
1157
+ After creating, report the page id, slug, type, field count, and the project it landed in.
1158
+ On 409 slug_taken, offer an alternative slug; on 401/403, the MCP key needs (re)authorizing.`
1159
+ }
1160
+ }
1161
+ ]
1162
+ })
1163
+ );
1164
+ server.registerPrompt(
1165
+ "new_form",
1166
+ {
1167
+ title: "New form (guided)",
1168
+ description: "Guided creation of a BetterCMS form (fields + settings) you can embed with <BcmsForm>. Calls create_form.",
1169
+ argsSchema: {
1170
+ request: z2.string().optional().describe("what the form is, e.g. 'a contact form with name, email, message'")
1171
+ }
1172
+ },
1173
+ ({ request }) => ({
1174
+ messages: [
1175
+ {
1176
+ role: "user",
1177
+ content: {
1178
+ type: "text",
1179
+ text: `Author a BetterCMS form via the \`create_form\` tool.${request ? ` The user wants: "${request}".` : ""}
1180
+
1181
+ ${FORM_FLOW}
1182
+
1183
+ After creating, report the form id and name, and how to embed it (\`<BcmsForm form={getForm('Name')} />\`).
1184
+ On 401/403, the MCP key needs (re)authorizing.`
1185
+ }
1186
+ }
1187
+ ]
1188
+ })
1189
+ );
1190
+ server.registerPrompt(
1191
+ "new_component",
1192
+ {
1193
+ title: "New component (guided)",
1194
+ description: "Guided creation of a reusable BetterCMS component (a blockJson tree + overridable props) you render with <BcmsBlocks>. Calls create_component.",
1195
+ argsSchema: {
1196
+ request: z2.string().optional().describe("what the component is, e.g. 'a hero with heading, text and a button'")
1197
+ }
1198
+ },
1199
+ ({ request }) => ({
1200
+ messages: [
1201
+ {
1202
+ role: "user",
1203
+ content: {
1204
+ type: "text",
1205
+ text: `Author a reusable BetterCMS component via the \`create_component\` tool.${request ? ` The user wants: "${request}".` : ""}
1206
+
1207
+ ${COMPONENT_FLOW}
1208
+
1209
+ After creating, report the component id, name, and slug, and how to render it (\`<BcmsBlocks>\`).
1210
+ On 409 slug_taken, offer an alternative slug; on 401/403, the MCP key needs (re)authorizing.`
1211
+ }
1212
+ }
1213
+ ]
1214
+ })
1215
+ );
1216
+ }
1217
+
1218
+ // src/server.ts
1219
+ var SERVER_NAME = "bettercms";
1220
+ var SERVER_VERSION = "0.1.0";
1221
+ function buildServer(deps) {
1222
+ const server = new McpServer(
1223
+ { name: SERVER_NAME, version: SERVER_VERSION },
1224
+ { capabilities: { tools: {}, prompts: {} } }
1225
+ );
1226
+ registerTools(server, {
1227
+ auth: deps.auth,
1228
+ createClient: (apiKey) => BetterCMS.management({ apiKey, baseUrl: deps.managementBaseUrl })
1229
+ });
1230
+ registerPrompts(server);
1231
+ return server;
1232
+ }
1233
+
1234
+ // src/index.ts
1235
+ async function main() {
1236
+ const config = loadConfig();
1237
+ const store = new FileTokenStore(config.credentialsPath, config.apiUrl);
1238
+ const auth = new DeviceAuthClient(config, store);
1239
+ const server = buildServer({ auth, managementBaseUrl: config.managementBaseUrl });
1240
+ const transport = new StdioServerTransport();
1241
+ await server.connect(transport);
1242
+ process.stderr.write(`[bettercms-mcp] ready on stdio (api: ${config.apiUrl})
1243
+ `);
1244
+ }
1245
+ function isMainModule() {
1246
+ const argv1 = process.argv[1];
1247
+ if (!argv1) return false;
1248
+ try {
1249
+ return realpathSync(argv1) === fileURLToPath(import.meta.url);
1250
+ } catch {
1251
+ return false;
1252
+ }
1253
+ }
1254
+ if (isMainModule()) {
1255
+ main().catch((err) => {
1256
+ process.stderr.write(`[bettercms-mcp] fatal: ${err instanceof Error ? err.message : String(err)}
1257
+ `);
1258
+ process.exit(1);
1259
+ });
1260
+ }
1261
+ export {
1262
+ DeviceAuthClient,
1263
+ buildServer,
1264
+ loadConfig
1265
+ };
1266
+ //# sourceMappingURL=index.js.map