@bettercms-ai/mcp 0.22.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,9 +57,9 @@ On the first tool call the server prints an authorization prompt to **stderr**
57
57
 
58
58
  ```
59
59
  ┌─ BetterCMS authorization required ─────────────────────────
60
- │ Visit: https://demo.bettercms.ai/activate
60
+ │ Visit: https://bettercms.ai/activate
61
61
  │ Enter code: WDJB-MJHT
62
- │ Or open: https://demo.bettercms.ai/activate?code=WDJB-MJHT
62
+ │ Or open: https://bettercms.ai/activate?code=WDJB-MJHT
63
63
  └────────────────────────────────────────────────────────────
64
64
  ```
65
65
 
package/dist/index.js CHANGED
@@ -316,7 +316,7 @@ import { BetterCMS } from "@bettercms-ai/sdk";
316
316
  import { z } from "zod";
317
317
 
318
318
  // ../types/src/component.ts
319
- var SECTION_DOCTRINE = "STRUCTURE (separate from schema): a page is composed of SECTIONS. NEVER build a page out of loose top-level heading/text/image/button/spacer blocks \u2014 they cannot be moved, duplicated or swapped as a unit, the visual editor cannot outline or name them, and every one of them becomes its own section in the editor. A hero of a headline, a lede and two CTAs is ONE section, not four. TWO SHAPES, and the choice is about REUSE. (1) A band that appears on more than one page, or that needs layout variants, is a COMPONENT with a `sectionType` \u2014 see create_component. Components sharing a `sectionType` are that section's VARIANTS (one Hero: 'Centered' for the home page and 'Two-column' for about, same prop keys so a swap keeps the content). This is also the only shape the editor's 'Add a section' picker can insert, and the only one that gets a family name and a variant switcher. (2) A genuinely one-off band on a single page is a `section` BLOCK whose `props.children` hold its blocks. THE TRADEOFF, stated in the present tense because it is real today: a component's children render WITHOUT field bindings, so their text is NOT click-to-edit on the canvas \u2014 it is edited through the component's declared `props` in the section dock. A `section` block's children stay click-to-edit. So when you choose a component, DECLARE A PROP for every string, link and image a marketer will ever touch; a component with un-propped editable copy is the defect, not the component. In the dock an unset prop shows EMPTY and inherits the definition's default, so set props explicitly when you want the current copy visible there. Do not hand-write a band's JSON: start from a built-in section component (list_components returns them with no projectId \u2014 hero-centered, hero-split, feature-grid-three, cta-banner and nine more), each already rooted in a `section` block with its editable leaves declared as props. INLINE its blockJson as a `section` block for a one-off band; REFERENCE it as a `component` when the band recurs or needs variants. Two consecutive call-to-action buttons are two sibling `button` blocks inside the same section \u2014 never a `columns` block, which is a `repeat(N,1fr)` grid and would stretch each CTA to half the container. Buttons are inline-level and flow side by side on their own.";
319
+ var SECTION_DOCTRINE = "STRUCTURE (separate from schema): a page is composed of SECTIONS. NEVER build a page out of loose top-level heading/text/image/button/spacer blocks \u2014 they cannot be moved, duplicated or swapped as a unit, the visual editor cannot outline or name them, and every one of them becomes its own section in the editor. A hero of a headline, a lede and two CTAs is ONE section, not four. TWO SHAPES, and the choice is about REUSE. (1) A band that appears on more than one page, or that needs layout variants, is a COMPONENT with a `sectionType` \u2014 see create_component. Components sharing a `sectionType` are that section's VARIANTS (one Hero: 'Centered' for the home page and 'Two-column' for about, same prop keys so a swap keeps the content). This is also the only shape the editor's 'Add a section' picker can insert, and the only one that gets a family name and a variant switcher. (2) A genuinely one-off band on a single page is a `section` BLOCK whose `props.children` hold its blocks. THE TRADEOFF, stated in the present tense because it is real today: a component's children render WITHOUT field bindings, so their text is NOT click-to-edit on the canvas \u2014 it is edited through the component's declared `props` in the section dock. A `section` block's children stay click-to-edit. So when you choose a component, DECLARE A PROP for every string, link and image a marketer will ever touch; a component with un-propped editable copy is the defect, not the component. In the dock an unset prop shows EMPTY and inherits the definition's default, so set props explicitly when you want the current copy visible there. Do not hand-write a band's JSON: start from a built-in section blueprint (list_components returns locked `builtin:*` blueprints with no projectId \u2014 hero-centered, hero-split, feature-grid-three, cta-banner and nine more), each already rooted in a `section` block with its editable leaves declared as props. INLINE its blockJson as a `section` block for a one-off band; for a recurring band, materialize the blueprint with create_component so it becomes project-scoped before implementation validation, Output or publication. Direct `builtin:*` component references exist only for legacy delivery compatibility. Two consecutive call-to-action buttons are two sibling `button` blocks inside the same section \u2014 never a `columns` block, which is a `repeat(N,1fr)` grid and would stretch each CTA to half the container. Buttons are inline-level and flow side by side on their own.";
320
320
 
321
321
  // ../types/src/layout-lucide-icons.ts
