@bettercms-ai/mcp 0.22.0 → 0.23.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
@@ -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,
@@ -3241,6 +3354,102 @@ function buildToolDefs(deps) {
3241
3354
  z.object({ jobId: z.string().min(1) }).shape,
3242
3355
  async (c, a) => ok("Job rejected.", await data(c, "POST", `/management/bulk/jobs/${s(a.jobId)}/reject`))
3243
3356
  ),
3357
+ def(
3358
+ "list_section_validation_requests",
3359
+ "List queued Section validation requests",
3360
+ "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.",
3361
+ listSectionValidationRequestsInput.shape,
3362
+ async (c, a) => ok("Queued user-agent Section validation requests.", await data(
3363
+ c,
3364
+ "GET",
3365
+ `/projects/${s(a.projectId)}/section-evidence/implementation-requests${q({ limit: a.limit })}`
3366
+ ))
3367
+ ),
3368
+ def(
3369
+ "claim_section_validation_request",
3370
+ "Claim a Section validation request",
3371
+ "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.",
3372
+ claimSectionValidationRequestInput.shape,
3373
+ async (c, a) => ok("Section validation request claimed.", await data(
3374
+ c,
3375
+ "POST",
3376
+ `/projects/${s(a.projectId)}/section-evidence/implementation-requests/${s(a.requestId)}/claim`,
3377
+ {
3378
+ commitSha: a.commitSha,
3379
+ providerRunId: a.providerRunId,
3380
+ providerRunUrl: a.providerRunUrl
3381
+ }
3382
+ ))
3383
+ ),
3384
+ def(
3385
+ "complete_section_validation_request",
3386
+ "Complete a Section validation request",
3387
+ "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.",
3388
+ completeSectionValidationRequestInput.shape,
3389
+ async (c, a) => ok("Section validation request completed.", await data(
3390
+ c,
3391
+ "POST",
3392
+ `/projects/${s(a.projectId)}/section-evidence/implementation-requests/${s(a.requestId)}/complete`,
3393
+ { manifestId: a.manifestId, validationRunId: a.validationRunId }
3394
+ ))
3395
+ ),
3396
+ def(
3397
+ "fail_section_validation_request",
3398
+ "Fail a Section validation request",
3399
+ "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.",
3400
+ failSectionValidationRequestInput.shape,
3401
+ async (c, a) => ok("Section validation request failed.", await data(
3402
+ c,
3403
+ "POST",
3404
+ `/projects/${s(a.projectId)}/section-evidence/implementation-requests/${s(a.requestId)}/fail`,
3405
+ {
3406
+ errorCode: a.errorCode,
3407
+ errorMessage: a.errorMessage,
3408
+ providerRunId: a.providerRunId,
3409
+ providerRunUrl: a.providerRunUrl
3410
+ }
3411
+ ))
3412
+ ),
3413
+ def(
3414
+ "submit_section_manifest",
3415
+ "Submit a Section implementation manifest",
3416
+ "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.",
3417
+ submitSectionManifestInput.shape,
3418
+ async (c, a) => ok("Section implementation manifest recorded.", await data(
3419
+ c,
3420
+ "POST",
3421
+ `/projects/${s(a.projectId)}/section-evidence/sections/${s(a.sectionId)}/versions/${a.version}/manifests`,
3422
+ {
3423
+ requestId: a.requestId,
3424
+ apiId: a.apiId,
3425
+ schemaHash: a.schemaHash,
3426
+ commitSha: a.commitSha,
3427
+ loader: a.loader,
3428
+ previewAdapter: a.previewAdapter,
3429
+ nativeViewports: a.nativeViewports
3430
+ }
3431
+ ))
3432
+ ),
3433
+ def(
3434
+ "submit_section_validation",
3435
+ "Submit a Section validation result",
3436
+ "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.",
3437
+ submitSectionValidationInput.shape,
3438
+ async (c, a) => ok("Section validation result recorded.", await data(
3439
+ c,
3440
+ "POST",
3441
+ `/projects/${s(a.projectId)}/section-evidence/sections/${s(a.sectionId)}/versions/${a.version}/validations`,
3442
+ {
3443
+ requestId: a.requestId,
3444
+ manifestId: a.manifestId,
3445
+ status: a.status,
3446
+ fixtureHash: a.fixtureHash,
3447
+ evidenceDigest: a.evidenceDigest,
3448
+ appShell: a.appShell,
3449
+ viewportResults: a.viewportResults
3450
+ }
3451
+ ))
3452
+ ),
3244
3453
  def(
3245
3454
  "get_deploy_status",
3246
3455
  "Get deploy/build status",
@@ -3296,11 +3505,13 @@ function buildToolDefs(deps) {
3296
3505
  name: "create_page",
3297
3506
  config: {
3298
3507
  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,
3508
+ 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
3509
  inputSchema: createPageInput.shape
3301
3510
  },
3302
3511
  handler: guard(
3303
3512
  async (args) => withClient(async (client) => {
3513
+ const dupes = duplicateKeys(flatFieldKeys(args.fields));
3514
+ if (dupes.length > 0) return fail(duplicateKeyFailure(dupes));
3304
3515
  const page = await client.createPage({
3305
3516
  title: args.title,
3306
3517
  slug: args.slug,
@@ -3326,6 +3537,8 @@ function buildToolDefs(deps) {
3326
3537
  },
3327
3538
  handler: guard(
3328
3539
  async (args) => withClient(async (client) => {
3540
+ const modelDupes = duplicateKeys(flatFieldKeys(args.fields));
3541
+ if (modelDupes.length > 0) return fail(duplicateKeyFailure(modelDupes));
3329
3542
  const model = await client.createModel({
3330
3543
  name: args.name,
3331
3544
  slug: args.slug,
@@ -3350,9 +3563,8 @@ function buildToolDefs(deps) {
3350
3563
  handler: guard(
3351
3564
  async (args) => withClient(async (client) => {
3352
3565
  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
- }
3566
+ const dupes = duplicateKeys([...flatFieldKeys(model.fields), args.key]);
3567
+ if (dupes.length > 0) return fail(duplicateKeyFailure(dupes));
3356
3568
  const updated = await client.updateModel(args.modelId, {
3357
3569
  fields: [...model.fields, toField(args)]
3358
3570
  });
@@ -3596,13 +3808,13 @@ function buildToolDefs(deps) {
3596
3808
  {
3597
3809
  name: "get_layout",
3598
3810
  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.",
3811
+ title: "Get the project or page Layout",
3812
+ 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
3813
  inputSchema: getLayoutInput.shape
3602
3814
  },
3603
3815
  handler: guard(async (args) => withClient(async (client) => {
3604
3816
  const layout = await client.getManagedLayout(args);
3605
- return ok(`${layout.scope === "global" ? "Global" : `Page ${layout.pageSlug}`} Layout draft at revision ${layout.revision}.`, layout);
3817
+ return ok(`${layout.scope === "global" ? "Global" : `Page ${layout.pageSlug}`} Layout ${args.copy === "published" ? "PUBLISHED copy" : "draft"} at revision ${layout.revision}.`, layout);
3606
3818
  }))
3607
3819
  },
3608
3820
  {
@@ -3784,6 +3996,7 @@ function registerTools(server, deps) {
3784
3996
  import { z as z2 } from "zod";
3785
3997
 
3786
3998
  // src/playbook.ts
3999
+ var PLAYBOOK_URI = "bettercms://playbook/schema";
3787
4000
  var SCHEMA_PLAYBOOK = `# Designing a BetterCMS project
3788
4001
 
3789
4002
  Read this BEFORE creating anything. Confirm the shape with the user before you build it.
@@ -3981,17 +4194,65 @@ renders. Three consequences, each load-bearing:
3981
4194
  d. update_page status 'published' \u2014 the canvas binds the PUBLISHED copy
3982
4195
  2. A value that renders in more than one place stays uneditable on the canvas (deliberate:
3983
4196
  binding it would edit all of them at once). It remains editable in the side panel.
3984
- 3. Keep chrome semantic \u2014 \`<nav>\`, \`<footer>\`, page content inside \`<main>\`, mastheads
4197
+ 3. Make the chrome ITSELF editable by speaking the layout grammar: the nav/footer
4198
+ elements declare \`data-bcms-layout-section="navigation"\` / \`"footer"\`, and each
4199
+ CMS-backed text inside them a \`data-bcms-layout-field="layout:<sectionId>:<fieldId>"\`
4200
+ marker (fieldId is the field's REAL id, verbatim \u2014 production layout ids are
4201
+ section-prefixed and dotted, e.g. \`footer.tagline\`, so the marker reads
4202
+ \`layout:footer:footer.tagline\`) \u2014 the canvas then gives them hover chrome, the
4203
+ Layout side panel, and
4204
+ double-click editing, writing to the project Layout store (never the page).
4205
+ The address reaches INSIDE structured fields by walking the schema: a group's text
4206
+ sub appends its slug (\`layout:navigation:navigation.cta.label\`), a repeater row its
4207
+ STORED index then the slug (\`layout:navigation:navigation.links.0.label\`), nesting
4208
+ as deep as the schema goes (\`layout:footer:footer.link-groups.0.links.1.label\`).
4209
+ Three rules, each one a measured defect when broken:
4210
+ a. a field rendered by TWO elements gets a marker on only one \u2014 the editor binds a
4211
+ twice-carried field to neither;
4212
+ b. PROVENANCE \u2014 mark an element only when the LAYOUT supplied its value; a marker
4213
+ over a fallback/singleton-sourced string opens an editor for a row that does not
4214
+ exist;
4215
+ c. row markers use the value's STORED index (a row you filtered out of the render
4216
+ still occupies its slot), or the edit lands on the wrong row.
4217
+ Text/longtext leaves edit inline; link/select/image leaves are side-panel-only by
4218
+ design (their formats need a real control). THE DOCTRINE: every string a marketer
4219
+ can see must be addressable \u2014 no marker means read-only on the canvas, so an
4220
+ unmarked CMS-backed string is a defect, not a style choice.
4221
+ 4. Keep chrome semantic \u2014 \`<nav>\`, \`<footer>\`, page content inside \`<main>\`, mastheads
3985
4222
  as a top-level \`<header>\`. Chrome is edited through the project Layout, not the page,
3986
4223
  and semantic landmarks are how the editor keeps a nav edit from being written into page
3987
4224
  content. Div-built chrome outside \`<main>\` is still excluded; div-built chrome with no
3988
4225
  \`<main>\` anywhere loses that protection.
3989
4226
 
4227
+ **Structural drafts can render on the real site too \u2014 the draft bridge.** Wrap your page's
4228
+ blocks in \`BcmsDraftBridge\` (\`@bettercms-ai/next/draft-bridge\`) instead of calling
4229
+ \`BcmsBlocks\` directly: standalone it renders identically, and inside the visual editor it
4230
+ receives the DRAFT block tree over a same-origin postMessage handshake and re-renders it with
4231
+ the site's own components \u2014 so adding, removing or reordering sections previews in the site's
4232
+ real design instead of the platform's approximate renderer. Sites without the bridge keep the
4233
+ approximate fallback; unpublished ROUTES always fall back (a static build has no file to frame).
4234
+
3990
4235
  **Hosting decides whether a canvas exists at all.** A site deployed here is framed through a
3991
4236
  same-origin proxy \u2014 that is what the canvas requires. A site hosted elsewhere (your own Vercel,
3992
4237
  your own server) has NO canvas today: the SDK's draft mode with \`stega: true\` embeds invisible
3993
4238
  per-field provenance in fetched strings, which prepares the content for editing surfaces, but do
3994
4239
  not promise a canvas for an externally-hosted site.
4240
+
4241
+ **\xA712 \u2014 RECEIPTS: a claim about published state needs a read of the PUBLISHED copy.** The
4242
+ doctrine this encodes cost a real incident (FLO-1188): a publish was verified against the
4243
+ draft for ~50 minutes because the reader silently returned the draft, and every check was
4244
+ green on the wrong document. Three rules, none optional:
4245
+ 1. NEVER verify a write by re-reading the store you wrote. Draft writes verify against
4246
+ the draft; a PUBLISH claim verifies ONLY via \`get_layout copy:'published'\` (or the
4247
+ entry/page's published copy) \u2014 and check the response's \`copy\` echo says
4248
+ 'published'. A reader that ignores your copy selector hands you the draft and a
4249
+ false green; the echo is how you catch it.
4250
+ 2. Publish and deploy are SEPARATE claims. "Published" means the published copy changed;
4251
+ the LIVE SITE changes only after its next deploy/rebuild. Never report "it's live"
4252
+ from a publish receipt \u2014 fetch the live URL (cache-busted) for that claim.
4253
+ 3. A tool param the schema does not declare is SILENTLY DROPPED, not rejected. If a
4254
+ call's behavior doesn't change when you change a param, treat the param as dead and
4255
+ verify through an independent channel before trusting any result built on it.
3995
4256
  `;
3996
4257
 
3997
4258
  // src/prompts.ts
@@ -4433,7 +4694,7 @@ On 401/403, the MCP key needs (re)authorizing.`
4433
4694
 
4434
4695
  // src/server.ts
4435
4696
  var SERVER_NAME = "bettercms";
4436
- var SERVER_VERSION = "1.3.0";
4697
+ var SERVER_VERSION = "1.4.0";
4437
4698
  var SERVER_DISPLAY = {
4438
4699
  title: "BetterCMS",
4439
4700
  websiteUrl: "https://bettercms.ai",
@@ -4445,7 +4706,22 @@ var SERVER_DISPLAY = {
4445
4706
  function buildServer(deps) {
4446
4707
  const server = new McpServer(
4447
4708
  { name: SERVER_NAME, version: SERVER_VERSION, ...SERVER_DISPLAY },
4448
- { capabilities: { tools: {}, prompts: {} } }
4709
+ {
4710
+ capabilities: { tools: {}, prompts: {}, resources: {} },
4711
+ 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."
4712
+ }
4713
+ );
4714
+ server.registerResource(
4715
+ "schema-playbook",
4716
+ PLAYBOOK_URI,
4717
+ {
4718
+ title: "BetterCMS schema & components playbook",
4719
+ description: "How to design a components-first BetterCMS project: components vs collections, section anatomy, sectionType variants, kind:'block' + modular fields, the 'document' article body, the draft->publish order, and what makes an imported site editable.",
4720
+ mimeType: "text/markdown"
4721
+ },
4722
+ () => ({
4723
+ contents: [{ uri: PLAYBOOK_URI, mimeType: "text/markdown", text: SCHEMA_PLAYBOOK }]
4724
+ })
4449
4725
  );
4450
4726
  registerTools(server, {
4451
4727
  auth: deps.auth,