@bettercms-ai/mcp 0.36.0 → 0.37.1
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 +410 -15
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -2209,6 +2209,49 @@ function ok(summary, data) {
|
|
|
2209
2209
|
function fail(message) {
|
|
2210
2210
|
return { content: [{ type: "text", text: message }], isError: true };
|
|
2211
2211
|
}
|
|
2212
|
+
function componentIdsIn(blocks) {
|
|
2213
|
+
const out = [];
|
|
2214
|
+
const walk = (value) => {
|
|
2215
|
+
if (!Array.isArray(value)) return;
|
|
2216
|
+
for (const candidate of value) {
|
|
2217
|
+
if (!candidate || typeof candidate !== "object") continue;
|
|
2218
|
+
const block = candidate;
|
|
2219
|
+
const props = block.props ?? {};
|
|
2220
|
+
if (block.type === "component" && typeof props.componentId === "string" && props.componentId) {
|
|
2221
|
+
out.push(props.componentId);
|
|
2222
|
+
}
|
|
2223
|
+
if (block.type === "section") walk(props.children);
|
|
2224
|
+
else if (block.type === "columns" && Array.isArray(props.columns)) for (const column of props.columns) walk(column);
|
|
2225
|
+
else if (block.type === "slider" && Array.isArray(props.slides)) {
|
|
2226
|
+
for (const slide of props.slides) walk(slide?.children);
|
|
2227
|
+
} else if (block.type === "tabs" && Array.isArray(props.tabs)) {
|
|
2228
|
+
for (const tab of props.tabs) walk(tab?.children);
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
};
|
|
2232
|
+
walk(blocks);
|
|
2233
|
+
return out;
|
|
2234
|
+
}
|
|
2235
|
+
function apiError(err) {
|
|
2236
|
+
if (!err || typeof err !== "object") return null;
|
|
2237
|
+
const e = err;
|
|
2238
|
+
if (typeof e.status !== "number") return null;
|
|
2239
|
+
return {
|
|
2240
|
+
status: e.status,
|
|
2241
|
+
message: typeof e.message === "string" ? e.message : "",
|
|
2242
|
+
bodyCode: typeof e.bodyCode === "string" ? e.bodyCode : void 0,
|
|
2243
|
+
fieldErrors: e.fieldErrors && typeof e.fieldErrors === "object" ? e.fieldErrors : void 0,
|
|
2244
|
+
body: e.body
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
function errorText(err) {
|
|
2248
|
+
const api = apiError(err);
|
|
2249
|
+
if (api) {
|
|
2250
|
+
const fields = api.fieldErrors ? Object.entries(api.fieldErrors).map(([k, v]) => `${k}: ${v}`).join("; ") : "";
|
|
2251
|
+
return `${api.status}${api.bodyCode ? ` ${api.bodyCode}` : ""} \u2014 ${fields || api.message}`;
|
|
2252
|
+
}
|
|
2253
|
+
return err instanceof Error ? err.message : String(err);
|
|
2254
|
+
}
|
|
2212
2255
|
function authPrompt(err) {
|
|
2213
2256
|
const text = [
|
|
2214
2257
|
"\u{1F510} BetterCMS authorization required \u2014 you're not signed in yet.",
|
|
@@ -2461,7 +2504,13 @@ function buildToolDefs(deps) {
|
|
|
2461
2504
|
max: z.number().optional(),
|
|
2462
2505
|
step: z.number().optional()
|
|
2463
2506
|
}).passthrough().optional(),
|
|
2464
|
-
defaultValue: z.unknown().optional()
|
|
2507
|
+
defaultValue: z.unknown().optional(),
|
|
2508
|
+
// Editor hints, stored in the props JSON. `required` is NOT enforced at write time — an
|
|
2509
|
+
// override that omits it still saves — so it is a marker the dashboard renders and
|
|
2510
|
+
// get_site_composition counts, never a refusal. Parity: componentPropDefSchema on the server.
|
|
2511
|
+
required: z.boolean().optional().describe("mark the field required in the editor. An editor HINT: the API still accepts a placement that leaves it empty, and get_site_composition is what reports one."),
|
|
2512
|
+
helpText: z.string().max(200).optional().describe("hint shown under the control, muted (\u2264 200 chars)"),
|
|
2513
|
+
placeholder: z.string().max(120).optional().describe("ghost text inside a text-ish control (\u2264 120 chars)")
|
|
2465
2514
|
});
|
|
2466
2515
|
const getLayoutInput = z.object({
|
|
2467
2516
|
scope: z.enum(["global", "page"]).default("global"),
|
|
@@ -2497,8 +2546,12 @@ function buildToolDefs(deps) {
|
|
|
2497
2546
|
scope: z.enum(["global", "page"]).default("global"),
|
|
2498
2547
|
pageId: z.string().min(1).optional(),
|
|
2499
2548
|
projectId: z.string().min(1).optional().describe("required only for a workspace-scoped grant"),
|
|
2500
|
-
command: layoutCommand.describe("one canonical discriminated Layout command; authority is derived from type"),
|
|
2501
|
-
|
|
2549
|
+
command: layoutCommand.optional().describe("one canonical discriminated Layout command; authority is derived from type"),
|
|
2550
|
+
// Chrome is many commands — add a section, add a component to it, bind its fields — and each
|
|
2551
|
+
// one needs the revision the PREVIOUS one returned. An agent doing that by hand runs
|
|
2552
|
+
// get_layout between every write, and a single missed re-read is a 409 mid-sequence.
|
|
2553
|
+
commands: z.array(layoutCommand).min(1).max(50).optional().describe("apply these commands IN ORDER, each against the revision the previous one returned. Mutually exclusive with `command`. Stops at the first failure and reports what was applied."),
|
|
2554
|
+
ifMatch: z.number().int().nonnegative().describe("revision returned by get_layout. With `commands`, this is the revision for the FIRST command only \u2014 the rest are chained.")
|
|
2502
2555
|
});
|
|
2503
2556
|
const publishLayoutInput = z.object({
|
|
2504
2557
|
projectId: z.string().min(1).optional().describe("required only for a workspace-scoped grant"),
|
|
@@ -2526,6 +2579,7 @@ function buildToolDefs(deps) {
|
|
|
2526
2579
|
name: z.string().min(1),
|
|
2527
2580
|
slug: slug.describe("url-safe unique slug (lowercase letters/numbers/hyphens)"),
|
|
2528
2581
|
category: componentCategory.optional().describe("defaults to 'custom'"),
|
|
2582
|
+
group: z.string().min(1).max(100).optional().describe("the folder editors see in the dashboard's Components tab, addressed by NAME (e.g. 'Sections', 'Navigation', 'Forms'). Resolved against this project's Groups and CREATED when missing, so a whole-site run can file everything without looking any id up first. Separate from `category`, which is the picker tab."),
|
|
2529
2583
|
sectionType: sectionType.optional(),
|
|
2530
2584
|
projectId: z.string().nullable().optional().describe("owning project id; null creates a workspace-wide global component"),
|
|
2531
2585
|
allowedOn: allowedOn.optional(),
|
|
@@ -2537,6 +2591,7 @@ function buildToolDefs(deps) {
|
|
|
2537
2591
|
componentId: z.string().min(1).describe("component id (from list_components)"),
|
|
2538
2592
|
name: z.string().optional(),
|
|
2539
2593
|
category: componentCategory.optional(),
|
|
2594
|
+
group: z.string().min(1).max(100).optional().describe("move it into this Group (by NAME; created when missing) \u2014 the folder editors see in the dashboard's Components tab"),
|
|
2540
2595
|
sectionType: sectionType.nullable().optional().describe(
|
|
2541
2596
|
"null demotes it to an ordinary component \u2014 it disappears from the 'Add a section' picker, and instances already placed keep rendering but lose their section chrome and variant switcher"
|
|
2542
2597
|
),
|
|
@@ -2548,6 +2603,24 @@ function buildToolDefs(deps) {
|
|
|
2548
2603
|
const getComponentInput = z.object({
|
|
2549
2604
|
componentId: z.string().min(1).describe("component id (from list_components)")
|
|
2550
2605
|
});
|
|
2606
|
+
const createComponentsInput = z.object({
|
|
2607
|
+
components: z.array(createComponentInput).min(1).max(50).describe("up to 50 components, each exactly the create_component input")
|
|
2608
|
+
});
|
|
2609
|
+
const publishComponentsInput = z.object({
|
|
2610
|
+
componentIds: z.array(z.string().min(1)).min(1).max(100).describe("component ids (from list_components)")
|
|
2611
|
+
});
|
|
2612
|
+
const composePagesInput = z.object({
|
|
2613
|
+
pages: z.array(
|
|
2614
|
+
z.object({
|
|
2615
|
+
pageId: z.string().min(1).optional().describe("page id (from list_pages); or pass `slug`"),
|
|
2616
|
+
slug: z.string().min(1).optional().describe("page slug, resolved against list_pages; or pass `pageId`"),
|
|
2617
|
+
blockJson: z.array(blockObject).describe("REPLACES this page's whole block composition")
|
|
2618
|
+
})
|
|
2619
|
+
).min(1).max(50)
|
|
2620
|
+
});
|
|
2621
|
+
const listComponentsInput = z.object({
|
|
2622
|
+
detail: z.boolean().optional().describe("true = every component's full blockJson and props. Costly and rarely what you want \u2014 read one definition with get_component instead.")
|
|
2623
|
+
});
|
|
2551
2624
|
const listExtractionCandidatesInput = z.object({
|
|
2552
2625
|
projectId: z.string().min(1).optional().describe("only needed for a workspace-wide key")
|
|
2553
2626
|
});
|
|
@@ -3046,6 +3119,13 @@ function buildToolDefs(deps) {
|
|
|
3046
3119
|
z.object({}).shape,
|
|
3047
3120
|
async (c) => ok("Approved conversion plan.", await data(c, "GET", `/management/projects/current/conversion-plan`))
|
|
3048
3121
|
),
|
|
3122
|
+
def(
|
|
3123
|
+
"get_site_composition",
|
|
3124
|
+
"Check every page is assembled from registered components",
|
|
3125
|
+
"The receipt for 'is every page assembled from registered components?' \u2014 read-only, computed live, nothing is written. Call it at the START of a whole-site componentize run (playbook \xA712) for the before picture, and at the END as the proof. Per page: `kind` ('blocks' | 'fields-only' | 'empty'), a block census, `nonComponentBlocks` (loose TOP-LEVEL bands the editor's section lane cannot outline), and every `component` placement with the component's `status` ('published' | 'draft' | 'missing' \u2014 a draft renders as an EMPTY STRING on the live site), whether it is `inPicker` (a component with no sectionType is dropped from 'Add a section'), and any `emptyRequiredProps` \u2014 `required` is an editor hint the write path does not enforce, so this read is the only thing that reports it. Site-wide: `components` counts published/draft, `awaitingEvidence` and `awaitingApproval` (evidence is exact and a HUMAN must approve it in the BetterCMS dashboard \u2014 no tool here can grant that, and there is no bulk approve), plus `notInPicker` and `unused` ids; `layout.placements` is the chrome placed in the Global Layout draft. `verdict.everyPageComposed` with `verdict.reasons` is the answer to give the user. Pages are read DRAFT-inclusive, so a page composed but not yet published still shows its placements \u2014 check each page's own `status` before claiming the site is live.",
|
|
3126
|
+
z.object({}).shape,
|
|
3127
|
+
async (c) => ok("Site composition.", await data(c, "GET", `/management/projects/current/composition`))
|
|
3128
|
+
),
|
|
3049
3129
|
def(
|
|
3050
3130
|
"get_componentize_plan",
|
|
3051
3131
|
"Get the plan for turning this site's sections into components",
|
|
@@ -3602,13 +3682,39 @@ function buildToolDefs(deps) {
|
|
|
3602
3682
|
{
|
|
3603
3683
|
name: "update_layout",
|
|
3604
3684
|
config: {
|
|
3605
|
-
title: "Apply
|
|
3606
|
-
description: "Apply
|
|
3685
|
+
title: "Apply Layout draft commands",
|
|
3686
|
+
description: "Apply permission-shaped values/composition/schema commands to the Global Layout or a page override. Draft-only: this never publishes. Pass the revision from get_layout as ifMatch. ONE command in `command`, or an ordered batch in `commands` (prefer the batch for more than three of anything \u2014 placing site chrome is add-section, add-component and set-bindings in sequence): each command is applied against the revision the previous one RETURNED, so you do not re-read get_layout between them. A batch stops at the first failure and tells you how many were applied and the revision it stopped at \u2014 the earlier ones stand, because a Layout command is not a transaction.",
|
|
3607
3687
|
inputSchema: commandLayoutInput.shape
|
|
3608
3688
|
},
|
|
3609
3689
|
handler: guard(async (args) => withClient(async (client) => {
|
|
3610
|
-
const
|
|
3611
|
-
|
|
3690
|
+
const queued = args.commands ?? (args.command ? [args.command] : []);
|
|
3691
|
+
if (queued.length === 0) return fail("Pass `command` (one) or `commands` (an ordered batch).");
|
|
3692
|
+
if (args.commands && args.command) return fail("Pass `command` or `commands`, not both \u2014 the order of the two would be undefined.");
|
|
3693
|
+
let revision = args.ifMatch;
|
|
3694
|
+
let layout;
|
|
3695
|
+
const applied = [];
|
|
3696
|
+
for (const command of queued) {
|
|
3697
|
+
try {
|
|
3698
|
+
layout = await client.commandManagedLayout({
|
|
3699
|
+
scope: args.scope,
|
|
3700
|
+
pageId: args.pageId,
|
|
3701
|
+
projectId: args.projectId,
|
|
3702
|
+
command,
|
|
3703
|
+
ifMatch: revision
|
|
3704
|
+
});
|
|
3705
|
+
} catch (err) {
|
|
3706
|
+
return fail(
|
|
3707
|
+
`Layout command ${applied.length + 1} of ${queued.length} (${command.type}) failed: ${errorText(err)}. ${applied.length} command(s) were applied and remain in the draft; the Layout is at revision ${revision}. Re-read get_layout and continue from command ${applied.length + 1}.`
|
|
3708
|
+
);
|
|
3709
|
+
}
|
|
3710
|
+
revision = layout.revision;
|
|
3711
|
+
applied.push({ type: command.type, revision });
|
|
3712
|
+
}
|
|
3713
|
+
const where = layout.scope === "global" ? "Global" : `Page ${layout.pageSlug}`;
|
|
3714
|
+
return ok(
|
|
3715
|
+
`Applied ${applied.length} command(s) to the ${where} Layout draft; now at revision ${revision}.`,
|
|
3716
|
+
{ applied, revision, layout }
|
|
3717
|
+
);
|
|
3612
3718
|
}))
|
|
3613
3719
|
},
|
|
3614
3720
|
{
|
|
@@ -3628,15 +3734,28 @@ function buildToolDefs(deps) {
|
|
|
3628
3734
|
name: "list_components",
|
|
3629
3735
|
config: {
|
|
3630
3736
|
title: "List reusable components",
|
|
3631
|
-
description: "List the reusable components in the bound project
|
|
3632
|
-
inputSchema:
|
|
3737
|
+
description: "List the reusable components in the bound project. By DEFAULT this is the catalogue: id, name, slug, category, `sectionType` (the section family \u2014 a component with none is not in the editor's 'Add a section' picker), `status` ('draft' renders as NOTHING on the live site), and the `group` an editor browses it under, plus the locked `builtin:*` blueprints. It deliberately does NOT carry block trees: the full list carries every component's draft AND live `blockJson`, which is the largest response this API produces, and one component's definition is what `get_component` is for. Pass `detail: true` for the old full shape when you genuinely need every definition at once (rendering with `<BcmsBlocks>` from @bettercms-ai/next).",
|
|
3738
|
+
inputSchema: listComponentsInput.shape
|
|
3633
3739
|
},
|
|
3634
3740
|
handler: guard(
|
|
3635
|
-
async () => withClient(async (client) => {
|
|
3636
|
-
|
|
3741
|
+
async (args) => withClient(async (client) => {
|
|
3742
|
+
if (args.detail) {
|
|
3743
|
+
const list2 = await client.listComponents();
|
|
3744
|
+
return ok(`${list2.length} component(s) in the bound project (full definitions).`, list2);
|
|
3745
|
+
}
|
|
3746
|
+
const list = await client.listComponents({ select: "summary" });
|
|
3637
3747
|
return ok(
|
|
3638
3748
|
`${list.length} component(s) in the bound project.`,
|
|
3639
|
-
list.map((cmp) => ({
|
|
3749
|
+
list.map((cmp) => ({
|
|
3750
|
+
id: cmp.id,
|
|
3751
|
+
name: cmp.name,
|
|
3752
|
+
slug: cmp.slug,
|
|
3753
|
+
category: cmp.category,
|
|
3754
|
+
sectionType: cmp.sectionType,
|
|
3755
|
+
status: cmp.status,
|
|
3756
|
+
group: cmp.groupName,
|
|
3757
|
+
...cmp.builtin ? { builtin: true } : {}
|
|
3758
|
+
}))
|
|
3640
3759
|
);
|
|
3641
3760
|
})
|
|
3642
3761
|
)
|
|
@@ -3669,6 +3788,132 @@ function buildToolDefs(deps) {
|
|
|
3669
3788
|
})
|
|
3670
3789
|
)
|
|
3671
3790
|
},
|
|
3791
|
+
/**
|
|
3792
|
+
* ── The batch three ──────────────────────────────────────────────────────
|
|
3793
|
+
*
|
|
3794
|
+
* Sequential SDK calls with PER-ITEM receipts, and no new server route: the throughput
|
|
3795
|
+
* problem is one round trip per component, not one query per component, and inventing a bulk
|
|
3796
|
+
* endpoint would duplicate every validation, warning and governance gate the single writes
|
|
3797
|
+
* already run. A whole-site componentize run is forty creates, a dozen page compositions and
|
|
3798
|
+
* forty publishes; as individual tool calls that is a hundred turns of an agent's context
|
|
3799
|
+
* spent on plumbing.
|
|
3800
|
+
*
|
|
3801
|
+
* 🔴 NOTHING STOPS THE RUN. A batch that aborts on item 31 has already written 30 rows that
|
|
3802
|
+
* no receipt mentions, and the agent's only safe move is to re-run it and duplicate them.
|
|
3803
|
+
* Every item reports its own outcome instead, and the summary line says how many failed.
|
|
3804
|
+
*/
|
|
3805
|
+
{
|
|
3806
|
+
name: "create_components",
|
|
3807
|
+
config: {
|
|
3808
|
+
title: "Create many components in one call",
|
|
3809
|
+
description: "Create up to 50 components in ONE call \u2014 the same input as create_component, once per item. Prefer it over create_component whenever you are making more than three: a whole-site componentize run (playbook \xA712) is dozens, and one call each spends the turn on plumbing. Each item reports its own `{ ok, id, slug, error }`: a failure does NOT stop the run and the successful rows stay, so read the receipt and retry only the failures (a 409 on `slug` means that name is taken \u2014 change it, do not re-run the batch). Every component lands as a DRAFT, exactly as create_component does \u2014 it renders as NOTHING on the live site until it is published, which needs the owner's approval in the dashboard. Pass `group` on each item to file it into the folder editors browse. " + SECTION_DOCTRINE,
|
|
3810
|
+
inputSchema: createComponentsInput.shape
|
|
3811
|
+
},
|
|
3812
|
+
handler: guard(
|
|
3813
|
+
async (args) => withClient(async (client) => {
|
|
3814
|
+
const results = [];
|
|
3815
|
+
for (const input of args.components) {
|
|
3816
|
+
try {
|
|
3817
|
+
const cmp = await client.createComponent(input);
|
|
3818
|
+
results.push({ ok: true, slug: cmp.slug, id: cmp.id, name: cmp.name });
|
|
3819
|
+
} catch (err) {
|
|
3820
|
+
results.push({ ok: false, slug: input.slug, error: errorText(err) });
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
3824
|
+
return ok(
|
|
3825
|
+
`Created ${results.length - failed} of ${args.components.length} component(s)${failed ? `; ${failed} failed \u2014 see the per-item errors` : ""}. All are DRAFTS: publish_components once the owner has approved them, or they render as empty strings.`,
|
|
3826
|
+
results
|
|
3827
|
+
);
|
|
3828
|
+
})
|
|
3829
|
+
)
|
|
3830
|
+
},
|
|
3831
|
+
{
|
|
3832
|
+
name: "publish_components",
|
|
3833
|
+
config: {
|
|
3834
|
+
title: "Publish many components in one call",
|
|
3835
|
+
description: "Publish up to 100 components in ONE call \u2014 the same act as publish_component, once per item, copying each DRAFT definition to the live copy. Each item reports `{ ok, status, error, readiness }` and a failure never stops the run. \u{1F534} A 409 COMPONENT_IMPLEMENTATION_NOT_READY is NOT retried and must not be: it means this exact variant still needs passing implementation evidence and a HUMAN's approval in the BetterCMS dashboard, which no tool on this connection can grant and which has no bulk form. The item's `readiness` names what is missing \u2014 relay that to the user as the list of what only they can approve. 403 PUBLISH_NOT_GRANTED means this connection may author but not publish; 422 means the publish would break a Layout, and the item names which.",
|
|
3836
|
+
inputSchema: publishComponentsInput.shape
|
|
3837
|
+
},
|
|
3838
|
+
handler: guard(
|
|
3839
|
+
async (args) => withClient(async (client) => {
|
|
3840
|
+
const results = [];
|
|
3841
|
+
for (const componentId of args.componentIds) {
|
|
3842
|
+
try {
|
|
3843
|
+
const res = await client.fetchJSON(
|
|
3844
|
+
client.url(`/management/components/${componentId}/publish`),
|
|
3845
|
+
{ method: "POST" }
|
|
3846
|
+
);
|
|
3847
|
+
results.push({ ok: true, componentId, slug: res.data?.slug, status: res.data?.status ?? "published" });
|
|
3848
|
+
} catch (err) {
|
|
3849
|
+
const readiness = apiError(err)?.body?.readiness;
|
|
3850
|
+
results.push({
|
|
3851
|
+
ok: false,
|
|
3852
|
+
componentId,
|
|
3853
|
+
error: errorText(err),
|
|
3854
|
+
...readiness ? { readiness } : {}
|
|
3855
|
+
});
|
|
3856
|
+
}
|
|
3857
|
+
}
|
|
3858
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
3859
|
+
return ok(
|
|
3860
|
+
`Published ${results.length - failed} of ${args.componentIds.length} component(s)${failed ? `; ${failed} could not be published \u2014 read each item's error and readiness, and tell the user which ones need THEIR approval in the dashboard` : ""}.`,
|
|
3861
|
+
results
|
|
3862
|
+
);
|
|
3863
|
+
})
|
|
3864
|
+
)
|
|
3865
|
+
},
|
|
3866
|
+
{
|
|
3867
|
+
name: "compose_pages",
|
|
3868
|
+
config: {
|
|
3869
|
+
title: "Set many pages' block composition in one call",
|
|
3870
|
+
description: "Give up to 50 pages their block composition in ONE call \u2014 the same write as update_page's `blockJson`, once per item, and it REPLACES each page's whole block array. Address a page by `pageId` or by `slug`. Each item reports `{ ok, pageId, slug, error }` and a failure does not stop the run. BEFORE writing anything it reads this project's component catalogue once and REFUSES any item placing a componentId the project does not have \u2014 that page would render the missing sections as empty strings with no error anywhere, which is the single hardest failure on this platform to trace back. Writes DRAFTS: publish each page with update_page status:'published' afterwards. " + SECTION_DOCTRINE,
|
|
3871
|
+
inputSchema: composePagesInput.shape
|
|
3872
|
+
},
|
|
3873
|
+
handler: guard(
|
|
3874
|
+
async (args) => withClient(async (client) => {
|
|
3875
|
+
const needsSlug = args.pages.some((p) => !p.pageId);
|
|
3876
|
+
const [known, pageList] = await Promise.all([
|
|
3877
|
+
client.listComponents({ select: "summary" }),
|
|
3878
|
+
needsSlug ? client.listPages() : Promise.resolve([])
|
|
3879
|
+
]);
|
|
3880
|
+
const ids = new Set(known.map((c) => c.id));
|
|
3881
|
+
const bySlug = new Map(pageList.map((p) => [p.slug, p.id]));
|
|
3882
|
+
const results = [];
|
|
3883
|
+
for (const page of args.pages) {
|
|
3884
|
+
const pageId = page.pageId ?? (page.slug ? bySlug.get(page.slug) : void 0);
|
|
3885
|
+
if (!pageId) {
|
|
3886
|
+
results.push({ ok: false, slug: page.slug, error: `No page with slug '${page.slug}' in this project \u2014 pass a pageId from list_pages.` });
|
|
3887
|
+
continue;
|
|
3888
|
+
}
|
|
3889
|
+
const unknown = [...new Set(componentIdsIn(page.blockJson))].filter((id) => !ids.has(id));
|
|
3890
|
+
if (unknown.length) {
|
|
3891
|
+
results.push({
|
|
3892
|
+
ok: false,
|
|
3893
|
+
pageId,
|
|
3894
|
+
slug: page.slug,
|
|
3895
|
+
error: `Refused: this page places component id(s) this project does not have \u2014 ${unknown.join(", ")}. A missing component renders as an empty string with no error, so nothing was written for this page.`
|
|
3896
|
+
});
|
|
3897
|
+
continue;
|
|
3898
|
+
}
|
|
3899
|
+
try {
|
|
3900
|
+
const written = await client.fetchJSON(
|
|
3901
|
+
client.url(`/management/pages/${pageId}/meta`),
|
|
3902
|
+
{ method: "PATCH", body: JSON.stringify({ blockJson: page.blockJson }) }
|
|
3903
|
+
);
|
|
3904
|
+
results.push({ ok: true, pageId, slug: written.data?.slug ?? page.slug, blocks: page.blockJson.length });
|
|
3905
|
+
} catch (err) {
|
|
3906
|
+
results.push({ ok: false, pageId, slug: page.slug, error: errorText(err) });
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3909
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
3910
|
+
return ok(
|
|
3911
|
+
`Composed ${results.length - failed} of ${args.pages.length} page(s)${failed ? `; ${failed} failed \u2014 see the per-item errors` : ""}. These are page DRAFTS: publish each with update_page status:'published'. Then call get_site_composition for the receipt.`,
|
|
3912
|
+
results
|
|
3913
|
+
);
|
|
3914
|
+
})
|
|
3915
|
+
)
|
|
3916
|
+
},
|
|
3672
3917
|
{
|
|
3673
3918
|
name: "list_extraction_candidates",
|
|
3674
3919
|
config: {
|
|
@@ -3846,6 +4091,10 @@ Then declare what an editor may change, via \`props\`:
|
|
|
3846
4091
|
{ key: "ctaHref", label: "CTA link", target: { blockId: "cta", path: "props.href" }, type: "url" }
|
|
3847
4092
|
]
|
|
3848
4093
|
|
|
4094
|
+
Each prop also takes \`required\`, \`helpText\` (\u2264 200 chars) and \`placeholder\` (\u2264 120), which the
|
|
4095
|
+
dashboard's prop editor renders. They are HINTS: nothing on the write path refuses a placement
|
|
4096
|
+
that leaves a required prop empty, so \`get_site_composition\` is what reports one.
|
|
4097
|
+
|
|
3849
4098
|
\u{1F534} **On a components-first page, \`props\` is the ONLY editing surface.** Click-to-edit binds
|
|
3850
4099
|
\`heading\`/\`text\`/\`button\`/\`image\` blocks; it does **not** bind a \`component\` block, because
|
|
3851
4100
|
a component's blocks belong to the shared definition, not to the instance. So a page built
|
|
@@ -4143,7 +4392,7 @@ your own server) has NO canvas today: the SDK's draft mode with \`stega: true\`
|
|
|
4143
4392
|
per-field provenance in fetched strings, which prepares the content for editing surfaces, but do
|
|
4144
4393
|
not promise a canvas for an externally-hosted site.
|
|
4145
4394
|
|
|
4146
|
-
|
|
4395
|
+
**RECEIPTS: a claim about published state needs a read of the PUBLISHED copy.** The
|
|
4147
4396
|
doctrine this encodes cost a real incident (FLO-1188): a publish was verified against the
|
|
4148
4397
|
draft for ~50 minutes because the reader silently returned the draft, and every check was
|
|
4149
4398
|
green on the wrong document. Three rules, none optional:
|
|
@@ -4160,6 +4409,85 @@ green on the wrong document. Three rules, none optional:
|
|
|
4160
4409
|
call's behavior doesn't change when you change a param, treat the param as dead and
|
|
4161
4410
|
verify through an independent channel before trusting any result built on it.
|
|
4162
4411
|
|
|
4412
|
+
## 12. Componentize the entire website
|
|
4413
|
+
|
|
4414
|
+
The whole-site ask \u2014 "turn every section into a reusable component, organised for editors" \u2014
|
|
4415
|
+
mapped onto what this platform actually models. Work the order at the end; each numbered part
|
|
4416
|
+
below is one heading of that ask.
|
|
4417
|
+
|
|
4418
|
+
**1. Global elements.** Navbar, mobile nav, header and footer are the LAYOUT, not page content:
|
|
4419
|
+
\`get_layout\` then \`update_layout\`, and a chrome COMPONENT is placed into a Layout section with
|
|
4420
|
+
the \`add-component\` command. CTA buttons are \`button\` BLOCKS inside the section that uses them,
|
|
4421
|
+
exposed as \`text\` + \`url\` props \u2014 variant, size and icon are that block's own props and
|
|
4422
|
+
\`style\`, never a standalone Button component. Rich text is a \`richtext\` prop over a
|
|
4423
|
+
\`richtext\`/\`text\` block. Forms are \`create_form\` plus a \`form\` block, and form FIELDS are the
|
|
4424
|
+
one place the platform models \`required\`, \`placeholder\`, \`helpText\` and real \`validation\`
|
|
4425
|
+
(emailPolicy, min/max, phoneFormat, pattern) \u2014 put a site's validated inputs there. Cards,
|
|
4426
|
+
badges, tags, links, lists and media are BLOCKS inside a section, exposed through props; a
|
|
4427
|
+
repeatable row of them is ONE \`table\` prop whose \`config.fields\` describe the row. Accordions
|
|
4428
|
+
and FAQs: start from the \`builtin:faq-list\` blueprint, or use a \`tabs\` block. NOT MODELLED \u2014
|
|
4429
|
+
cookie banners, modals, breadcrumbs and pagination have no primitive at all. Leave them in the
|
|
4430
|
+
site's code and say so in your final summary.
|
|
4431
|
+
|
|
4432
|
+
**2. One component per distinct section, one-offs included.** \`create_component\` with a
|
|
4433
|
+
\`sectionType\` IS the section. \`sectionType\` is the FAMILY (Hero, Features, Testimonials,
|
|
4434
|
+
Pricing, FAQ, Contact, Newsletter, CTA, Stats, Logo cloud, Content\u2026) and components sharing one
|
|
4435
|
+
are its VARIANTS \u2014 give a family the same prop KEYS or a swap drops content. A one-off band
|
|
4436
|
+
still gets its own component: its own family, plus \`allowedOn: ["slug:<page>"]\`. A component
|
|
4437
|
+
with NO \`sectionType\` never appears in the editor's "Add a section" picker. Start from the
|
|
4438
|
+
builtin blueprints \`list_components\` returns (\`builtin:*\`) instead of hand-writing block JSON.
|
|
4439
|
+
On a DERIVED site run \`get_componentize_plan\` \u2192 \`componentize_sections\` FIRST \u2014 it does most
|
|
4440
|
+
of this in one call.
|
|
4441
|
+
|
|
4442
|
+
**3. The hierarchy editors see is the GROUP.** \`create_component\` and \`update_component\` take
|
|
4443
|
+
\`group\`: a NAME, resolved against the project's component groups and created when missing. That
|
|
4444
|
+
is the folder the dashboard's Components tab shows. Use the names the request asks for \u2014 Global,
|
|
4445
|
+
Navigation, Actions, Content, Forms, Media, Sections, Page-specific. \`category\` is a different
|
|
4446
|
+
axis: it is the picker TAB (hero / content / social-proof / conversion for sections; navbar /
|
|
4447
|
+
footer for chrome; form; custom for page-specific).
|
|
4448
|
+
|
|
4449
|
+
**4. Editor fields are \`props\`.** Types: \`text richtext image url boolean number select group
|
|
4450
|
+
table slot\`. Always set \`defaultValue\`. \`required\`, \`helpText\` and \`placeholder\` are editor
|
|
4451
|
+
hints the dashboard renders \u2014 they are NOT enforced at write time, so an override missing a
|
|
4452
|
+
required prop still saves; \`get_site_composition\` is what reports those. Use \`select\` with
|
|
4453
|
+
\`config.options\` for layout / theme / alignment / spacing variants that change only styling,
|
|
4454
|
+
and a SEPARATE variant component when the markup differs. Repeatable list \u2192 \`table\`; fixed
|
|
4455
|
+
cluster \u2192 \`group\`. Nesting is capped at TWO levels from the prop (a \`table\` row may hold a
|
|
4456
|
+
\`table\`, and that inner row is scalars only). Flatten anything deeper.
|
|
4457
|
+
|
|
4458
|
+
**5. Design, responsiveness and accessibility.** \`componentize_sections\` and \`npx
|
|
4459
|
+
@bettercms-ai/convert --componentize\` move the section's markup VERBATIM \u2014 classes, responsive
|
|
4460
|
+
utilities, aria attributes and alt text all survive. Never restyle while componentizing. Run
|
|
4461
|
+
\`componentize_sections { dryRun: true }\` and the codemod's \`--dry-run\` first.
|
|
4462
|
+
|
|
4463
|
+
**6. The final validation is \`get_site_composition\`.** It is the receipt for "is every page
|
|
4464
|
+
assembled from registered components?": per page its blocks and every component placement with
|
|
4465
|
+
that component's status, plus site totals \u2014 unpublished, awaiting evidence, awaiting approval,
|
|
4466
|
+
missing from the picker, placed nowhere \u2014 and \`verdict.everyPageComposed\` with the reasons it
|
|
4467
|
+
is false. A component reaches a visitor only once PUBLISHED, and publishing needs the project
|
|
4468
|
+
owner's approval in the DASHBOARD: evidence goes \`submit_section_manifest\` \u2192
|
|
4469
|
+
\`submit_section_validation\`, the owner approves there, then \`publish_components\`. There is no
|
|
4470
|
+
bulk approve, and no tool here can grant that approval. Finish with \`get_next_steps\`.
|
|
4471
|
+
|
|
4472
|
+
**Order of operations.**
|
|
4473
|
+
|
|
4474
|
+
1. get_site_composition the before picture
|
|
4475
|
+
2. get_componentize_plan DERIVED site only \u2014 confirm it with the user
|
|
4476
|
+
3. componentize_sections dryRun first, then for real
|
|
4477
|
+
4. create_components everything the plan did not cover, ONE call, each with a group
|
|
4478
|
+
5. compose_pages every page's blockJson, ONE call
|
|
4479
|
+
6. update_layout { commands } nav/footer chrome + add-component, ONE call
|
|
4480
|
+
7. evidence submit_section_manifest -> submit_section_validation
|
|
4481
|
+
8. owner approves in the dashboard, then publish_components ONE call, per-item receipts
|
|
4482
|
+
9. update_page status:'published', publish_layout
|
|
4483
|
+
10. get_site_composition, then get_next_steps
|
|
4484
|
+
|
|
4485
|
+
**What to tell the user at the end.** The group hierarchy and what landed in each; every family
|
|
4486
|
+
and its variants; what you left in the site's code because the platform does not model it
|
|
4487
|
+
(cookie banners, modals, breadcrumbs, pagination) and why; and exactly which components are
|
|
4488
|
+
still waiting for THEIR approval in the dashboard \u2014 until that happens those sections render as
|
|
4489
|
+
an empty string on the live site.
|
|
4490
|
+
|
|
4163
4491
|
## 13. Convert an imported repo into a CMS-backed, editable site
|
|
4164
4492
|
|
|
4165
4493
|
\xA711 says a deploy does not make a site editable. This is the recipe that does, and it ends in a
|
|
@@ -4339,7 +4667,7 @@ conversion is yours to write.
|
|
|
4339
4667
|
\`bound > 0\`, and \u2014 for a site converted against a pinned brief \u2014 \`coverage.pending: []\`. That certifies one thing only \u2014 that every non-empty field has SOME element
|
|
4340
4668
|
carrying its path. It CANNOT see copy that was never modelled, so diff each route's visible
|
|
4341
4669
|
text against its entry values yourself before you call the page done. Then publish, and
|
|
4342
|
-
fetch the live URL cache-busted (\
|
|
4670
|
+
fetch the live URL cache-busted (RECEIPTS in \xA711: publish and deploy are separate claims).
|
|
4343
4671
|
\`get_next_steps\` keeps reporting the gap until every one of these holds.
|
|
4344
4672
|
|
|
4345
4673
|
**Done means.** Whichever recipe you ran: \`get_binding_report\` reads \`mode "declared"\`,
|
|
@@ -4425,6 +4753,41 @@ error-prone, so go slow and confirm. Never guess the layout.
|
|
|
4425
4753
|
6. **\`publish_component\`.** It lands as a DRAFT, and a draft component renders as NOTHING on
|
|
4426
4754
|
the live site \u2014 no error, no placeholder. This step is not optional.
|
|
4427
4755
|
`;
|
|
4756
|
+
var COMPONENTIZE_SITE_FLOW = `### Componentize the entire website \u2192 playbook \xA712 + the batch tools
|
|
4757
|
+
Read \`bettercms://playbook/schema\` \xA712 first \u2014 this is the short form of it.
|
|
4758
|
+
1. **Global elements.** Navbar / mobile nav / header / footer are the LAYOUT (\`get_layout\`,
|
|
4759
|
+
\`update_layout\`; a chrome component is placed with the \`add-component\` command). CTA buttons
|
|
4760
|
+
are \`button\` BLOCKS inside the section that uses them, exposed as \`text\` + \`url\` props \u2014
|
|
4761
|
+
variant/size/icon are that block's own props and \`style\`, never a Button component. Rich text
|
|
4762
|
+
is a \`richtext\` prop. Forms are \`create_form\` + a \`form\` block, and form FIELDS are the only
|
|
4763
|
+
place with real \`required\` / \`placeholder\` / \`helpText\` / \`validation\`. Cards, badges, tags,
|
|
4764
|
+
links, lists and media are blocks inside a section, repeatables as one \`table\` prop.
|
|
4765
|
+
Accordions/FAQ: \`builtin:faq-list\` or a \`tabs\` block. NOT MODELLED: cookie banners, modals,
|
|
4766
|
+
breadcrumbs, pagination \u2014 leave them in the site's code and say so at the end.
|
|
4767
|
+
2. **One component per distinct section**, one-offs included. \`sectionType\` is the family;
|
|
4768
|
+
components sharing one are VARIANTS and must share prop KEYS. A one-off gets its own family
|
|
4769
|
+
plus \`allowedOn: ["slug:<page>"]\`. Start from the \`builtin:*\` blueprints in
|
|
4770
|
+
\`list_components\`. On a DERIVED site run \`get_componentize_plan\` \u2192 \`componentize_sections\`
|
|
4771
|
+
first (dryRun first, and confirm the plan with the user).
|
|
4772
|
+
3. **Hierarchy** = \`group\` on create_component/update_component (a NAME; created when missing).
|
|
4773
|
+
Use Global, Navigation, Actions, Content, Forms, Media, Sections, Page-specific. \`category\`
|
|
4774
|
+
is the separate picker TAB.
|
|
4775
|
+
4. **Fields** = \`props\`: text, richtext, image, url, boolean, number, select, group, table, slot.
|
|
4776
|
+
Always a \`defaultValue\`; \`required\` / \`helpText\` / \`placeholder\` are editor hints, not write
|
|
4777
|
+
-time enforcement. \`select\` for styling-only variants, a separate component when the markup
|
|
4778
|
+
differs. Nesting is capped at two levels \u2014 flatten anything deeper.
|
|
4779
|
+
5. **Quality.** The componentize lane and \`npx @bettercms-ai/convert --componentize\` move markup
|
|
4780
|
+
VERBATIM \u2014 classes, responsive utilities, aria attributes, alt text all survive. Never
|
|
4781
|
+
restyle. Dry-run first.
|
|
4782
|
+
6. **Validation** = \`get_site_composition\`: every page's placements, each component's status,
|
|
4783
|
+
and \`verdict.everyPageComposed\` with its reasons. Publishing needs the OWNER's approval in
|
|
4784
|
+
the dashboard (evidence via \`submit_section_manifest\` \u2192 \`submit_section_validation\`), then
|
|
4785
|
+
\`publish_components\`. No tool here can grant that approval, and there is no bulk approve.
|
|
4786
|
+
|
|
4787
|
+
**Order:** get_site_composition \u2192 (get_componentize_plan \u2192 componentize_sections) \u2192
|
|
4788
|
+
create_components \u2192 compose_pages \u2192 update_layout { commands } \u2192 evidence + owner approval \u2192
|
|
4789
|
+
publish_components \u2192 publish the pages / publish_layout \u2192 get_site_composition \u2192 get_next_steps.
|
|
4790
|
+
Prefer the batch tools whenever there is more than three of anything.`;
|
|
4428
4791
|
var LAYOUT_FLOW = `### Project/page Layout authoring \u2192 \`get_layout\` / \`update_layout\` / \`publish_layout\`
|
|
4429
4792
|
Edit the project's draft Global Layout or one page's draft override. \`update_layout\` never
|
|
4430
4793
|
changes the live site; \`publish_layout\` takes the Global Layout draft live (page overrides go
|
|
@@ -4511,6 +4874,8 @@ ${FORM_FLOW}
|
|
|
4511
4874
|
|
|
4512
4875
|
${COMPONENT_FLOW}
|
|
4513
4876
|
|
|
4877
|
+
${COMPONENTIZE_SITE_FLOW}
|
|
4878
|
+
|
|
4514
4879
|
${LAYOUT_FLOW}
|
|
4515
4880
|
|
|
4516
4881
|
${BUILD_SITE_FLOW}
|
|
@@ -4662,6 +5027,36 @@ On 409 slug_taken, offer an alternative slug; on 401/403, the MCP key needs (re)
|
|
|
4662
5027
|
]
|
|
4663
5028
|
})
|
|
4664
5029
|
);
|
|
5030
|
+
server.registerPrompt(
|
|
5031
|
+
"componentize_site",
|
|
5032
|
+
{
|
|
5033
|
+
title: "Componentize the entire website (guided)",
|
|
5034
|
+
description: "Turn every section of a site into a reusable component, organised into groups editors can browse: audit with get_site_composition, create the components in one call, compose every page, then publish what the owner has approved.",
|
|
5035
|
+
argsSchema: {
|
|
5036
|
+
request: z2.string().optional().describe("scope or emphasis, e.g. 'marketing pages only'")
|
|
5037
|
+
}
|
|
5038
|
+
},
|
|
5039
|
+
({ request }) => ({
|
|
5040
|
+
messages: [
|
|
5041
|
+
{
|
|
5042
|
+
role: "user",
|
|
5043
|
+
content: {
|
|
5044
|
+
type: "text",
|
|
5045
|
+
text: `Componentize this entire website in BetterCMS.${request ? ` Scope: "${request}".` : ""}
|
|
5046
|
+
|
|
5047
|
+
${COMPONENTIZE_SITE_FLOW}
|
|
5048
|
+
|
|
5049
|
+
${STRUCTURE_RULE}
|
|
5050
|
+
|
|
5051
|
+
Confirm the component library with the user before creating it. End with the summary \xA712 asks
|
|
5052
|
+
for: the group hierarchy, each family and its variants, what you left in the site's code because
|
|
5053
|
+
the platform does not model it, and which components are still waiting for the owner's approval
|
|
5054
|
+
in the dashboard \u2014 those render as an empty string until they are published.`
|
|
5055
|
+
}
|
|
5056
|
+
}
|
|
5057
|
+
]
|
|
5058
|
+
})
|
|
5059
|
+
);
|
|
4665
5060
|
server.registerPrompt(
|
|
4666
5061
|
"build_site",
|
|
4667
5062
|
{
|
|
@@ -4804,7 +5199,7 @@ function buildServer(deps) {
|
|
|
4804
5199
|
// 🔴 The plain-language ask, routed — the stdio twin of the hosted connector's line
|
|
4805
5200
|
// (src/routes/mcp/index.ts MCP_INSTRUCTIONS). "Make my site editable" has one recipe and
|
|
4806
5201
|
// one definition of done; without this an agent converts the page it landed on and stops.
|
|
4807
|
-
instructions: "When the user asks to make a site or all of its pages editable, to convert it, or to
|
|
5202
|
+
instructions: "When the user asks to make a site or all of its pages editable, to convert it, or to bind its fields: this is playbook \xA713. Read `bettercms://playbook/schema` \xA713, call get_binding_report and get_conversion_brief { complete: true }, convert EVERY route the brief lists, and finish only when get_binding_report shows coverage.pending empty on every route \u2014 not when the first page works. On a workspace-wide connection pass projectId on every call; never ask the user to re-scope the connection. When the user asks to componentize the whole site, to turn every section into a component, or to build a component library from the site: this is playbook \xA712. Read `bettercms://playbook/schema` \xA712, start with get_site_composition, and use the batch tools \u2014 create_components, compose_pages, update_layout with `commands`, publish_components \u2014 rather than one call per component. Finish with get_site_composition and tell the user what the platform does not model (cookie banners, modals, breadcrumbs, pagination) and which components still need the owner's approval in the dashboard before they can be published. BetterCMS never executes a customer's Section renderer or app code. An ordinary MCP connection is not a push runner: explicitly poll list_section_validation_requests, claim one request at an exact git commit, run implementation and responsive checks inside the user's own repository and real app shell, then submit manifest + validation with that requestId and complete it\u2014or truthfully fail it when implementation/evidence is missing. Never invent a manifest, a passing validation, or visual evidence; these tools cannot grant the separate human Visual Approval required for publication."
|
|
4808
5203
|
}
|
|
4809
5204
|
);
|
|
4810
5205
|
server.registerResource(
|