@bettercms-ai/mcp 0.72.1 → 0.72.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2540,6 +2540,7 @@ var STRUCTURE_PLAYBOOK_URI = "bettercms://playbook/structure";
2540
2540
  var STRUCTURE_DEFAULT_INSTRUCTION = `After you create collections or pages, organise them per the structure playbook: read ${STRUCTURE_PLAYBOOK_URI}, call suggest_content_structure (read-only), show the user its outline, then apply it with set_content_structure and the version it returned (the If-Match). A project that already has a structure keeps it: file only what you created, with move_to_folder, unless the user asks for a full re-organisation.`;
2541
2541
  var SKILLS_ROUTING_INSTRUCTION = "Before writing code in a repo that uses BetterCMS, explore the repo, then read the installed `bettercms` skill (`npx @bettercms-ai/install --project -y` adds it if missing): its table turns what you found into the one reference the task needs \u2014 read only that. Without the skill installed, read the same files as resources: bettercms://skill/bettercms, then bettercms://skill/<reference>.";
2542
2542
  var DOCK_UI_ROUTING_INSTRUCTION = "How fields and props look in the editor (groups, tabs, rows, icons, item names) is playbook \xA714 or the `field-ui` prompt: write it with update_content_model field* or update_component prop* plus ifMatch, read it back, and finish when get_next_steps reports no dock-* step.";
2543
+ var PUBLISH_HOLD_INSTRUCTION = "An item an author held back from site publishes (`publishHeldAt` set) stays out of whole-site publishes: do not publish it unless the user names it; publish_entries `release` includes it again.";
2543
2544
  var RECEIPT_FIRST_INSTRUCTION = "Start every project with get_next_steps: its `receipt` lists what is already done (`done`), what is still missing (`missing`, each item citing the count it reacted to) and the one `nextAction`. Work from that receipt instead of re-reading the site, call get_next_steps again after each batch of edits, and before you tell the user you are finished. On an imported site the gate is the definition of done: run `receipt.gate.nextAction`, read again, and repeat until `receipt.complete` is true. Record a gap you cannot close with editor_setup `exempt` only for an allowed reason that is true, with a note, never in prose.";
2544
2545
  var NEXT_STEPS_DESCRIPTION = `Call this FIRST on a project. Returns \`receipt\` \u2014 \`done\` (live release, bound elements, conversion coverage, composed pages, published components and Layout), \`missing\`, \`nextAction\` and \`notes\` (unjudged: unknown, not done) \u2014 plus \`data\`, the unfinished items by \`ruleId\` (skill finish-site lists them). Each item cites its count and may carry \`payload\` {rows, total}, at most 20 rows. \`receipt.complete\` is the setup gate's verdict and \`receipt.gate\` its numbers per built route. Run \`receipt.gate.nextAction\`, call it again after each batch; you are finished only when \`complete\` is true. \`organise-content\`: pages and collections are in no folder \u2014 read ${STRUCTURE_PLAYBOOK_URI}, call suggest_content_structure and, once the user agrees, set_content_structure.`;
2545
2546
  var LAYOUT_CHROME_INSTRUCTION = 'When the user asks to make the navigation, footer or other site chrome editable, or get_next_steps lists a `layout-*` or `chrome-duplicated` item: this is playbook \xA711 item 3. Chrome is the project Layout, never page fields: in the source, wrap each Section in `data-bcms-layout-section` (the slot, its base slug) and mark each value `data-bcms-layout-field="layout:<sections[slug].id>:<fieldId>"`, never `data-bcms-field` inside it, then push; the strip removes the page copies, never delete them by hand. On a site BetterCMS serves, publish_layout writes Layout VALUES into the live pages; a structure change waits for a Rebuild. Never place nav, footer or modal blocks or fields on a page: the Layout (or the site\'s own layout) draws chrome, so a page copy either duplicates it or renders nowhere (get_componentize_plan reports one as LAYOUT_CHROME).';
