@bettercms-ai/mcp 0.54.0 → 0.54.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 CHANGED
@@ -2280,7 +2280,9 @@ var fieldShape = {
2280
2280
  ),
2281
2281
  label: z.string().min(1).describe("human label shown in the editor"),
2282
2282
  type: fieldType,
2283
- required: z.boolean().optional(),
2283
+ required: z.boolean().optional().describe(
2284
+ "an entry cannot be saved without a value \u2014 set it only where the site genuinely cannot render without one"
2285
+ ),
2284
2286
  richText: z.boolean().optional().describe(
2285
2287
  "prose formatting. DEFAULTS TO TRUE for 'text': the field is created as rich text so editors can bold, link and format it on the canvas, and the API returns rich text (render with rich() from @bettercms-ai/sdk; plain() for titles and meta). Pass false for a value that is NOT prose and must stay a bare string \u2014 a URL/href, a slug, an id, an email, a phone number, a CSS class, an icon name. A link stored as rich text will not work as an href."
2286
2288
  ),
@@ -2288,7 +2290,7 @@ var fieldShape = {
2288
2290
  "choices when type is 'select'. A plain string, or {label, value} when the stored value differs from what the editor shows (e.g. {label:'Sold out', value:'soldOut'})."
2289
2291
  ),
2290
2292
  config: z.record(z.string(), z.unknown()).optional().describe(
2291
- "per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, array {itemType: 'text'|'number'|'date'}, date {includeTime}, modular {blockSlugs: ['quote','gallery'], minItems?, maxItems?} \u2014 blockSlugs is REQUIRED, non-empty, and each slug must name an existing kind:'block' model"
2293
+ "per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, array {itemType: 'text'|'number'|'date'}, date {includeTime}, modular {blockSlugs: ['quote','gallery'], minItems?, maxItems?} \u2014 blockSlugs is REQUIRED, non-empty, and each slug must name an existing kind:'block' model; richtext {inline:true, hostTag:'h2', accept:['h2','p','b','i','u','link'], features:['bold','italic','underline','link','headings'], allowMultipleParagraphs:false} \u2014 for a field bound to ONE element on the page: `hostTag` is that element's tag and `accept` must name it, so the editor's block-style control opens already showing 'Heading 2' instead of 'Paragraph'. Name exactly ONE heading level: publish refuses an <h3> value bound to an <h2> rather than nesting one heading inside the other. Keep 'p' in `accept` or the sanitiser strips a block demoted to Paragraph"
2292
2294
  ),