322
322
  var LAYOUT_SECTION_ICONS = Object.freeze([
@@ -2408,10 +2408,15 @@ var fieldType = z.enum([
2408
2408
  var slug = z.string().regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only");
2409
2409
  var fieldKey = z.string().regex(/^[a-zA-Z0-9_]+$/, "letters, numbers, and underscores only");
2410
2410
  var fieldShape = {
2411
- key: fieldKey.describe("machine field key, e.g. 'title' or 'blog_hero'"),
2411
+ key: fieldKey.describe(
2412
+ "machine field key. \u{1F534} UNIQUE ACROSS THE WHOLE MODEL \u2014 API IDs share ONE FLAT NAMESPACE, so a field nested inside a group must NOT reuse a key used by another field or group. Every section's heading cannot be 'title'. Prefix with the section: 'hero_title', 'pricing_title', 'faq_title'. Reusing a key is refused, and if it slips through it leaves permanent errors in the editor and breaks conditional-visibility rules, which reference fields by bare key."
2413
+ ),
2412
2414
  label: z.string().min(1).describe("human label shown in the editor"),
2413
2415
  type: fieldType,
2414
2416
  required: z.boolean().optional(),
2417
+ richText: z.boolean().optional().describe(
2418
+ "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."
2419
+ ),
2415
2420
  options: z.array(z.string()).optional().describe("choices when type is 'select'"),
2416
2421
  config: z.record(z.string(), z.unknown()).optional().describe(
2417
2422
  "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"
@@ -2421,9 +2426,39 @@ var fieldShape = {
2421
2426
  )
2422
2427
  };
2423
2428
  var fieldObject = z.object(fieldShape);
2429
+ function flatFieldKeys(fields) {
2430
+ const out = [];
2431
+ const childrenOf = (f) => {
2432
+ const zones = f.config?.zones;
2433
+ if (zones?.nonRepeatable) return zones.nonRepeatable;
2434
+ if (zones?.repeatable?.fields) return zones.repeatable.fields;
2435
+ return f.fields ?? [];
2436
+ };
2437
+ for (const raw of Array.isArray(fields) ? fields : []) {
2438
+ const f = raw;
2439
+ if (typeof f?.key === "string" && f.key.trim()) out.push(f.key.trim());
2440
+ for (const raw2 of childrenOf(f)) {
2441
+ const c = raw2;
2442
+ if (typeof c?.key === "string" && c.key.trim()) out.push(c.key.trim());
2443
+ }
2444
+ }
2445
+ return out;
2446
+ }
2447
+ function duplicateKeys(keys) {
2448
+ const seen = /* @__PURE__ */ new Map();
2449
+ for (const k of keys) seen.set(k, (seen.get(k) ?? 0) + 1);
2450
+ return [...new Set(keys)].filter((k) => (seen.get(k) ?? 0) > 1);
2451
+ }
2452
+ function duplicateKeyFailure(dupes) {
2453
+ return `Duplicate API ID${dupes.length === 1 ? "" : "s"}: ${dupes.map((k) => `'${k}'`).join(", ")}. API IDs share ONE flat namespace across the whole model \u2014 a field inside a group must not reuse a key used by another field or group. Prefix each key with its section and retry (e.g. 'hero_title' and 'pricing_title', never 'title' in both).`;
2454
+ }
2424
2455
  function toFields(fs) {
2425
2456
  return (fs ?? []).map(toField);
2426
2457
  }
2458
+ function proseType(f) {
2459
+ if (f.type !== "text") return f.type;
2460
+ return f.richText === false ? "text" : "richtext";
2461
+ }
2427
2462
  function toField(f) {
2428
2463
  const base = {
2429
2464
  key: f.key,
@@ -2457,7 +2492,7 @@ function toField(f) {
2457
2492
  }
2458
2493
  return {
2459
2494
  ...base,
2460
- type: f.type,
2495
+ type: proseType(f),
2461
2496
  ...f.options ? { options: f.options } : {},
2462
2497
  ...f.config ? { config: f.config } : {}
2463
2498
  };
@@ -2717,6 +2752,7 @@ function buildToolDefs(deps) {
2717
2752
  const getLayoutInput = z.object({
2718
2753
  scope: z.enum(["global", "page"]).default("global"),
2719
2754
  pageId: z.string().min(1).optional().describe("page id or slug; required for page scope"),
2755
+ copy: z.enum(["draft", "published"]).optional().describe("which copy to read; default draft. Verifying a publish MUST read copy:'published' and check the response's copy echo \u2014 the draft is never a publish receipt (FLO-1188)."),
2720
2756
  projectId: z.string().min(1).optional().describe("required only for a workspace-scoped grant")
2721
2757
  });
2722
2758
  const layoutBinding = z.object({ inputId: z.string().min(1), fieldId: z.string().min(1) });
@@ -2803,6 +2839,83 @@ function buildToolDefs(deps) {
2803
2839
  slug: slug.describe("url-safe unique slug (lowercase letters/numbers/hyphens)"),
2804
2840
  projectId: z.string().min(1).optional().describe("only needed for a workspace-wide key")
2805
2841
  });
2842
+ const sectionEvidenceProjectId = z.string().min(1).max(64).describe("exact project id from get_project (not a slug); Section evidence is project-local and never reusable across projects");
2843
+ const sectionId = z.string().uuid().describe("Section definition id from Section Studio");
2844
+ const sectionVersion = z.number().int().positive().describe("exact Section schema version implemented or tested");
2845
+ const sha256 = z.string().regex(/^[0-9a-f]{64}$/i, "Expected a SHA-256 hex digest");
2846
+ const sectionViewport = z.object({
2847
+ name: z.string().trim().min(1).max(64),
2848
+ width: z.number().int().min(240).max(7680),
2849
+ height: z.number().int().min(240).max(7680)
2850
+ });
2851
+ const sectionEvidenceDigest = z.string().regex(/^(?:sha256:)?[0-9a-f]{64}$/i, "Expected a SHA-256 digest");
2852
+ const nonEmptyDescriptor = z.record(z.string(), z.unknown()).refine((value) => Object.keys(value).length > 0, "Descriptor cannot be empty");
2853
+ const sectionRequestId = z.string().uuid().describe("durable Section validation request id returned by list_section_validation_requests or the dashboard");
2854
+ const sectionHttpUrl = z.string().url().max(2048).refine((value) => {
2855
+ try {
2856
+ const protocol = new URL(value).protocol;
2857
+ return protocol === "http:" || protocol === "https:";
2858
+ } catch {
2859
+ return false;
2860
+ }
2861
+ }, "Expected an HTTP or HTTPS URL");
2862
+ const sectionValidationViewport = sectionViewport.extend({
2863
+ status: z.enum(["passed", "failed"]),
2864
+ evidenceDigest: sectionEvidenceDigest
2865
+ });
2866
+ const submitSectionManifestInput = z.object({
2867
+ projectId: sectionEvidenceProjectId,
2868
+ requestId: sectionRequestId.optional().describe("include when fulfilling a claimed dashboard request so the evidence is bound to that exact run"),
2869
+ sectionId,
2870
+ version: sectionVersion,
2871
+ apiId: z.string().regex(/^[a-z][A-Za-z0-9]*$/, "Use lower camelCase").max(100),
2872
+ schemaHash: sha256.describe("exact SHA-256 hex hash shown for this Section version"),
2873
+ commitSha: z.string().regex(/^[0-9a-f]{7,64}$/i).describe("git commit containing the implementation"),
2874
+ loader: nonEmptyDescriptor.describe("non-empty serializable loader descriptor emitted by the repo build; never executable code"),
2875
+ previewAdapter: nonEmptyDescriptor.describe("non-empty serializable adapter descriptor for the customer's real app shell; never executable code"),
2876
+ nativeViewports: z.array(sectionViewport).max(20).optional().describe("the customer's named responsive viewports; BetterCMS also enforces Desktop 1440x900, Tablet 768x1024, and Mobile 390x844")
2877
+ });
2878
+ const submitSectionValidationInput = z.object({
2879
+ projectId: sectionEvidenceProjectId.describe("exact project id from get_project (not a slug); must match the manifest's project"),
2880
+ requestId: sectionRequestId.optional().describe("same claimed request id used for the manifest; binds and terminalizes that run"),
2881
+ sectionId: sectionId.describe("exact Section definition id"),
2882
+ version: sectionVersion,
2883
+ manifestId: z.string().uuid().describe("manifest id returned by submit_section_manifest; it pins the schema hash and commit SHA"),
2884
+ status: z.enum(["passed", "failed"]).describe("the actual result of the external repo/app-shell validation"),
2885
+ fixtureHash: sha256.describe("SHA-256 hex hash of the canonical preview dataset and stress fixtures used"),
2886
+ evidenceDigest: sectionEvidenceDigest.describe("SHA-256 digest of the immutable validation evidence bundle (screenshots/report/results)"),
2887
+ appShell: z.object({
2888
+ kind: z.literal("actual-app"),
2889
+ identifier: z.string().trim().min(1).max(255),
2890
+ url: sectionHttpUrl.optional()
2891
+ }).describe("the real customer application shell used for the validation run"),
2892
+ viewportResults: z.array(sectionValidationViewport).min(1).max(23).describe("one signed result for every required and native manifest viewport")
2893
+ });
2894
+ const listSectionValidationRequestsInput = z.object({
2895
+ projectId: sectionEvidenceProjectId,
2896
+ limit: z.number().int().min(1).max(100).optional()
2897
+ });
2898
+ const claimSectionValidationRequestInput = z.object({
2899
+ projectId: sectionEvidenceProjectId,
2900
+ requestId: sectionRequestId,
2901
+ commitSha: z.string().regex(/^[0-9a-f]{7,64}$/i).describe("exact git commit the agent will inspect and validate; evidence must use this same commit"),
2902
+ providerRunId: z.string().trim().min(1).max(255).optional(),
2903
+ providerRunUrl: sectionHttpUrl.optional().describe("optional HTTP(S) link to the user's external agent run")
2904
+ });
2905
+ const completeSectionValidationRequestInput = z.object({
2906
+ projectId: sectionEvidenceProjectId,
2907
+ requestId: sectionRequestId,
2908
+ manifestId: z.string().uuid().optional(),
2909
+ validationRunId: z.string().uuid().optional()
2910
+ });
2911
+ const failSectionValidationRequestInput = z.object({
2912
+ projectId: sectionEvidenceProjectId,
2913
+ requestId: sectionRequestId,
2914
+ errorCode: z.string().trim().regex(/^[A-Z][A-Z0-9_]*$/).max(100),
2915
+ errorMessage: z.string().trim().min(1).max(2e3),
2916
+ providerRunId: z.string().trim().min(1).max(255).optional(),
2917
+ providerRunUrl: sectionHttpUrl.optional().describe("optional HTTP(S) link to the user's external agent run")
2918
+ });
2806
2919
  function lifecycleTools() {
2807
2920
  const def = (name, title, description, shape, run) => ({
2808
2921
  name,
@@ -3063,6 +3176,13 @@ function buildToolDefs(deps) {
3063
3176
  return ok("Recorded the authoring architecture.", await data(c, "PATCH", `/management/projects/current/authoring-preference`, { preference }));
3064
3177
  }
3065
3178
  ),
3179
+ def(
3180
+ "set_binding_mode",
3181
+ "Set how the site's bindings are resolved",
3182
+ "Switch this project between the two binding resolvers, from the NEXT release on. `declaredBindings: true` makes the annotator trust the template's own data-bcms-field / data-bcms-props and never guess from rendered text \u2014 the durable state; `false` returns to text-matching, which works once (at import, when the CMS values equal the built copy) and breaks the first time anyone edits a value. Call it ONLY after every page's copy is declared in the template: undeclared fields stop being editable. The order is push \u2192 release \u2192 get_binding_report shows mode 'text-match' with 0 unmatched \u2192 set_binding_mode \u2192 release again \u2192 get_binding_report shows mode 'declared'. Flipping back is the same call. REQUIRES a project-scoped connection carrying the artifact:write scope \u2014 the same authority that deploys the site \u2014 because this decides what every future release does to every page; a workspace-wide grant is refused with 403. See section 13 of the bettercms://playbook/schema resource.",
3183
+ z.object({ declaredBindings: z.boolean().describe("true = trust the template's declared bindings; false = text-match (the default)") }).shape,
3184
+ async (c, a) => ok("Recorded the binding mode.", await data(c, "PATCH", `/management/projects/current/binding-mode`, { declaredBindings: a.declaredBindings }))
3185
+ ),
3066
3186
  def(
3067
3187
  "clone_project",
3068
3188
  "Clone a project",
@@ -3172,6 +3292,13 @@ function buildToolDefs(deps) {
3172
3292
  z.object({}).shape,
3173
3293
  async (c) => ok("Next steps.", await data(c, "GET", `/management/insights/next-steps`))
3174
3294
  ),
3295
+ def(
3296
+ "get_binding_report",
3297
+ "Check what on the live site is editable",
3298
+ "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. 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. Pass `slot` ('current' or 'staging') to read the other tree; the default is the slot this project's releases land in.",
3299
+ z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read; defaults to the one this project deploys to") }).shape,
3300
+ async (c, a) => ok("Binding report.", await data(c, "GET", `/management/projects/current/binding-report${q({ slot: a.slot })}`))
3301
+ ),
3175
3302
  def(
3176
3303
  "get_analytics_overview",
3177
3304
  "Get traffic overview",
@@ -3241,6 +3368,102 @@ function buildToolDefs(deps) {
3241
3368
  z.object({ jobId: z.string().min(1) }).shape,
3242
3369
  async (c, a) => ok("Job rejected.", await data(c, "POST", `/management/bulk/jobs/${s(a.jobId)}/reject`))
3243
3370
  ),
3371
+ def(
3372
+ "list_section_validation_requests",
3373
+ "List queued Section validation requests",
3374
+ "Poll for user-agent Section implementation-validation requests in this exact project. BetterCMS cannot push work into an ordinary MCP client: call this explicitly, claim one request, inspect and run the customer's real repository/app shell, then submit request-bound evidence and complete it. Returns only unclaimed user-agent requests; it never exposes another project.",
3375
+ listSectionValidationRequestsInput.shape,
3376
+ async (c, a) => ok("Queued user-agent Section validation requests.", await data(
3377
+ c,
3378
+ "GET",
3379
+ `/projects/${s(a.projectId)}/section-evidence/implementation-requests${q({ limit: a.limit })}`
3380
+ ))
3381
+ ),
3382
+ def(
3383
+ "claim_section_validation_request",
3384
+ "Claim a Section validation request",
3385
+ "Atomically claim one queued user-agent request and pin the exact git commit you will inspect. Claim BEFORE submitting evidence. BetterCMS records coordination only; all customer code and responsive checks must run in the user's repository and real app shell.",
3386
+ claimSectionValidationRequestInput.shape,
3387
+ async (c, a) => ok("Section validation request claimed.", await data(
3388
+ c,
3389
+ "POST",
3390
+ `/projects/${s(a.projectId)}/section-evidence/implementation-requests/${s(a.requestId)}/claim`,
3391
+ {
3392
+ commitSha: a.commitSha,
3393
+ providerRunId: a.providerRunId,
3394
+ providerRunUrl: a.providerRunUrl
3395
+ }
3396
+ ))
3397
+ ),
3398
+ def(
3399
+ "complete_section_validation_request",
3400
+ "Complete a Section validation request",
3401
+ "Complete a claimed request only after submit_section_manifest and submit_section_validation have bound exact, passed evidence to the same requestId and pinned commit. This cannot grant the separate human Visual Approval.",
3402
+ completeSectionValidationRequestInput.shape,
3403
+ async (c, a) => ok("Section validation request completed.", await data(
3404
+ c,
3405
+ "POST",
3406
+ `/projects/${s(a.projectId)}/section-evidence/implementation-requests/${s(a.requestId)}/complete`,
3407
+ { manifestId: a.manifestId, validationRunId: a.validationRunId }
3408
+ ))
3409
+ ),
3410
+ def(
3411
+ "fail_section_validation_request",
3412
+ "Fail a Section validation request",
3413
+ "Truthfully close a claimed request when the renderer, validation command, app shell, or required evidence is missing or fails. This path intentionally works without a manifest so 'not implemented' is representable instead of being reported as passed or left queued forever.",
3414
+ failSectionValidationRequestInput.shape,
3415
+ async (c, a) => ok("Section validation request failed.", await data(
3416
+ c,
3417
+ "POST",
3418
+ `/projects/${s(a.projectId)}/section-evidence/implementation-requests/${s(a.requestId)}/fail`,
3419
+ {
3420
+ errorCode: a.errorCode,
3421
+ errorMessage: a.errorMessage,
3422
+ providerRunId: a.providerRunId,
3423
+ providerRunUrl: a.providerRunUrl
3424
+ }
3425
+ ))
3426
+ ),
3427
+ def(
3428
+ "submit_section_manifest",
3429
+ "Submit a Section implementation manifest",
3430
+ "Record the implementation manifest produced for one exact Section schema version by CI or an agent running INSIDE the user's repository. BetterCMS does not execute customer code: inspect/build the real renderer in the user's app shell first, then submit its exact API ID, schema hash, commit SHA, loader, preview adapter, and native responsive viewports. Requires a project-scoped artifact:write credential. This records evidence only; it does not validate the implementation and cannot create human Visual Approval.",
3431
+ submitSectionManifestInput.shape,
3432
+ async (c, a) => ok("Section implementation manifest recorded.", await data(
3433
+ c,
3434
+ "POST",
3435
+ `/projects/${s(a.projectId)}/section-evidence/sections/${s(a.sectionId)}/versions/${a.version}/manifests`,
3436
+ {
3437
+ requestId: a.requestId,
3438
+ apiId: a.apiId,
3439
+ schemaHash: a.schemaHash,
3440
+ commitSha: a.commitSha,
3441
+ loader: a.loader,
3442
+ previewAdapter: a.previewAdapter,
3443
+ nativeViewports: a.nativeViewports
3444
+ }
3445
+ ))
3446
+ ),
3447
+ def(
3448
+ "submit_section_validation",
3449
+ "Submit a Section validation result",
3450
+ "Record a real validation result for one exact Section schema version and manifest. Run the component in the user's repository and real app shell across the canonical preview dataset, AI stress fixtures, and required/native viewports BEFORE calling this tool; BetterCMS never runs that customer code. Pass status 'passed' only when those checks actually passed, otherwise 'failed'. The manifest pins schemaHash and commitSha; fixtureHash and evidenceDigest pin the tested data and immutable evidence bundle. Requires project-scoped artifact:write and cannot create the separate human Visual Approval required for publication.",
3451
+ submitSectionValidationInput.shape,
3452
+ async (c, a) => ok("Section validation result recorded.", await data(
3453
+ c,
3454
+ "POST",
3455
+ `/projects/${s(a.projectId)}/section-evidence/sections/${s(a.sectionId)}/versions/${a.version}/validations`,
3456
+ {
3457
+ requestId: a.requestId,
3458
+ manifestId: a.manifestId,
3459
+ status: a.status,
3460
+ fixtureHash: a.fixtureHash,
3461
+ evidenceDigest: a.evidenceDigest,
3462
+ appShell: a.appShell,
3463
+ viewportResults: a.viewportResults
3464
+ }
3465
+ ))
3466
+ ),
3244
3467
  def(
3245
3468
  "get_deploy_status",
3246
3469
  "Get deploy/build status",
@@ -3296,11 +3519,13 @@ function buildToolDefs(deps) {
3296
3519
  name: "create_page",
3297
3520
  config: {
3298
3521
  title: "Create a page",
3299
- description: "Create a page with its own typed schema. Supports pageType 'singleton' (exactly one entry \u2014 Home, About, Contact) and 'dynamic' (many entries sharing the schema \u2014 Blog posts, Products). Project-scoped from the key. Additive \u2014 does not delete or overwrite existing pages. FIRST read the page's real markup and DECOMPOSE it into a destructured tree: each visual section becomes a nested field \u2014 a fixed grouped block \u2192 type 'group', a repeating list of items (cards, testimonials, features, FAQs) \u2192 type 'repeater' \u2014 each carrying its own child `fields`. Do NOT flatten sections into many flat top-level fields. Build the full nested tree, then call this once. That tree is the page's SCHEMA \u2014 what it holds. " + SECTION_DOCTRINE,
3522
+ description: "Create a page with its own typed schema. Supports pageType 'singleton' (exactly one entry \u2014 Home, About, Contact) and 'dynamic' (many entries sharing the schema \u2014 Blog posts, Products). Project-scoped from the key. Additive \u2014 does not delete or overwrite existing pages. FIRST read the page's real markup and DECOMPOSE it into a destructured tree: each visual section becomes a nested field \u2014 a fixed grouped block \u2192 type 'group', a repeating list of items (cards, testimonials, features, FAQs) \u2192 type 'repeater' \u2014 each carrying its own child `fields`. Do NOT flatten sections into many flat top-level fields. Build the full nested tree, then call this once. \u{1F534} Every field key must be UNIQUE ACROSS THE WHOLE TREE \u2014 the namespace is flat, so 'title' cannot appear in two sections; prefix each with its section ('hero_title', 'faq_title'). Prose fields (headings, body copy, descriptions) are created as RICH TEXT by default; pass richText:false for values that must stay bare strings \u2014 hrefs, slugs, ids, emails. That tree is the page's SCHEMA \u2014 what it holds. " + SECTION_DOCTRINE,
3300
3523
  inputSchema: createPageInput.shape
3301
3524
  },
3302
3525
  handler: guard(
3303
3526
  async (args) => withClient(async (client) => {
3527
+ const dupes = duplicateKeys(flatFieldKeys(args.fields));
3528
+ if (dupes.length > 0) return fail(duplicateKeyFailure(dupes));
3304
3529
  const page = await client.createPage({
3305
3530
  title: args.title,
3306
3531
  slug: args.slug,
@@ -3326,6 +3551,8 @@ function buildToolDefs(deps) {
3326
3551
  },
3327
3552
  handler: guard(
3328
3553
  async (args) => withClient(async (client) => {
3554
+ const modelDupes = duplicateKeys(flatFieldKeys(args.fields));
3555
+ if (modelDupes.length > 0) return fail(duplicateKeyFailure(modelDupes));
3329
3556
  const model = await client.createModel({
3330
3557
  name: args.name,
3331
3558
  slug: args.slug,
@@ -3350,9 +3577,8 @@ function buildToolDefs(deps) {
3350
3577
  handler: guard(
3351
3578
  async (args) => withClient(async (client) => {
3352
3579
  const model = await client.getModel(args.modelId);
3353
- if (model.fields.some((f) => f.key === args.key)) {
3354
- return fail(`Field '${args.key}' already exists on model '${model.name}'.`);
3355
- }
3580
+ const dupes = duplicateKeys([...flatFieldKeys(model.fields), args.key]);
3581
+ if (dupes.length > 0) return fail(duplicateKeyFailure(dupes));
3356
3582
  const updated = await client.updateModel(args.modelId, {
3357
3583
  fields: [...model.fields, toField(args)]
3358
3584
  });
@@ -3596,13 +3822,13 @@ function buildToolDefs(deps) {
3596
3822
  {
3597
3823
  name: "get_layout",
3598
3824
  config: {
3599
- title: "Get the project or page Layout draft",
3600
- description: "Read the Global Layout or one page's Layout override, including its optimistic revision. Always read this before update_layout.",
3825
+ title: "Get the project or page Layout",
3826
+ description: "Read the Global Layout or one page's Layout override, including its optimistic revision. Always read this before update_layout. copy:'published' reads the published copy \u2014 the only honest receipt for a publish claim.",
3601
3827
  inputSchema: getLayoutInput.shape
3602
3828
  },
3603
3829
  handler: guard(async (args) => withClient(async (client) => {
3604
3830
  const layout = await client.getManagedLayout(args);
3605
- return ok(`${layout.scope === "global" ? "Global" : `Page ${layout.pageSlug}`} Layout draft at revision ${layout.revision}.`, layout);
3831
+ return ok(`${layout.scope === "global" ? "Global" : `Page ${layout.pageSlug}`} Layout ${args.copy === "published" ? "PUBLISHED copy" : "draft"} at revision ${layout.revision}.`, layout);
3606
3832
  }))
3607
3833
  },
3608
3834
  {
@@ -3980,19 +4206,163 @@ renders. Three consequences, each load-bearing:
3980
4206
  c. set_page_content values EXACTLY equal to the text the site renders \u2014
3981
4207
  binding matches by value, so a paraphrase binds nothing
3982
4208
  d. update_page status 'published' \u2014 the canvas binds the PUBLISHED copy
3983
- 2. A value that renders in more than one place stays uneditable on the canvas (deliberate:
3984
- binding it would edit all of them at once). It remains editable in the side panel.
3985
- 3. Keep chrome semantic \u2014 \`<nav>\`, \`<footer>\`, page content inside \`<main>\`, mastheads
4209
+ 2. A value that renders in more than one place is still ONE field: bind every element that
4210
+ renders it, and the editor keeps them in sync \u2014 an edit patches every copy at once.
4211
+ Binding only one copy leaves the others showing the old text until the next rebuild.
4212
+ 3. Make the chrome ITSELF editable by speaking the layout grammar: the nav/footer
4213
+ elements declare \`data-bcms-layout-section="navigation"\` / \`"footer"\`, and each
4214
+ CMS-backed text inside them a \`data-bcms-layout-field="layout:<sectionId>:<fieldId>"\`
4215
+ marker (fieldId is the field's REAL id, verbatim \u2014 production layout ids are
4216
+ section-prefixed and dotted, e.g. \`footer.tagline\`, so the marker reads
4217
+ \`layout:footer:footer.tagline\`) \u2014 the canvas then gives them hover chrome, the
4218
+ Layout side panel, and
4219
+ double-click editing, writing to the project Layout store (never the page).
4220
+ The address reaches INSIDE structured fields by walking the schema: a group's text
4221
+ sub appends its slug (\`layout:navigation:navigation.cta.label\`), a repeater row its
4222
+ STORED index then the slug (\`layout:navigation:navigation.links.0.label\`), nesting
4223
+ as deep as the schema goes (\`layout:footer:footer.link-groups.0.links.1.label\`).
4224
+ Three rules, each one a measured defect when broken:
4225
+ a. a field rendered by TWO elements gets a marker on BOTH \u2014 the editor keeps the copies
4226
+ in sync, and a marker on only one leaves the other stale;
4227
+ b. PROVENANCE \u2014 mark an element only when the LAYOUT supplied its value; a marker
4228
+ over a fallback/singleton-sourced string opens an editor for a row that does not
4229
+ exist;
4230
+ c. row markers use the value's STORED index (a row you filtered out of the render
4231
+ still occupies its slot), or the edit lands on the wrong row.
4232
+ Text/longtext leaves edit inline; link/select/image leaves are side-panel-only by
4233
+ design (their formats need a real control). THE DOCTRINE: every string a marketer
4234
+ can see must be addressable \u2014 no marker means read-only on the canvas, so an
4235
+ unmarked CMS-backed string is a defect, not a style choice.
4236
+ 4. Keep chrome semantic \u2014 \`<nav>\`, \`<footer>\`, page content inside \`<main>\`, mastheads
3986
4237
  as a top-level \`<header>\`. Chrome is edited through the project Layout, not the page,
3987
4238
  and semantic landmarks are how the editor keeps a nav edit from being written into page
3988
4239
  content. Div-built chrome outside \`<main>\` is still excluded; div-built chrome with no
3989
4240
  \`<main>\` anywhere loses that protection.
3990
4241
 
4242
+ **Structural drafts can render on the real site too \u2014 the draft bridge.** Wrap your page's
4243
+ blocks in \`BcmsDraftBridge\` (\`@bettercms-ai/next/draft-bridge\`) instead of calling
4244
+ \`BcmsBlocks\` directly: standalone it renders identically, and inside the visual editor it
4245
+ receives the DRAFT block tree over a same-origin postMessage handshake and re-renders it with
4246
+ the site's own components \u2014 so adding, removing or reordering sections previews in the site's
4247
+ real design instead of the platform's approximate renderer. Sites without the bridge keep the
4248
+ approximate fallback; unpublished ROUTES always fall back (a static build has no file to frame).
4249
+
3991
4250
  **Hosting decides whether a canvas exists at all.** A site deployed here is framed through a
3992
4251
  same-origin proxy \u2014 that is what the canvas requires. A site hosted elsewhere (your own Vercel,
3993
4252
  your own server) has NO canvas today: the SDK's draft mode with \`stega: true\` embeds invisible
3994
4253
  per-field provenance in fetched strings, which prepares the content for editing surfaces, but do
3995
4254
  not promise a canvas for an externally-hosted site.
4255
+
4256
+ **\xA712 \u2014 RECEIPTS: a claim about published state needs a read of the PUBLISHED copy.** The
4257
+ doctrine this encodes cost a real incident (FLO-1188): a publish was verified against the
4258
+ draft for ~50 minutes because the reader silently returned the draft, and every check was
4259
+ green on the wrong document. Three rules, none optional:
4260
+ 1. NEVER verify a write by re-reading the store you wrote. Draft writes verify against
4261
+ the draft; a PUBLISH claim verifies ONLY via \`get_layout copy:'published'\` (or the
4262
+ entry/page's published copy) \u2014 and check the response's \`copy\` echo says
4263
+ 'published'. A reader that ignores your copy selector hands you the draft and a
4264
+ false green; the echo is how you catch it.
4265
+ 2. Publish and deploy are SEPARATE claims. "Published" means the published copy changed;
4266
+ the LIVE SITE changes only after its next deploy/rebuild. Never report "it's live"
4267
+ from a publish receipt \u2014 fetch the live URL (cache-busted) for that claim.
4268
+ 3. A tool param the schema does not declare is SILENTLY DROPPED, not rejected. If a
4269
+ call's behavior doesn't change when you change a param, treat the param as dead and
4270
+ verify through an independent channel before trusting any result built on it.
4271
+
4272
+ ## 13. Convert an imported repo into a CMS-backed, editable site
4273
+
4274
+ \xA711 says a deploy does not make a site editable. This is the recipe that does, and it ends in a
4275
+ receipt you can read: \`get_binding_report\` says \`mode: "declared"\` with zero unmatched paths.
4276
+
4277
+ **Scope, before you start.**
4278
+
4279
+ (a) This is the FIELD-driven conversion \u2014 page fields and collections. If the human answered
4280
+ \`components\` at the \xA710 gate, go to \xA710's recipe instead: a component's editing surface is its
4281
+ declared \`props\`, and those bindings are NOT what \`get_binding_report\` walks.
4282
+
4283
+ (b) It works for any framework that emits STATIC HTML, because the binding contract is plain
4284
+ HTML attributes \u2014 the annotator, the injector and the canvas stamper never see your source.
4285
+ There are SDK helpers for Astro and Next. A Node-runtime site (\`bcms-runtime.json\`) edits on
4286
+ the canvas but skips release annotation and publish-time injection, because there is no HTML on
4287
+ disk to annotate. Copy rendered on the client must carry the attributes in the HYDRATED DOM,
4288
+ and only the canvas sees it \u2014 a release scan cannot.
4289
+
4290
+ **If you know Sanity, this is the same shape under different names:**
4291
+
4292
+ defineType schema in code -> content models / page fields (create_content_model,
4293
+ add_page_field, or bcms-content.json "schema")
4294
+ TypeGen -> @bettercms-ai/codegen
4295
+ GROQ query in the page -> @bettercms-ai/sdk read client, or the bcms-content.json
4296
+ build snapshot
4297
+ <PortableText> body -> @bettercms-ai/richtext portableTextToHtml, field type
4298
+ 'document'
4299
+ data-sanity / stega -> data-bcms-field + data-bcms-kind (<BcmsField>), stega on
4300
+ draft reads
4301
+ Presentation tool overlays -> the visual editor canvas, framing your own build
4302
+
4303
+ **The steps.**
4304
+
4305
+ 1. \`pull_project_source\` (or clone the \`github\` remote it returns). Read the SOURCE. Never
4306
+ reconstruct content from the deployed HTML \u2014 that is how a site ends up bound to a copy of
4307
+ its own stale build.
4308
+ 2. Decide the architecture WITH the human (\xA710) and record it: \`set_authoring_preference\`.
4309
+ 3. Register the schema. Per route: \`create_page\` + \`add_page_field\` (text / longtext /
4310
+ richtext / image / array groups). Per repeated content type: \`create_content_model\`, with a
4311
+ \`document\` body for articles. Site chrome goes through \`update_layout\`; images through
4312
+ \`create_media_upload\` / \`upload_asset\`, then reference the CMS URL. The repo-owned
4313
+ alternative to calling these one by one is a committed \`bcms-content.json\` carrying a
4314
+ \`schema\` block, which seeds models, pages and entries at connect time \u2014 the \`defineType\`
4315
+ analogue.
4316
+ 4. Seed the entries with the copy EXACTLY as the source renders it (\`set_page_content\`,
4317
+ \`create_content_entry\`). Prose goes in as Portable Text arrays (\`_type: "block"\`); never
4318
+ markup inside a \`text\` field.
4319
+ **Prose MIGRATED from another CMS needs one extra check.** Every block's \`_type\` must be one
4320
+ this platform stores \u2014 \`block\`, or \`bcmsBlock\` carrying a \`schemaKey\` such as
4321
+ \`builtin:table\` \u2014 and a foreign node (a Sanity-shaped \`{_type:"table", rows:[{cells}]}\` is
4322
+ the one that has actually happened) is not merely unrendered: it has no component here, so it
4323
+ vanishes from the HTML every delivery surface reads while the stored value still looks whole.
4324
+ Verify each \`_type\` against the schema before you write, not after.
4325
+ 5. Codemod the templates. Read each value from the CMS (\`@bettercms-ai/astro\` /
4326
+ \`@bettercms-ai/next\` client, or the \`bcms-content.json\` build snapshot), KEEP the in-code
4327
+ copy as the fallback, and declare the binding on the element that already renders it.
4328
+ Prefer schema-derived bindings \u2014 the TypeGen analogue:
4329
+ \`npx @bettercms-ai/codegen --bindings-out src/bettercms.bindings.generated.ts\`, then spread
4330
+ \`{...bcms.home.hero.title}\` / \`{...bcms.blog.features.$(i)}\`. The hand form is
4331
+ \`data-bcms-field="<path>"\` (plus \`data-bcms-kind="richtext"|"image"\`), \`data-bcms-props\`
4332
+ for an \`href\` / \`alt\` / \`src\`, the \xA711 layout markers for nav and footer, and
4333
+ \`<div data-bcms-field="body" data-bcms-kind="document">\` around a Portable Text render.
4334
+ Bind CONDITIONALLY (\`fromCms ? path : undefined\`) so a fallback row is never bound. A value
4335
+ rendered in N places carries the binding on ALL N \u2014 the editor keeps the copies in sync.
4336
+ **Read the LIVE SCHEMA before you bind \u2014 \`get_page\` / \`get_content_model\`, never the
4337
+ delivery snapshot.** A field nobody has authored yet is simply ABSENT from the payload, so a
4338
+ snapshot cannot tell "this field does not exist" from "this field is empty": bind against it
4339
+ and you declare a path for a \`cover\` field that was never created, which the report then
4340
+ reports as broken forever. The schema is the list of what exists; the snapshot is only what
4341
+ currently has a value.
4342
+ **An index or listing route binds NOTHING.** The editor loads ONE entry per route, and an
4343
+ index renders many, so a binding there addresses whichever entry the editor happened to
4344
+ load. Bind each item's fields on that item's OWN route (\`/blog/<slug>\`); on the index,
4345
+ render from the CMS and declare nothing.
4346
+ 5b. **Where the content comes from at build time.** Two lanes, and they differ:
4347
+ - GIT-CONNECTED (recommended for a converted site): the platform's provisioned workflow
4348
+ writes \`bcms-content.json\` into the repo root before \`build\`, using the repo's
4349
+ \`BCMS_API_KEY\` secret. Read that file, with in-code fallbacks so a local or CI build
4350
+ without it still renders.
4351
+ - ARCHIVE (\`deploy_project\` / \`deploy_from_upload\`): the sandbox build runs with no env
4352
+ and no network content step, by design. So the archive MUST SHIP its own
4353
+ \`bcms-content.json\` \u2014 generate it locally with a delivery key and commit it. Without
4354
+ one the site renders its fallbacks and the report says \`no-element\` for every path.
4355
+ 6. Push, or \`deploy_project\`; poll \`get_deploy_status\` until it is live. Then
4356
+ \`get_binding_report\` \u2014 still \`text-match\`, and \`unmatched\` should be EMPTY because the
4357
+ values are byte-equal to what the build renders. Now \`set_binding_mode
4358
+ {declaredBindings: true}\` and release again (an empty commit is enough). Do not flip before
4359
+ the report is clean: in declared mode an undeclared field simply stops being editable.
4360
+ 7. **Receipts.** \`get_binding_report\` reads \`mode: "declared"\`, \`unmatched: []\`, and
4361
+ \`bound > 0\`. That certifies one thing only \u2014 that every non-empty field has SOME element
4362
+ carrying its path. It CANNOT see copy that was never modelled, so diff each route's visible
4363
+ text against its entry values yourself before you call the page done. Then publish, and
4364
+ fetch the live URL cache-busted (\xA712: publish and deploy are separate claims).
4365
+ \`get_next_steps\` keeps reporting the gap until every one of these holds.
3996
4366
  `;
3997
4367
 
3998
4368
  // src/prompts.ts
@@ -4434,7 +4804,7 @@ On 401/403, the MCP key needs (re)authorizing.`
4434
4804
 
4435
4805
  // src/server.ts
4436
4806
  var SERVER_NAME = "bettercms";
4437
- var SERVER_VERSION = "1.3.0";
4807
+ var SERVER_VERSION = "1.4.0";
4438
4808
  var SERVER_DISPLAY = {
4439
4809
  title: "BetterCMS",
4440
4810
  websiteUrl: "https://bettercms.ai",
@@ -4446,7 +4816,10 @@ var SERVER_DISPLAY = {
4446
4816
  function buildServer(deps) {
4447
4817
  const server = new McpServer(
4448
4818
  { name: SERVER_NAME, version: SERVER_VERSION, ...SERVER_DISPLAY },
4449
- { capabilities: { tools: {}, prompts: {}, resources: {} } }
4819
+ {
4820
+ capabilities: { tools: {}, prompts: {}, resources: {} },
4821
+ instructions: "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."
4822
+ }
4450
4823
  );
4451
4824
  server.registerResource(
4452
4825
  "schema-playbook",