@@ -2898,6 +2899,7 @@ var LISTING_TAIL = /* @__PURE__ */ new Set([
2898
2899
  "apply_branch",
2899
2900
  "generate_image",
2900
2901
  "transform_image",
2902
+ "write_alt_text",
2901
2903
  // The ABM campaign lane — also hidden from the default listing (ABM_TOOLS below).
2902
2904
  "research_account",
2903
2905
  "build_abm_page",
@@ -2950,10 +2952,11 @@ var WEBHOOK_EVENTS = [
2950
2952
  "deployment.triggered"
2951
2953
  ];
2952
2954
  var NEW_TOOL_TEXT = {
2955
+ save_reusable_block: "Copy a page section (its layout, values and design) into Saved blocks in the section library, under `name`. The page is not changed; a block placed from Saved blocks is an independent section.",
2953
2956
  search_content: "Full-text search over the project's PUBLISHED pages and entries (the site-search index). Returns id, type ('page'|'entry'), title, slug and a snippet per hit; `type` narrows to one kind. Drafts are not indexed: find those with list_content_entries `where`. `meta.unavailable` means site search is off for this project.",
2954
2957
  discard_draft: "Throw away unpublished edits to an entry (`entryId`) or a page (`pageId`) \u2014 exactly one: the draft returns to exactly what is live and `pendingChanges` becomes false. A page resets its blockJson and its published Layout overrides. 409 no_published_version means it was never published (delete it instead). Pass ifMatch (optimisticVersion) to refuse if it changed since you read it.",
2955
2958
  give_feedback: "Tell the BetterCMS team about a tool that failed, a capability you needed and did not have, or docs that misled you. Opens a ticket for the team; nothing in the project changes. Name the `tool`, and paste the request and response into `context`. Never include secrets or personal data.",
2956
- publish_entries: "Publish or unpublish many entries and pages in ONE call (up to 100 ids of each; pages publish only). Needs a publish-enabled connection, and each item still passes its own gates (editorial stage, required fields). Nothing stops the run: the result lists what changed (`published` or `unpublished`) and every refusal in `failed` with its reason. A publish answers 202 with the routes being rebuilt.",
2959
+ publish_entries: "Publish or unpublish many entries and pages in ONE call (up to 100 ids of each; no page unpublish). Needs a publish-enabled connection, and each item still passes its own gates (editorial stage, required fields). Nothing stops the run: the result lists what changed (`published` or `unpublished`) and every refusal in `failed` with its reason. A publish answers 202 with the routes being rebuilt. `hold` keeps a live item's edits out of whole-site publishes, `release` includes them again; publishing a held item here ends its hold.",
2957
2960
  create_content_entries: "Create up to 200 DRAFT entries of one collection in ONE call, each {slug?, data} as create_content_entry takes it. Every row reports `created` (with id and slug) or `failed` (with the error), a failure does not stop the others, and `meta` counts them. A row with `externalKey` is safe to send again: its entry is `updated` (draft only), or `unchanged` when the row is the same. A row setting a developer-locked field fails (FIELD_LOCKED). Publish afterwards with publish_entries.",
2958
2961
  translate_entry: "Create an entry's `targetLocale` translation with AI (uses AI credits: 402/403 when out or not on the plan). Text and longtext fields are translated; rich text and documents are copied for you to translate; the rest is copied. The new entry is a DRAFT with `locale` set and `translationOf` = the source; `meta` lists translated and copied keys. `targetLocale` must be one of the project's locales; 409 locale_conflict means that translation exists, so edit it instead.",
2959
2962
  list_webhooks: "List the project's outgoing webhooks: name, url, events, whether active, and delivery health (total, failed, last attempt). Signing secrets are never returned.",
@@ -2966,7 +2969,8 @@ var NEW_TOOL_TEXT = {
2966
2969
  refresh_search_index: "Tell search engines and site search about published changes; costs nothing. action 'indexnow' submits `urls` (this site's own, up to 1000) to IndexNow, or every change since the last submission when omitted. action 'reindex' rebuilds the project's site-search index. 503 means the index is unreachable: try again later.",
2967
2970
  import_redirects: "Bulk-create redirects for a migration, from `rows` or from `text` in `format` (our CSV export, a Netlify `_redirects` file, or a vercel.json). Call it with `dryRun: true` FIRST: nothing is written and each row comes back `would-create`, `existing` or `skipped` with a reason. Rows get create_redirect's rules. Max 5000.",
2968
2971
  editor_setup: "Make the live build editable. A build imports only SEO/meta, media, brand and the models bcms-content.json declares; this derives the rest from the live release: pages, collections, forms, bindings, button and section components (when the authoring preference is components), Component Groups and the Content sidebar. Run it after an import or a rebuild, before the conversion brief. Idempotent: `ready` when it already ran for the live sha, else it starts (`running`; poll with `check: true` or get_deploy_status `editorSetup`). `state`: never or stale (run it), running, ready, failed. 409 = nothing is live yet: deploy first. Then get_next_steps.",
2969
- transform_image: "Edit an existing Media Library image with an instruction (e.g. 'remove the background', 'make it dusk'); the result is saved as a NEW asset and the original is kept (5 AI credits). Returns the new asset (id, url, altText)."
2972
+ transform_image: "Edit an existing Media Library image with an instruction (e.g. 'remove the background', 'make it dusk'); the result is saved as a NEW asset and the original is kept (5 AI credits). Returns the new asset (id, url, altText).",
2973
+ write_alt_text: "Alt text for a Media Library image (mediaId) or an https image URL (url), written from its pixels (1 AI credit). A suggestion: save it with update_media."
2970
2974
  };
2971
2975
  var DELETE_PAGE_RUNTIME_BUNDLE = " Or, with no pageId, `runtimeBundle`: 'report' lists the duplicate pages derived from a server app's own bundle (`app/` folders); 'apply' retires them and their empty folders.";
2972
2976
  var FIELD_LOCKED_NOTE = "A readOnly 'developer' field keeps its stored value: changing or omitting it without schema access is 403 FIELD_LOCKED.";
@@ -3025,7 +3029,8 @@ var SEO_KEY_TEXT = {
3025
3029
  indexnowConfig: "IndexNow on publish, minus URL patterns to leave out",
3026
3030
  searchConfig: "site search: on/off, the box's placeholder, AI answers"
3027
3031
  };
3028
- var VARIANT_GROUP_NOTE = "When the component belongs to a family of looks, `variantGroup` names the family and lists its looks.";
3032
+ var VARIANT_GROUP_NOTE = "`variantGroup`: the family of looks it belongs to, if any, and its looks.";
3033
+ var DETACHED_COPY_NOTE = "{overrides?}: a rich-text embed's separate-copy `fields` (playbook \xA75)";
3029
3034
  var UNPROPPED_COPY_NOTE = "A `warnings` entry UNPROPPED_COPY names copy no prop targets, which nobody can edit: add a prop with a `target` for each leaf it lists.";
3030
3035
  var UPDATE_PAGE_IF_MATCH_NOTE = "get_page's `optimisticVersion`: the write applies only at that version, and a 409 means the page changed since, so read it again. Send it whenever you pass blockJson, which REPLACES the whole composition.";
3031
3036
  var SLOT_MULTIPLE_NOTE = "'slot' with config.multiple holds a list [{componentId, overrides?}] (config.maxItems, default 12, at most 50): target.path 'props.children' on a section. V2 slots, 'table' mode 'types': playbook \xA75.";
@@ -3713,7 +3718,7 @@ function buildToolDefs(deps) {
3713
3718
  "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, variant? (hosted pages; else a Button component), size? sm|md|lg, fullWidth?, arrow? boolean, icon? ui.icon name, newTab? boolean, rel? nofollow|sponsored|ugc}; 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 hold ANY block; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, source? {modelId}, limit?, filters?, sort?, pinnedIds?, excludeCurrent?}: a collection's published entries as cards (every prop: skill content-modeling)"
3714
3719
  ),
3715
3720
  style: z.record(z.string(), z.unknown()).optional().describe(
3716
- "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, border (all four sides, in the text colour), hideOn [mobile|tablet|desktop], never all three. A real marketing band is a `section` block carrying bg + padding + contentWidth."
3721
+ "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, border (all four sides, in the text colour), hideOn [mobile|tablet|desktop] (all three hides it on the page: kept in the draft, never published). A real marketing band is a `section` block carrying bg + padding + contentWidth."
3717
3722
  ),
3718
3723
  window: windowArg.nullable().optional().describe(WINDOW_NOTE)
3719
3724
  });
@@ -4120,7 +4125,8 @@ function buildToolDefs(deps) {
4120
4125
  componentId: z.string().min(1).describe("component id (from list_components)")
4121
4126
  });