2293
2295
  fields: z.array(z.lazy(() => fieldObject)).optional().describe(
2294
2296
  "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."
@@ -2314,7 +2316,7 @@ var fieldShape = {
2314
2316
  "conditional visibility \u2014 hide this field until another field has a given value (e.g. show `external_url` only when `link_type` is 'external'). Admin-only: it changes what an author SEES, never what is stored or delivered. Rules reference fields by BARE KEY, and the key namespace is flat, so the key must exist on this model."
2315
2317
  ),
2316
2318
  ui: uiObject.optional().describe(
2317
- "AUTHORING CHROME \u2014 how this field presents in the editor. Never a delivery effect. `preview` and `layout` are valid ONLY on a nesting field ('group', 'repeater', an 'array' with config.zones, or 'modular'); `reorderable` only where there is a LIST to lock ('repeater', an 'array' with config.zones.repeatable, or 'modular' \u2014 a 'group' holds one item). preview keys must name real children, or the write is refused with the valid keys listed. An unknown key inside `ui` is a 400, not a silent strip. Read it back with get_content_model / get_page to confirm it landed. There is deliberately no `order`: field order is the array index \u2014 use add_field's `after` to insert."
2319
+ "AUTHORING CHROME \u2014 how this field presents in the editor; never a delivery effect. `preview` slots name a CHILD FIELD KEY and must name real children, or the write is refused with the valid keys listed. `preview`/`layout` need a nesting field, `reorderable` needs a list, and an unknown key inside `ui` is a 400, not a silent strip. No `order`: field order is the array index \u2014 use `after`. Section 14 of bettercms://playbook/schema has each key and where it is legal."
2318
2320
  ),
2319
2321
  // ── G5: the four keys the backend field schema accepts that this whitelist stripped. ──
2320
2322
  fieldsetId: z.string().min(1).optional().describe(
@@ -2428,7 +2430,8 @@ var BRAND_KIT_NOTE = "`brandKit` is the project's brand kit: colors, typography,
2428
2430
  var MODEL_IF_MATCH_NOTE = "optimisticVersion from get_content_model. When sent, the update applies only if the model is still at that version; a 409 means someone changed it since \u2014 read it again and re-apply, never retry blindly. Omitted, the last write wins.";
2429
2431
  var ENTRY_META_NOTE = "SEO is native: set this entry's metaTitle, metaDescription, noindex, canonical, og, twitter, schemaType or schema in `meta` (never as model fields). `meta` MERGES into what is stored: a key you leave out is kept, null or an empty string clears it.";
2430
2432
  var PAGE_HEAD_NOTE = "noindex, canonical, og and twitter set the page's head extras; each MERGES into what is stored (a key you leave out is kept, null or an empty string clears it).";
2431
- var STRUCTURE_NOTE = 'ORGANISATION ONLY: Content sidebar folders never change a URL, slug, content or publish state, and nothing is rebuilt or delivered; URL folders (page folders) are set in the dashboard. Folders nest at most 4 levels deep (a page or collection may sit in a level-4 folder). Icons are lucide icon names, e.g. "folder", "file-text", "rows-3", "house" (a-z, 0-9 and dashes); null clears one. SYSTEM FOLDERS (CPO-129): "home:pages" (Other pages), "home:collections" (Shared) and "home:globals" (Settings) always exist, even when the document has no record for them. They close the sidebar, below the folders you make, and can never be moved or deleted (400 NAV_SYSTEM_FOLDER), but rename_folder and set_icon work on them and create their record on first use. TYPE PURITY: anything inside Other pages, at any depth, must be a page, and anything inside Shared must be a collection, and Settings holds NO pages or collections at all, because globals are not sidebar items (subfolders under Settings are allowed but stay empty). Any of these is 400 NAV_TYPE_MISMATCH. Your own folders hold either kind. ACCESS (CPO-131): Requires Admin or Developer access to CHANGE the structure, the same access as editing the schema; anyone who can read content can call get_content_structure. A 403 SCHEMA_ACCESS_REQUIRED means the person behind this connection is not an Admin or Developer: stop and tell the user, do not retry. ROOT PINS (CPO-132): folderId \'root\' pins a page or collection at the top level; null returns it to its home (Other pages / Shared). Pins sit above the folders, in their own order, and may be pages or collections. In a document a pin is folderId "home:root", a virtual id with no folder record (a folder record with that id is 400 NAV_SYSTEM_FOLDER). KIND CHECK: an id is either a page or a collection, never both, and a custom folder holds either \u2014 so move_to_folder and set_icon VERIFY that the id really is the `kind` you named on this branch, and refuse a mismatch with 400 NAV_KIND_MISMATCH naming the kind to use; call it again with that kind, which also clears the wrong-kind entry. An id that resolves to nothing is allowed (it may exist on another branch) and the result says so. set_content_structure never refuses: it returns `warnings` and `kindMismatches`. get_content_structure marks such items `kindMismatch: true` and lists them in `summary.kindMismatches`. PAGE-BOUND MODELS (CPO-135): a page with fields has a content model bound to it. That model is NOT a collection \u2014 get_content_structure never lists it among collections or in the tree, and move_to_folder / set_icon / set_content_structure refuse it with 400 NAV_PAGE_BOUND_MODEL naming the owning page (\'This model belongs to the page "\u2026"; organise the page.\'). Organise the PAGE instead, by its page id. If a stored document still references one it appears in `summary.hiddenItems` with `boundToPageId`, and the next single write drops it.';
2433
+ var STRUCTURE_NOTE = 'ORGANISATION ONLY: Content sidebar folders never change a URL, slug, content or publish state; URL folders (page folders) are set in the dashboard. Folders nest at most 4 levels deep. Icons are lucide icon names (a-z, 0-9, dashes); null clears one. SYSTEM FOLDERS: "home:pages" (Other pages), "home:collections" (Shared) and "home:globals" (Settings) always exist and can never be moved or deleted (400 NAV_SYSTEM_FOLDER); rename_folder and set_icon do work on them. TYPE PURITY (400 NAV_TYPE_MISMATCH): Other pages holds only pages at any depth, Shared only collections, and Settings neither \u2014 globals are not sidebar items. ACCESS: changing the structure needs Admin or Developer, the same as editing the schema; reading needs only content access. A 403 SCHEMA_ACCESS_REQUIRED means this connection is neither: stop and tell the user, do not retry. Pins sit above the folders, in their own order, and may be pages or collections. In a document a pin is folderId "home:root", a virtual id with no folder record (a folder record with that id is 400 NAV_SYSTEM_FOLDER). set_content_structure never refuses a kind mismatch: it returns `warnings` and `kindMismatches` (the single writes do \u2014 see `kind`). PAGE-BOUND MODELS: a page with fields has a model bound to it. That model is NOT a collection: it is never listed or filed, and the writes refuse it with 400 NAV_PAGE_BOUND_MODEL naming its page. Organise the PAGE instead, by its page id. ';
2434
+ var STRUCTURE_FENCE = "ORGANISATION ONLY: Content sidebar folders never change a URL, slug, content or publish state, and nothing is rebuilt or delivered; URL folders (page folders) are set in the dashboard. Read the resource bettercms://playbook/structure before you organise anything: it holds the rules this refuses on \u2014 system folders, type purity, the Admin/Developer access check, root pins and page-bound models. A resource is never truncated; a long description is.";
2432
2435
  function structureResult(d) {
2433
2436
  const message = d?.message;
2434
2437
  return ok(typeof message === "string" ? message : "Updated the Content structure.", d);
@@ -2522,7 +2525,7 @@ function authPrompt(err) {
2522
2525
  }
2523
2526
  var currentProject = new AsyncLocalStorage();
2524
2527
  var projectIdArg = z.string().min(1).optional().describe(
2525
- "only needed when your grant covers the whole WORKSPACE: the project to act on (from list_projects). A project-scoped key ignores it."
2528
+ "target project id (from list_projects); only for a workspace-wide grant."
2526
2529
  );
2527
2530
  var withProjectId = (def) => ({
2528
2531
  ...def,
@@ -2532,6 +2535,47 @@ var withProjectId = (def) => ({
2532
2535
  return currentProject.run(project, () => def.handler(args));
2533
2536
  }
2534
2537
  });
2538
+ var LISTING_TAIL = /* @__PURE__ */ new Set([
2539
+ // The agent-job lane: a dashboard surface an agent rarely drives.
2540
+ "list_ai_jobs",
2541
+ "get_ai_job",
2542
+ "approve_ai_job",
2543
+ "reject_ai_job",
2544
+ "list_ai_reports",
2545
+ "connect_native_ai",
2546
+ "native_bridge_heartbeat",
2547
+ // Section evidence and component validation: a CI lane, not an authoring one.
2548
+ "claim_section_validation_request",
2549
+ "complete_section_validation_request",
2550
+ "fail_section_validation_request",
2551
+ "list_section_validation_requests",
2552
+ "submit_section_validation",
2553
+ "submit_section_manifest",
2554
+ "request_component_validation",
2555
+ "declare_component_route",
2556
+ "clear_component_source",
2557
+ "submit_componentize_receipt",
2558
+ // Reporting, history and workflow chrome.
2559
+ "get_analytics_overview",
2560
+ "get_analytics_top_pages",
2561
+ "list_activity",
2562
+ "get_workflow_board",
2563
+ "move_entry_stage",
2564
+ "move_page_stage",
2565
+ "list_entry_versions",
2566
+ "restore_entry_version",
2567
+ // One-off project operations and the extraction lane.
2568
+ "clone_project",
2569
+ "promote_project",
2570
+ "generate_pages_from_dataset",
2571
+ "extract_component",
2572
+ "list_extraction_candidates",
2573
+ "create_deploy_upload",
2574
+ "deploy_from_upload",
2575
+ "set_authoring_preference",
2576
+ "set_media_delivery",
2577
+ "delete_form_submission"
2578
+ ]);
2535
2579
  function buildToolDefs(deps) {
2536
2580
  async function withClient(fn) {
2537
2581
  const token = await deps.auth.getAccessToken();
@@ -2586,7 +2630,7 @@ function buildToolDefs(deps) {
2586
2630
  ]).describe("block type; section/slider/tabs/columns nest child blocks"),
2587
2631
  id: z.string().min(1).describe("stable unique block id"),
2588
2632
  props: z.record(z.string(), z.unknown()).describe(
2589
- "per-type props: heading {text, level}; text/richtext {html} (NOT {text}); image {src, alt}; button {text, href}; spacer {height}; video {url}; form {formId}; component {componentId, overrides?}; navbar {links:[{label,href}], logo?, cta?}; footer {columns, copyright?}; section {children: block[]}; columns {columns: block[][], gap} \u2014 a column may NOT hold columns/section/slider/tabs; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, detailComponentId?, titleField?, excerptField?, limit?, order?, emptyText?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug>"
2633
+ "per-type props: heading {text, level}; text/richtext {html} (NOT {text}), and both also take level 1-6, rendering the html AS that <hN> with inline marks kept (one line of inline copy only) \u2014 use it for a title that carries a styled span; image {src, alt}; button {text, href}; spacer {height}; video {url}; form {formId}; component {componentId, overrides?}; navbar {links:[{label,href}], logo?, cta?}; footer {columns, copyright?}; section {children: block[]}; columns {columns: block[][], gap} \u2014 a column may NOT hold columns/section/slider/tabs; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, detailComponentId?, titleField?, excerptField?, limit?, order?, emptyText?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug>"
2590
2634
  ),
2591
2635
  style: z.record(z.string(), z.unknown()).optional().describe(
2592
2636
  "design tokens: theme, bg (none|surface|muted|accent|dark|custom), bgCustom hex, paddingTop/paddingBottom/paddingSides px, contentWidth (narrow|default|wide|full), align, corner, shadow, borderTop/borderBottom. A real marketing band is a `section` block carrying bg + padding + contentWidth."
@@ -2748,8 +2792,11 @@ function buildToolDefs(deps) {
2748
2792
  ]),
2749
2793
  placeholder: z.string().optional(),
2750
2794
  helpText: z.string().optional().describe("hint shown under the control, muted"),
2751
- required: z.boolean().optional(),
2795
+ required: z.boolean().optional().describe(
2796
+ "an entry cannot be saved without a value \u2014 set it only where the site genuinely cannot render without one"
2797
+ ),
2752
2798
  options: z.array(z.string()).optional().describe("choices when type is 'select', 'radio' or 'checkboxes'"),
2799
+ optionValues: z.array(z.string()).optional().describe("what each choice SUBMITS, positionally paired with `options` \u2014 only where it differs from the label"),
2753
2800
  hidden: z.boolean().optional().describe("not rendered; pairs with defaultValue to capture context"),
2754
2801
  defaultValue: z.string().optional(),
2755
2802
  showIf: z.object({ field: z.string(), equals: z.string() }).optional().describe("show this field only when another field equals a value"),
@@ -3154,11 +3201,11 @@ function buildToolDefs(deps) {
3154
3201
  z.object({ formId: z.string().min(1), submissionId: z.string().min(1) }).shape,
3155
3202
  async (c, a) => ok("Deleted submission.", await data(c, "DELETE", `/management/forms/${s(a.formId)}/submissions/${s(a.submissionId)}`))
3156
3203
  ),
3157
- // ── Content structure: the Content sidebar's folders and icons (CPO-127 f) ──
3204
+ // ── Content structure: the Content sidebar's folders and icons ──
3158
3205
  def(
3159
3206
  "get_content_structure",
3160
3207
  "Get the Content sidebar structure",
3161
- `Read the Content sidebar's structure for the connected project: its folders, which pages and collections sit in each, and their icons. Returns { doc, version, summary }: \`doc\` is the raw document ({ schema: 1, folders: [{ id, parentId, name, icon, sort }], items: [{ kind: 'page'|'collection', id, folderId, sort, icon }] }), \`version\` is what set_content_structure needs, and \`summary\` is the resolved tree with page and collection TITLES (\`outline\` is a readable version), ids that no longer resolve (\`missing\`), and the pages and collections not in any folder yet. Call it before changing the structure, and read the resource ${STRUCTURE_PLAYBOOK_URI} (the structure standard) before you organise anything. ${STRUCTURE_NOTE}`,
3208
+ `Returns { doc, version, summary }: \`doc\` is the raw document ({ schema: 1, folders: [{ id, parentId, name, icon, sort }], items: [{ kind: 'page'|'collection', id, folderId, sort, icon }] }), \`version\` is what set_content_structure needs, and \`summary\` is the resolved tree with page and collection TITLES (\`outline\` is a readable version), ids that no longer resolve (\`missing\`), and the pages and collections not in any folder yet. Call it before changing the structure, and read the resource ${STRUCTURE_PLAYBOOK_URI} (the structure standard) before you organise anything. ${STRUCTURE_NOTE}`,
3162
3209
  z.object({}).shape,
3163
3210
  async (c) => {
3164
3211
  const d = await data(c, "GET", `/management/content-structure`);
@@ -3182,7 +3229,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3182
3229
  def(
3183
3230
  "set_content_structure",
3184
3231
  "Replace the Content sidebar structure",
3185
- `Replace the whole Content sidebar document in one write. Follow the structure standard (read ${STRUCTURE_PLAYBOOK_URI} first; suggest_content_structure returns a ready doc). Prefer the single-action tools (create_folder, rename_folder, move_to_folder, set_icon, delete_folder) for small changes; use this to lay out a full structure at once. Send the complete \`doc\` and the \`version\` get_content_structure returned (0 when the project has none yet). Validated exactly like the dashboard: unique folder ids, every parentId/folderId must be a folder in the doc, no cycles, depth at most 4, names 1-80 chars, lucide icon names. A 412 VERSION_CONFLICT means someone changed it since you read it: call get_content_structure again and re-apply your change. Returns the new version and a diff (folders added, removed, renamed or moved; items moved; icons changed). ${STRUCTURE_NOTE}`,
3232
+ `Replace the whole Content sidebar document in one write. Follow the structure standard (read ${STRUCTURE_PLAYBOOK_URI} first; suggest_content_structure returns a ready doc). Send the complete \`doc\` and the \`version\` get_content_structure returned (0 when the project has none yet). Validated exactly like the dashboard: unique folder ids, every parentId/folderId a folder in the doc, no cycles, depth \u22644, names 1-80 chars, lucide icons. A 412 VERSION_CONFLICT means someone changed it since you read it: read again and re-apply. Returns the new version and a diff. ${STRUCTURE_FENCE}`,
3186
3233
  z.object({
3187
3234
  doc: z.object({
3188
3235
  schema: z.literal(1),
@@ -3208,7 +3255,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3208
3255
  def(
3209
3256
  "create_folder",
3210
3257
  "Create a Content sidebar folder",
3211
- `Create a folder in the Content sidebar, at the top level or inside \`parentId\`, placed after what is already there. Returns the new \`folderId\`. Reads, applies and writes with the version check, retrying once on a conflict. ${STRUCTURE_NOTE}`,
3258
+ `Create a folder in the Content sidebar, at the top level or inside \`parentId\`, placed after what is already there. Returns the new \`folderId\`. Reads, applies and writes with the version check, retrying once on a conflict. ${STRUCTURE_FENCE}`,
3212
3259
  z.object({
3213
3260
  name: z.string().min(1).describe("the folder's label, 1-80 chars"),
3214
3261
  parentId: z.string().min(1).optional().describe("optional parent folder id (from get_content_structure); omit it, or send 'root', for the top level"),
@@ -3219,7 +3266,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3219
3266
  def(
3220
3267
  "rename_folder",
3221
3268
  "Rename a Content sidebar folder",
3222
- `Rename a Content sidebar folder. ${STRUCTURE_NOTE}`,
3269
+ `Rename a Content sidebar folder. ${STRUCTURE_FENCE}`,
3223
3270
  z.object({
3224
3271
  folderId: z.string().min(1).describe("folder id (from get_content_structure)"),
3225
3272
  name: z.string().min(1).describe("the new label, 1-80 chars")
@@ -3229,9 +3276,11 @@ ${d.outline}` : "Proposed Content structure.", d);
3229
3276
  def(
3230
3277
  "move_to_folder",
3231
3278
  "Move into a Content sidebar folder",
3232
- `Move a page, a collection or a folder into a folder. folderId null moves it back to its home: Other pages for a page, the Shared list for a collection, the top level for a folder. It lands after what is already there. Moving a folder takes its contents with it and is refused if the result would nest deeper than 4 levels or put a folder inside itself. ${STRUCTURE_NOTE}`,
3279
+ `Move a page, a collection or a folder into a folder. folderId null moves it back to its home: Other pages for a page, the Shared list for a collection, the top level for a folder. It lands after what is already there. Moving a folder takes its contents with it and is refused if the result would nest deeper than 4 levels or put a folder inside itself. ${STRUCTURE_FENCE}`,
3233
3280
  z.object({
3234
- kind: z.enum(["page", "collection", "folder"]),
3281
+ kind: z.enum(["page", "collection", "folder"]).describe(
3282
+ "what the id IS on this branch \u2014 VERIFIED, not assumed: an id is either a page or a collection, never both, and a mismatch is refused with 400 NAV_KIND_MISMATCH naming the kind to use. Call it again with that kind, which also clears the wrong-kind entry."
3283
+ ),
3235
3284
  id: z.string().min(1).describe("the page id, collection (content model) id, or folder id"),
3236
3285
  folderId: z.string().min(1).nullable().describe("target folder id; 'root' pins a page or collection at the top level (for a folder, 'root' is the top level); null moves it back to its home (Other pages, the Shared list, or the top level for a folder)")
3237
3286
  }).shape,
@@ -3270,9 +3319,11 @@ ${d.outline}` : "Proposed Content structure.", d);
3270
3319
  def(
3271
3320
  "set_icon",
3272
3321
  "Set a Content sidebar icon",
3273
- `Set or clear the sidebar icon of a page, a collection or a folder. ${STRUCTURE_NOTE}`,
3322
+ `Set or clear the sidebar icon of a page, a collection or a folder. ${STRUCTURE_FENCE}`,
3274
3323
  z.object({
3275
- kind: z.enum(["page", "collection", "folder"]),
3324
+ kind: z.enum(["page", "collection", "folder"]).describe(
3325
+ "what the id IS on this branch \u2014 VERIFIED, not assumed: an id is either a page or a collection, never both, and a mismatch is refused with 400 NAV_KIND_MISMATCH naming the kind to use. Call it again with that kind, which also clears the wrong-kind entry."
3326
+ ),
3276
3327
  id: z.string().min(1).describe("the page id, collection (content model) id, or folder id"),
3277
3328
  icon: z.string().min(1).nullable().describe("lucide icon name, e.g. 'file-text'; null clears it")
3278
3329
  }).shape,
@@ -3281,7 +3332,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3281
3332
  def(
3282
3333
  "delete_folder",
3283
3334
  "Delete a folder (its contents move up; nothing is deleted)",
3284
- `Delete a folder (its contents move up; nothing is deleted). NOTHING ELSE IS DELETED: its pages, collections and sub-folders move up to the folder's parent, in the same order. ${STRUCTURE_NOTE}`,
3335
+ `Delete a folder (its contents move up; nothing is deleted). NOTHING ELSE IS DELETED: its pages, collections and sub-folders move up to the folder's parent, in the same order. ${STRUCTURE_FENCE}`,
3285
3336
  z.object({ folderId: z.string().min(1).describe("folder id (from get_content_structure)") }).shape,
3286
3337
  async (c, a) => structureResult(await data(c, "DELETE", `/management/content-structure/folders/${encodeURIComponent(s(a.folderId))}`))
3287
3338
  ),
@@ -3483,7 +3534,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3483
3534
  def(
3484
3535
  "submit_conversion_receipt",
3485
3536
  "Record what the conversion codemod could and could not do",
3486
- "Hand BetterCMS the codemod's own account of a conversion run, so the coverage meter can say WHY a path is not declared instead of only that it is not. Submit the receipt `npx @bettercms-ai/convert` wrote (`--receipt out.json`) for the SAME `briefDigest` get_conversion_brief { complete: true } returned: `{ briefDigest, receipt }`, where the receipt carries `paths: { declared, rewritten, alreadyDeclared, pending[{ route, scope, path, kind, file, reason, message, fix }] }`. `paths.declared` must equal rewritten + alreadyDeclared + pending.length, and each pending `reason` is one of the converter's own (IN_EXPRESSION, AMBIGUOUS_LITERAL, REPEATER_FIXED_LENGTH, PARSE_ERROR, \u2026) \u2014 a path with no receipt row simply reads `not-declared`. \u{1F534} A RECEIPT WITH PENDING PATHS IS A PROGRESS REPORT, NOT A FINISH LINE. The response answers `complete` and `pendingTotal`, and echoes the first 40 pending rows WITH their `fix` \u2014 `{ action, file, line, col?, snippet, why?, kind? }`, where `action` is one of `wrap-span` (wrap the literal in a `<span data-bcms-field=\u2026>`), `declare-attr` (add `data-bcms-field=\u2026` to the element at file:line), `bind-expression` (replace the expression with the framework's bcmsField helper), `declare-richtext` (bind the container with the richtext helper \u2014 and when it carries `kind: document` the container is the ONE element wrapping every block of a Body, never the paragraph holding its first block), `bind-data` (the literal comes from the data file at file:line \u2014 bind that field) or `manual` (with a one-sentence `why`). Apply every fix in the source, rerun the codemod, resubmit. `complete: true` is the only receipt that ends a conversion \u2014 do not report a site converted on anything less. It is a RECORD, not a release: it changes nothing about the site, and the meter picks it up on the next get_binding_report after the next deploy. A 404 `unknown-brief` means that digest was never issued here, so convert against a brief this project actually returned. \u{1F534} SUBMIT THE `--forms` RECEIPT TOO, AS A SECOND CALL. A run of `npx @bettercms-ai/convert --forms` writes a receipt whose `paths` are all zero and whose account is in a `forms` block (`{ wired, alreadyWired, pending[{ id, name, reason }], notes, wiredForms[{ id, file }] }`). Write it to its own file (`--receipt forms-receipt.json`) and submit it here under the same `briefDigest`: it is stored BESIDE the binding receipt and never touches coverage, and the next release reads `wiredForms` to say which component renders each form. Then read `forms.pending` and publish every form `forms.notes` names in the Forms tab. Requires artifact:write, the same authority as set_binding_mode; on a workspace-wide connection pass `projectId`.",
3537
+ "Hand BetterCMS the codemod's own account of a run, so the meter can say WHY a path is undeclared. Submit the receipt `npx @bettercms-ai/convert` wrote (`--receipt out.json`) for the SAME `briefDigest` get_conversion_brief { complete: true } returned: `{ briefDigest, receipt }`, where the receipt carries `paths: { declared, rewritten, alreadyDeclared, pending[{ route, scope, path, kind, file, reason, message, fix }] }`. `paths.declared` must equal rewritten + alreadyDeclared + pending.length, and each pending `reason` is one of the converter's own (IN_EXPRESSION, AMBIGUOUS_LITERAL, REPEATER_FIXED_LENGTH, PARSE_ERROR, \u2026) \u2014 a path with no receipt row simply reads `not-declared`. \u{1F534} A RECEIPT WITH PENDING PATHS IS A PROGRESS REPORT, NOT A FINISH LINE: The response answers `complete` and `pendingTotal`, and echoes the first 40 pending rows with their `fix` \u2014 `{ action, file, line, col?, snippet, why?, kind? }`. `action` is `wrap-span`, `declare-attr`, `bind-expression`, `declare-richtext`, `bind-data` or `manual`; each row's `snippet` and `why` say what to change there. On a `declare-richtext` with `kind: document`, bind the ONE element wrapping every block of the Body, never the paragraph holding its first. Apply every fix, rerun, resubmit. `complete: true` is the only receipt that ends a conversion; do not report a site converted on less. It is a RECORD, not a release: it changes nothing about the site, and the meter picks it up on the next get_binding_report after the next deploy. A 404 `unknown-brief` means that digest was never issued here. \u{1F534} SUBMIT THE `--forms` RECEIPT TOO, AS A SECOND CALL: A `npx @bettercms-ai/convert --forms` run writes a receipt whose `paths` are all zero and whose account is in a `forms` block. Write it to its own file (`--receipt forms-receipt.json`) and submit it under the same `briefDigest`: stored beside the binding receipt, never counted in coverage. Then read `forms.pending` and publish every form it names. Requires artifact:write, the same authority as set_binding_mode.",
3487
3538
  z.object({
3488
3539
  briefDigest: z.string().min(1).describe("The `briefDigest` get_conversion_brief { complete: true } returned. Must match the receipt's own."),
3489
3540
  receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --receipt out.json` wrote, verbatim.")
@@ -3719,8 +3770,8 @@ ${res.warnings.join("\n")}` : summary, res.data);
3719
3770
  def(
3720
3771
  "get_binding_report",
3721
3772
  "Check what on the live site is editable",
3722
- "The receipt for 'is this site actually EDITABLE?'. Every release scans the built HTML for the element that renders each CMS field value; this returns what that scan found, per slot: `mode` ('text-match' = bindings guessed from rendered text, 'declared' = the template declares them), `pagesInspected`, `bound` (elements carrying a binding), and `unmatched` \u2014 per page, each path with its kind and the reason it failed (not-declared / ambiguous-text / no-element). DEPLOY FIRST: before any release there is no report and this answers pages 0, mode null, refreshRequired true. `refreshReason` says which of two states that is: `never-generated` \u2014 no report was ever written for this slot, and only a release BetterCMS itself builds and serves writes one, so a HEADLESS site never gets one and redeploying the same way will not change it (verify that site by fetching it); `outdated` \u2014 a report exists in an older shape, and the next release rewrites it. It certifies exactly one thing \u2014 that every non-empty field of every page has SOME element carrying its path. It cannot see copy that was never modelled, so diff each route's visible text against its entry values yourself before calling a page done. `builtRoutes` lists the routes the live build has HTML for (null when unknown: runtime release, >500 routes, or a report older than this field). `canvas.lane` names the live-preview lane this build gives the editor \u2014 `bridge` (the build ships BcmsDraftBridge), `draft-route` (a node runtime rendering drafts server-side) or `none`, in which case get_next_steps carries the recipe (playbook section 11); null means the report predates the field. `unaddressable` counts visible text on the live site that no field owns \u2014 the one thing `unmatched` structurally cannot see, because it only ever speaks about fields that already exist. EVERY release measures it server-side on every inspected page: `{count, pages, measuredAt, routes:[{path, count, visible, buckets:[{tag, context, chars, nodes, samples}]}]}`, where `path` is the route (the empty string is the home page), `visible` its countable characters (ornaments, skip links and the platform badge excluded from both sides) and `buckets` where the unowned text is, biggest first. Anything over `count / visible > 0.02` is copy the CMS cannot see: fix it in the SOURCE (wrap the run in an element the codemod can bind, or declare `<BcmsField path=\u2026>`), deploy, re-read \u2014 playbook section 11 has the loop. An author turning the editor's Unbound text control on posts a bare count of text NODES for the route they opened (no `visible`, no buckets); those rows are reported only for a build the release never measured, never summed with the release's characters. `skipReasons` says why pages were not inspected (`no-html`, `route-cap`, `authored-content` = the singleton lane declined the project, so NO route of it has a page), and `coverage.error` = `nothing-bound` means not one element of this build carries a binding, so the percentage beside it describes a site this build is not. `unaddressable` is measured PER SLOT; on a promote-gated project pass slot:'current' to read the tree the editor frames. Pass `slot` ('current' or 'staging') to read the other tree; the default is the slot this project's releases land in.",
3723
- z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read; defaults to the one this project deploys to") }).shape,
3773
+ "The receipt for 'is this site actually EDITABLE?'. Every release scans the built HTML for the element that renders each CMS field value; this returns what that scan found, per slot: `mode` ('text-match' = bindings guessed from rendered text, 'declared' = the template declares them), what was bound, and `unmatched` \u2014 each path with the reason it failed (not-declared / ambiguous-text / no-element). DEPLOY FIRST: before any release there is no report (pages 0, mode null, refreshRequired true). `refreshReason` says which: `never-generated` \u2014 only a release BetterCMS builds and serves writes a report, so a HEADLESS site never gets one and redeploying will not change it (verify that site by fetching it); `outdated` \u2014 the next release rewrites it. It certifies one thing: every non-empty field has SOME element carrying its path. It cannot see copy that was never modelled, so diff each route's visible text against its entry values yourself before calling a page done. `builtRoutes` lists the routes the live build has HTML for, or null when it cannot say. `canvas.lane` names the live-preview lane this build gives the editor \u2014 `bridge`, `draft-route` or `none`, in which case get_next_steps carries the recipe (playbook section 11). `unaddressable` counts visible text on the live site that no field owns \u2014 the one thing `unmatched` structurally cannot see, because it only ever speaks about fields that already exist. EVERY release measures it server-side on every inspected page and the result names the routes, their countable characters and the buckets the unowned text sits in, biggest first. Anything over `count / visible > 0.02` is copy the CMS cannot see: fix it in the SOURCE (wrap the run in an element the codemod can bind, or declare `<BcmsField path=\u2026>`), deploy, re-read \u2014 playbook section 11 has the loop. `skipReasons` says why pages were not inspected; `coverage.error` = `nothing-bound` means not one element of this build carries a binding, so the percentage beside it describes a site this build is not.",
3774
+ z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read \u2014 everything here, `unaddressable` included, is measured PER SLOT, so on a promote-gated project pass 'current' to read the tree the editor frames. Defaults to the slot this project's releases land in.") }).shape,
3724
3775
  async (c, a) => ok("Binding report.", await data(c, "GET", `/management/projects/current/binding-report${q({ slot: a.slot })}`))
3725
3776
  ),
3726
3777
  def(
@@ -3757,13 +3808,13 @@ ${res.warnings.join("\n")}` : summary, res.data);
3757
3808
  def(
3758
3809
  "componentize_sections",
3759
3810
  "Turn this site's derived sections into components",
3760
- "Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections and say how many components it will create and which pages it will rewrite. It creates each proposed component as a DRAFT (its `sectionType` family, category 'section', placeable on any page) and replaces each page's DRAFT blocks with an ordered list of `component` instances \u2014 one per group, each carrying `props.bind: \"<groupKey>\"`, which points at the page field group that already holds the copy. So nothing is copied and nothing moves: the page keeps its `fields`, its values and its bindings, click-to-edit keeps working and the coverage meter does not change. Pass the plan's `digest`; a 409 `stale-plan` means the site changed since you read that plan, so read it again, show the user what changed and confirm again. Call it with `dryRun: true` first \u2014 same receipt, nothing written. Read its `components.wouldDuplicate` before applying: each id is an existing component of the same family this run would sit a NEW one beside, because reuse is by identity and slug, never by family. Place those by hand or accept the second component knowingly. Running it twice is safe: a group that already has a placement comes back in `sections.pending` as ALREADY_COMPONENTIZED and no second component is created. DRAFTS ONLY \u2014 an unpublished component renders as an EMPTY STRING on the live site, so publish_component each one and publish the pages before this reaches a visitor. Then run `npx @bettercms-ai/convert --componentize` in the repo so its templates render these sections from `pages[].blocks`. `copy` says WHO OWNS THE COPY. The default `bind` is the above: the words stay in the page's field group. `copy: \"instance\"` is the page-builder model \u2014 each placement takes that group's CURRENT draft values into its own `props.overrides` (a repeater becomes one table prop holding every row), records which page path each came from in `props.source`, and drops `props.bind`; the page's fields are KEPT and marked `origin: \"componentized\"`, so nothing is lost and the editor stops showing a second place to type the same words. Run it again and it converts nothing and duplicates nothing. `sections` narrows a run to named groupKeys (`hero`, `group-fast`); a key the plan does not list on the pages you named is refused as `unknown-section` rather than quietly doing nothing. Every component it CREATES is filed into a Component Group (the folders of the dashboard's Components tab): the one you name in `group` (found or created), else its section family \u2014 nav, header, footer or menu \u2192 Layout, an unnamed section \u2192 Sections, otherwise the family name (Hero, FAQ). A component it reuses keeps the Group it has; the receipt's `components.groups` lists the Groups it filed into.",
3811
+ "Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections and say how many components it will create and which pages it will rewrite. It creates each proposed component as a DRAFT and replaces each page's DRAFT blocks with an ordered list of `component` instances \u2014 one per group, each carrying `props.bind: \"<groupKey>\"`, which points at the page field group that already holds the copy. So nothing is copied and nothing moves: the page keeps its `fields`, its values and its bindings, click-to-edit keeps working and the coverage meter does not change. Pass the plan's `digest`; a 409 `stale-plan` means the site changed since you read that plan, so read it again, show the user what changed and confirm again. Read its `components.wouldDuplicate` before applying: each id is an existing component of the same family this run would sit a NEW one beside, because reuse is by identity and slug, never by family. Running it twice is safe: a group that already has a placement comes back in `sections.pending` as ALREADY_COMPONENTIZED and no second component is created. DRAFTS ONLY \u2014 an unpublished component renders as an EMPTY STRING on the live site, so publish_component each one and publish the pages before this reaches a visitor. Then run `npx @bettercms-ai/convert --componentize` in the repo so its templates render these sections from `pages[].blocks`. The default `bind` is the above: the words stay in the page's field group. `copy: \"instance\"` moves the words onto each placement and marks the page's fields `origin: \"componentized\"` \u2014 kept, never deleted (see `copy`). Every component it CREATES is filed into a Component Group (the folders of the dashboard's Components tab): the one you name in `group` (found or created), else its section family \u2014 nav, header, footer or menu \u2192 Layout, an unnamed section \u2192 Sections, otherwise the family name (Hero, FAQ). A component it reuses keeps the Group it has; the receipt's `components.groups` lists the Groups it filed into.",
3761
3812
  z.object({
3762
3813
  digest: z.string().min(1).describe("The `digest` get_componentize_plan returned. A different one is refused with 409 stale-plan."),
3763
3814
  pageIds: z.array(z.string().min(1)).optional().describe("Componentize only these pages (ids from the plan). Omit for every page the plan lists."),
3764
3815
  pages: z.array(z.string().min(1)).optional().describe("The same page filter under the name the dashboard uses. Unioned with pageIds; an EMPTY list is refused rather than treated as every page."),
3765
3816
  sections: z.array(z.string().min(1)).optional().describe("Act only on these section groupKeys (from the plan). Every other section on the page keeps the placement it already has."),
3766
- copy: z.enum(["bind", "instance"]).optional().describe("Who owns each section's copy: 'bind' (default) leaves it in the page fields; 'instance' moves it onto the placement and marks those fields origin: componentized."),
3817
+ copy: z.enum(["bind", "instance"]).optional().describe("Who owns each section's copy. 'bind' (default) leaves it in the page fields. 'instance' is the page-builder model: each placement takes that group's CURRENT draft values into its own `props.overrides` (a repeater becomes one table prop holding every row), records which page path each came from in `props.source`, and drops `props.bind`; the page's fields are KEPT and marked `origin: \"componentized\"`, so nothing is lost and the editor stops showing a second place to type the same words."),
3767
3818
  dryRun: z.boolean().optional().describe("true = return the receipt without writing anything. Do this first."),
3768
3819
  group: z.string().min(1).max(100).optional().describe("File the components this run creates into this Component Group (by NAME; created when missing). Omit to file each under its section family.")
3769
3820
  }).shape,
@@ -4477,7 +4528,7 @@ ${notes.join("\n")}` : summary, created);
4477
4528
  name: "create_components",
4478
4529
  config: {
4479
4530
  title: "Create many components in one call",
4480
- 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,
4531
+ 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. DECLARE A PROP \u2014 WITH A `target` \u2014 for every string, link and image a marketer will ever touch: inside a component only the leaves a declared prop TARGETS are click-to-edit, and copy no prop points at is reachable neither from the canvas nor from the dock. A page is composed of SECTIONS; the full doctrine is in the bettercms://playbook/schema resource, \xA712.",
4481
4532
  inputSchema: createComponentsInput.shape
4482
4533
  },
4483
4534
  handler: guard(
@@ -4538,7 +4589,7 @@ ${notes.join("\n")}` : summary, created);
4538
4589
  name: "compose_pages",
4539
4590
  config: {
4540
4591
  title: "Set many pages' block composition in one call",
4541
- 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,
4592
+ 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. A page is composed of SECTIONS: a recurring band is a component with a `sectionType`, a one-off band is a `section` block whose `props.children` hold its blocks \u2014 never loose top-level heading/text/image blocks, which no editor can move, name or swap as a unit. The full doctrine is in the bettercms://playbook/schema resource, \xA712.",
4542
4593
  inputSchema: composePagesInput.shape
4543
4594
  },
4544
4595
  handler: guard(
@@ -4844,7 +4895,10 @@ ${lines.join("\n")}`, found);
4844
4895
  // through the client's request plumbing — no bespoke SDK method per endpoint.
4845
4896
  ...lifecycleTools()
4846
4897
  ];
4847
- return defs.map(withProjectId);
4898
+ return [
4899
+ ...defs.filter((d) => !LISTING_TAIL.has(d.name)),
4900
+ ...defs.filter((d) => LISTING_TAIL.has(d.name))
4901
+ ].map(withProjectId);
4848
4902
  }
4849
4903
  function registerTools(server, deps) {
4850
4904
  const withElicit = {