4122
4127
  const getComponentInput = z.object({
4123
- componentId: z.string().min(1).describe("component id (from list_components)")
4128
+ componentId: z.string().min(1).describe("component id (from list_components)"),
4129
+ detachedCopy: z.object({ overrides: z.record(z.string(), z.unknown()).optional() }).optional().describe(DETACHED_COPY_NOTE)
4124
4130
  });
4125
4131
  const publishComponentsInput = z.object({
4126
4132
  componentIds: z.array(z.string().min(1)).min(1).max(100).describe("component ids (from list_components)")
@@ -4428,6 +4434,13 @@ ${d.outline}` : "Proposed Content structure.", d);
4428
4434
  z.object({ redirectId: z.string().min(1) }).shape,
4429
4435
  async (c, a) => ok("Deleted redirect.", await data(c, "DELETE", `/management/redirects/${s(a.redirectId)}`))
4430
4436
  ),
4437
+ def(
4438
+ "save_reusable_block",
4439
+ "Save a section as a reusable block",
4440
+ NEW_TOOL_TEXT.save_reusable_block,
4441
+ z.object({ pageId: z.string().min(1), blockId: z.string().min(1).describe("a top-level section id (get_page)"), name: z.string().min(1).describe("80 characters at most; a longer one is refused as SAVED_BLOCK_NAME_TOO_LONG") }).shape,
4442
+ async (c, a) => ok("Saved the section as a reusable block.", await data(c, "POST", `/management/saved-blocks`, { pageId: a.pageId, blockId: a.blockId, name: a.name }))
4443
+ ),
4431
4444
  def(
4432
4445
  "get_seo",
4433
4446
  "Get site SEO",
@@ -5022,14 +5035,14 @@ ${d.outline}` : "Proposed Content structure.", d);
5022
5035
  "Publish or unpublish many entries and pages",
5023
5036
  NEW_TOOL_TEXT.publish_entries,
5024
5037
  z.object({
5025
- action: z.enum(["publish", "unpublish"]).describe("take the items live, or back to draft"),
5038
+ action: z.enum(["publish", "unpublish", "hold", "release"]).describe("live, back to draft, or hold/release from site publishes"),
5026
5039
  entryIds: z.array(z.string().min(1)).max(100).optional().describe("entry ids (from list_content_entries)"),
5027
- pageIds: z.array(z.string().min(1)).max(100).optional().describe("page ids (from list_pages); publish only")
5040
+ pageIds: z.array(z.string().min(1)).max(100).optional().describe("page ids (from list_pages); not for unpublish")
5028
5041
  }).shape,
5029
5042
  async (c, a) => {
5030
5043
  const receipt = await data(c, "POST", `/management/publish`, { action: a.action, entryIds: a.entryIds, pageIds: a.pageIds });
5031
5044
  const outcome = receipt?.outcome;
5032
- return ok(a.action === "publish" ? publishedLines(outcome, "Publish receipt.").join("\n") : "Unpublish receipt.", receipt);
5045
+ return ok(a.action === "publish" ? publishedLines(outcome, "Publish receipt.").join("\n") : a.action === "unpublish" ? "Unpublish receipt." : "Hold receipt.", receipt);
5033
5046
  }
5034
5047
  ),
5035
5048
  def(
@@ -5141,6 +5154,13 @@ ${d.outline}` : "Proposed Content structure.", d);
5141
5154
  }).shape,
5142
5155
  async (c, a) => ok("Edited image (a new asset).", await data(c, "POST", `/management/ai/image/transform`, { mediaId: a.mediaId, prompt: a.prompt, altText: a.altText, folderId: a.folderId }))
5143
5156
  ),
5157
+ def(
5158
+ "write_alt_text",
5159
+ "Write alt text with AI",
5160
+ NEW_TOOL_TEXT.write_alt_text,
5161
+ z.object({ mediaId: z.string().min(1).optional().describe("the image (from list_media)"), url: z.string().optional().describe("or an https image URL") }).shape,
5162
+ async (c, a) => ok("Alt text suggestion.", await data(c, "POST", `/management/ai/alt-text`, a.url ? { url: a.url } : { mediaId: a.mediaId }))
5163
+ ),
5144
5164
  def(
5145
5165
  "list_activity",
5146
5166
  "List project activity",
@@ -5509,6 +5529,8 @@ ${d.outline}` : "Proposed Content structure.", d);
5509
5529
  language: p.language,
5510
5530
  pageType: p.pageType,
5511
5531
  status: p.status,
5532
+ // Held back from whole-site publishes: only when set, so an unheld list stays the same size.
5533
+ ...p.publishHeldAt ? { publishHeldAt: p.publishHeldAt } : {},
5512
5534
  fields: p.fields
5513
5535
  }))
5514
5536
  );
@@ -6019,11 +6041,18 @@ ${notes.join("\n")}` : summary, created);
6019
6041
  name: "get_component",
6020
6042
  config: {
6021
6043
  title: "Get a component (with its blockJson)",
6022
- description: "Get one component by id INCLUDING its blockJson tree and props (with any stored `ui` authoring chrome on them; `stale: true` = no placement renders it). Read this before update_component so you keep the existing blocks, and after declaring field UI as the receipt that it landed \u2014 a prop absent here was not stored. " + VARIANT_GROUP_NOTE,
6044
+ description: "One component with its blockJson and props (stored `ui` on them; `stale: true` = no placement renders it). Read it before update_component to keep its blocks, and after declaring field UI: a prop absent here was not stored. " + VARIANT_GROUP_NOTE,
6023
6045
  inputSchema: getComponentInput.shape
6024
6046
  },
6025
6047
  handler: guard(
6026
6048
  async (args) => withClient(async (client) => {
6049
+ if (args.detachedCopy) {
6050
+ const res = await client.fetchJSON(
6051
+ client.url(`/management/components/${encodeURIComponent(args.componentId)}/detached-copy`),
6052
+ { method: "POST", body: JSON.stringify(args.detachedCopy) }
6053
+ );
6054
+ return ok(`A separate copy of '${res.data.name}': store \`fields\` on the embed.`, res.data);
6055
+ }
6027
6056
  const cmp = await client.getComponent(args.componentId);
6028
6057
  return ok(`Component '${cmp.name}' (${cmp.blockJson.length} block(s)).`, cmp);
6029
6058
  })
@@ -6549,6 +6578,16 @@ component). A list of several item shapes on a component is a \`table\` prop wit
6549
6578
  \`config.mode: 'types'\` and \`config.itemTypes: [{ name, label?, fields }]\`, rows
6550
6579
  \`[{ _key?, _type: '<name>', ...values }]\`.
6551
6580
 
6581
+ A component inside a \`richtext\` value is \`{ _type: 'bcmsBlock', _key, schemaKey: 'component',
6582
+ schemaLabel?, fields: { componentId, overrides?, variant? } }\` and FOLLOWS the component: publishing
6583
+ it changes this place. A SEPARATE COPY stops following it: \`fields: { componentId, detached: true,
6584
+ props, blocks, sourcePublishedAt, variant? }\`, exactly as \`get_component { componentId,
6585
+ detachedCopy: { overrides } }\` returns them. It renders its own \`blocks\`; \`componentId\` stays so
6586
+ it can be linked again (write the linked shape), and a \`sourcePublishedAt\` older than the
6587
+ component's \`lastPublishedAt\` (every publish; \`publishedAt\` is the first) means the shared
6588
+ version changed since. Refused by name:
6589
+ \`component_copy_invalid\`, \`component_copy_not_frozen\`.
6590
+
6552
6591
  ## 5b. NEVER MODEL SEO. It is native.
6553
6592
 
6554
6593
  Do not create \`seoTitle\`, \`metaTitle\`, \`metaDescription\`, \`ogImage\`, \`canonicalUrl\`,
@@ -7187,8 +7226,11 @@ conversion is yours to write.
7187
7226
  markup inside a \`text\` field.
7188
7227
  **Prose MIGRATED from another CMS needs one extra check.** Every block's \`_type\` must be one
7189
7228
  this platform stores \u2014 \`block\`, \`table\` (\`{_type:"table", rows:[{_key, cells:[{_key,
7190
- content:[...blocks], header?}]}]}\`; Sanity's row of plain strings, \`rows:[{cells:["a","b"]}]\`,
7191
- is admitted too), \`bcmsCode\`, \`bcmsDivider\`, \`bcmsMention\`, or \`bcmsBlock\` carrying a
7229
+ content:[...blocks], header?, group?}], group?}], caption?, notes?, rowGroups?, columnGroups?}\`,
7230
+ groups \`[{_key, label}]\`; Sanity's row of plain strings, \`rows:[{cells:["a","b"]}]\`,
7231
+ is admitted too), \`bcmsCode\`, \`bcmsDivider\`, \`bcmsMention\`, \`pullQuote\`
7232
+ (\`{quote, name, role?, link?}\`), \`keyTakeaways\` (\`{title?, points:[...strings]}\`,
7233
+ title at most 40 characters, at most 8 points), or \`bcmsBlock\` carrying a
7192
7234
  \`schemaKey\` \u2014 and a foreign node (a Sanity image, \`{_type:"image", asset:{_ref}}\`, for one)
7193
7235
  is not merely unrendered: it has no component here, so it
7194
7236
  vanishes from the HTML every delivery surface reads while the stored value still looks whole.
@@ -8248,7 +8290,7 @@ function buildServer(deps) {
8248
8290
  // one definition of done; without this an agent converts the page it landed on and stops.
8249
8291
  instructions: RECEIPT_FIRST_INSTRUCTION + " 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. " + LAYOUT_CHROME_INSTRUCTION + " " + CTA_BUTTON_INSTRUCTION + " 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 componentize_sections, 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. " + // The default after authoring (the structure standard): organise what you made. Same
8250
8292
  // sentence as the hosted connector's MCP_INSTRUCTIONS.
8251
- STRUCTURE_DEFAULT_INSTRUCTION + " " + SKILLS_ROUTING_INSTRUCTION + " " + DOCK_UI_ROUTING_INSTRUCTION
8293
+ STRUCTURE_DEFAULT_INSTRUCTION + " " + SKILLS_ROUTING_INSTRUCTION + " " + DOCK_UI_ROUTING_INSTRUCTION + " " + PUBLISH_HOLD_INSTRUCTION
8252
8294
  }
8253
8295
  );
8254
8296
  server.registerResource(