@bettercms-ai/mcp 0.83.0 → 0.83.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.
Files changed (40) hide show
  1. package/README.md +86 -86
  2. package/SKILL.md +160 -160
  3. package/dist/index.js +38 -9
  4. package/dist/index.js.map +1 -1
  5. package/dist/kit-catalog.json +1509 -1509
  6. package/dist/skills/bettercms/SKILL.md +206 -206
  7. package/dist/skills/bettercms/references/accessibility.md +171 -171
  8. package/dist/skills/bettercms/references/animation.md +296 -296
  9. package/dist/skills/bettercms/references/astro.md +148 -148
  10. package/dist/skills/bettercms/references/bindings.md +366 -366
  11. package/dist/skills/bettercms/references/componentize.md +185 -185
  12. package/dist/skills/bettercms/references/components.md +415 -415
  13. package/dist/skills/bettercms/references/content-experiments.md +62 -62
  14. package/dist/skills/bettercms/references/content-modeling.md +382 -382
  15. package/dist/skills/bettercms/references/detection.md +465 -465
  16. package/dist/skills/bettercms/references/dock-input.md +121 -121
  17. package/dist/skills/bettercms/references/editor-chrome.md +92 -92
  18. package/dist/skills/bettercms/references/external-content.md +78 -78
  19. package/dist/skills/bettercms/references/finish-site.md +377 -377
  20. package/dist/skills/bettercms/references/forms-and-interactions.md +245 -245
  21. package/dist/skills/bettercms/references/frameworks.md +159 -159
  22. package/dist/skills/bettercms/references/getting-started.md +156 -156
  23. package/dist/skills/bettercms/references/integrations.md +364 -364
  24. package/dist/skills/bettercms/references/kit-catalog.md +3259 -3259
  25. package/dist/skills/bettercms/references/kit-scenarios.md +712 -712
  26. package/dist/skills/bettercms/references/localization.md +85 -85
  27. package/dist/skills/bettercms/references/media-and-brand.md +181 -181
  28. package/dist/skills/bettercms/references/next.md +189 -189
  29. package/dist/skills/bettercms/references/other-frameworks.md +85 -85
  30. package/dist/skills/bettercms/references/portable-text.md +213 -213
  31. package/dist/skills/bettercms/references/programmatic-seo.md +98 -98
  32. package/dist/skills/bettercms/references/publishing.md +289 -289
  33. package/dist/skills/bettercms/references/seo.md +357 -357
  34. package/dist/skills/bettercms/references/shopify-headless.md +52 -52
  35. package/dist/skills/bettercms/references/site-blueprints.md +895 -895
  36. package/dist/skills/bettercms/references/site-review.md +74 -74
  37. package/dist/skills/bettercms/references/worked-examples-advanced.md +509 -509
  38. package/dist/skills/bettercms/references/worked-examples.md +885 -885
  39. package/dist/skills/bettercms/references/write-limits.md +217 -217
  40. package/package.json +1 -1
@@ -1,217 +1,217 @@
1
- ---
2
- title: "Write limits"
3
- description: "What the server checks on a content write (lengths, patterns, uniqueness, references, body size) and what it does not."
4
- ---
5
-
6
- # Write limits: what the server checks on a content write
7
-
8
- Use this before you rely on a limit. "Exists" means the server refuses a bad value. "Does not
9
- exist" means the value is stored, so you are the check.
10
-
11
- ## The write path
12
-
13
- Entry writes from the MCP and the Management API run `createEntry` / `updateEntry` in
14
- `src/lib/content/entry-write.ts`:
15
-
16
- 1. Request schema (Zod). Failure: 400 with `{ errors }`, keyed by field, no `code`.
17
- 2. Value normalisation (shapes below). Failure: a validation result with `errors`.
18
- 3. Reference existence. Every entry id in a reference field is looked up in the workspace. A
19
- missing one is refused: 400 `ref_missing`, with `errors` keyed by the field that holds it. A
20
- target that exists but sits in the trash is stored, and the result carries a `warnings` line
21
- starting `REFERENCE_NOT_FOUND:` that names the field. A reference loop is refused with 400
22
- and `errors._base`.
23
- 4. Per-field value checks against the model. Failure: a validation result with `errors`.
24
- 5. Uniqueness, inside the insert transaction. Failure: `kind: "unique_conflict"` with `conflicts`.
25
-
26
- The batch route reports steps 2, 4 and 5 as `code: "UNIQUE_CONFLICT"` or `"VALIDATION"`.
27
-
28
- Rule: read `errors` by field key, fix exactly those fields, write again once.
29
-
30
- `update_content_entry` merges `data` into the stored value by top-level key: a field left out is
31
- kept, and a group or repeater you send still replaces that whole key. `merge: false` REPLACES the
32
- whole value object instead, deleting every optional field left out. Pass `ifMatch` (the
33
- `optimisticVersion` from `get_content_entry`) so a 409 tells you someone wrote since your read; a
34
- merge defaults to the version it read.
35
-
36
- ## Value contract
37
-
38
- What to send. "Checked" means a bad value is refused; "yours" means the server stores what it gets.
39
-
40
- | Value | Send | Server |
41
- |---|---|---|
42
- | No value | Leave the key out. Never `null`, never `""` on an optional field | Yours. `null` skips every type check and is stored as written; `""` is stored as text. Both count as empty for `required` |
43
- | No value, SEO writers | Send only the keys you change | `null` or `""` CLEARS the stored key ([seo.md](seo.md) §1) |
44
- | Number | A JSON number: `29`, `39.99`. Never `"29"` | Checked: a string or a non-finite number is refused |
45
- | Money | A `number` field in major units (`39.99`, not `3999` unless the source API is in cents) plus a sibling `select` holding the ISO 4217 code. There is no money type | Number checked; unit and currency are yours |
46
- | Price label (`"$39/mo"`) | A separate text field, only when the page prints that exact string | Yours. Keep it in step with the number |
47
- | `date`, date only | `YYYY-MM-DD` | Checked: a real calendar day, no time part |
48
- | `date` with `includeTime: true`, `datetime` | ISO 8601 with seconds and an offset: `2026-10-14T09:00:00+05:30`, or `Z` for UTC | Time part checked. On a field with `timezone` in its config (`site`, `UTC` or an IANA name) a value without `Z` or an offset is refused. Without it the offset is accepted but not required, so a value without one is stored ambiguous: always send it |
49
- | `select` | One of the declared option values | Checked |
50
- | Plain text (`text`, `longtext`) | Words only. No tags, no entities, no markdown | Yours. Markup is stored and printed as written |
51
- | Formatted prose | A Portable Text array in a rich text field ([portable-text.md](portable-text.md)) | Top-level `_type` checked |
52
- | A list of things | A `repeater` or an `array`, never `<ul>` or line breaks in one string | Shape checked |
53
- | A link, a button, an image | Their own fields, never `<a>` or `<img>` in a string | Per field type |
54
-
55
- Booleans: send `false` explicitly when a condition (`showIf`) or the template reads the field.
56
-
57
- ### Stable keys
58
-
59
- Keys are edit addresses: the canvas and co-editing point at them. Regenerating one
60
- orphans whatever pointed at it.
61
-
62
- | Where | Key | Rule |
63
- |---|---|---|
64
- | Portable Text block, span, markDef, table row and cell | `_key` | Matches `^[A-Za-z0-9-]{1,32}$` (no underscore, no dot: the address grammar splits on them), unique in the value. Write short meaningful keys (`intro`, `q-shipping`), never a position |
65
- | Repeater row | `_id` | Minted by the server on the first write when you send none. An `_id` you send is kept as is |
66
- | Entry, component, page | id and slug | Never recreate to change content: update in place |
67
-
68
- Rules:
69
-
70
- - To change a keyed value, read it, edit the items you mean to change, and send every other item
71
- back with the key it came with.
72
- - A repeater row sent without `_id` on a later write gets a NEW one: the MCP and Management API
73
- write path does not match rows to the stored ones. Always echo `_id` from the read.
74
- - On a re-run of a load, match each source item to its stored item by content and reuse that key.
75
- - `array` items (scalars) have no key. Their address is the index, so do not reorder them for style.
76
-
77
- ## Dry run
78
-
79
- `create_content_entry`, `create_content_entries`, `create_content_model`, `add_field`,
80
- `compose_pages`, `create_redirect` and `import_redirects` take `dryRun: true`. The call runs every
81
- check a real one runs and writes nothing:
82
- no row, no ingested image, no webhook, no rebuild. The answer carries `dryRun: true` and, per item,
83
- `would-create` (`would-update` for a composed page) or the refusal a real run gives. A batch dry run
84
- also catches a slug repeated inside the batch; a repeated `unique` value shows only on the real run.
85
-
86
- Rule: dry-run a bulk write first, fix the failed rows, then send it again without `dryRun`.
87
-
88
- ## Safe re-runs
89
-
90
- A load that stops halfway is run again. Write it so the second run changes nothing it already did.
91
-
92
- `create_content_entries` rows and `create_content_entry` take `externalKey` (1 to 255 characters):
93
- the item's id in the system it comes from. The key is unique per collection. Each keyed row answers:
94
-
95
- | Status | Meaning |
96
- |---|---|
97
- | `created` | No entry had the key. A new draft is stored under it |
98
- | `unchanged` | The entry exists and the row equals the one last sent. Nothing is written |
99
- | `updated` | The entry exists and the row differs. `data` replaces its draft; a published entry stays published with pending changes |
100
- | `failed` | The refusal a plain create or update gives (`FIELD_LOCKED`, a taken slug, a validation error), or the key repeated inside the batch |
101
-
102
- "Equals" compares a hash of the `slug` and `data` you send, not the stored values (image URLs are
103
- re-hosted on the way in), so send the same payload to get `unchanged`. With `dryRun: true` the
104
- statuses are `would-create`, `would-update`, `unchanged` and `failed`. Optional `source` (the source
105
- URL) and `runId` are kept with the entry. A row without `externalKey` always creates.
106
-
107
- `list_content_entries` takes `externalKey` (exact match) and its summaries carry the key, so a
108
- stopped run resumes by reading what is already stored. Only entries created with a key match.
109
-
110
- Rule: give every migrated row its source id as `externalKey`, then re-run the whole batch freely.
111
- Entries then need no local id map. Media, and a reference to an item not yet created, still do.
112
-
113
- What has no `externalKey` (models, components, pages, redirects, repeater rows, Portable Text):
114
-
115
- - Reuse stable identifiers: the same slug per entry, the same `_key` and `_id` per item (see Stable
116
- keys), the same component id. Read the stored row and match by content before you mint anything.
117
- - Skip a write whose source did not change. Keep a content hash per source page and asset.
118
- - These answers mean "already done". Record them as success and move on:
119
-
120
- | Answer | From | Why it is success |
121
- |---|---|---|
122
- | `ALREADY_COMPONENTIZED` | `componentize_sections` | The group is already placed as a component; nothing is written |
123
- | `existing` on a row | `import_redirects` | The same rule is already stored |
124
- | 409 `slug_taken` on a row you created earlier in this load | entry or model create without `externalKey` | Switch to update for that row |
125
- | 409 `EXTERNAL_KEY_TAKEN` | a keyed entry create that raced another write | Send the row again: it matches that entry |
126
-
127
- - A `create_redirect` 409 means a rule for that source exists, identical or not: the tool does
128
- not tell the two apart. Read it with `list_redirects` and decide. On a re-run use
129
- `import_redirects`, which answers `existing` for a rule already stored as sent.
130
- - Batch writes report each row. One failed row does not stop the rest: record the failures, fix
131
- them, re-run only those rows.
132
- - Before a bulk load, write ONE entry per collection, read it back, open its page, then load the
133
- rest. A wrong shape costs one entry instead of hundreds.
134
- - Create reference targets before the entries that point at them, or the write is refused with
135
- `ref_missing`.
136
-
137
- ## Limits that exist
138
-
139
- | Limit | Behaviour |
140
- |---|---|
141
- | `maxLength` / `minLength` in text field config | Counted in characters. An empty value is left to `required` |
142
- | `pattern` in text field config | Tested against every non-empty value |
143
- | Numeric limit config (`maxLength`, `minLength`, `min`, `max`, `step`) on model save | A numeric string (`"60"`) is stored as a number; an impossible value (`"abc"`, a negative length, `step: 0`, `min` above `max`, a pattern that does not compile) is refused with `FIELD_CONFIG_INVALID`, naming the field and the fix |
144
- | Repeater `maxItems` | Enforced when a number |
145
- | `select` value | Must be one of the field's options |
146
- | Rich text top-level `_type` | A name nothing renders is refused, naming `_type` and `_key`. Top level only — a foreign mark keeps its text unmarked, a foreign inline child renders nothing |
147
- | Reference target | Each referenced entry id must exist in the workspace: 400 `ref_missing`, keyed by field. A reference chain that loops back to the entry is refused with 400 and `errors._base` |
148
- | Request body | 260 MB for the whole server |
149
-
150
- ## Limits that do not exist (you hold them)
151
-
152
- | Missing check | Rule for the agent |
153
- |---|---|
154
- | Limit config saved as a string before the model-save check existed | Write every numeric config value as a JSON number; re-save a model you find with string limits |
155
- | Modular (block) field `blockSlugs`, `minItems`, `maxItems` | Check the block list against the field config yourself before writing |
156
- | A reference to a trashed entry (stored, with a warning) | Read `warnings` after the write; repoint or restore the target |
157
- | Markup inside plain text; a datetime with no offset; `null` on an optional field | Follow the value contract above |
158
- | Component `overrides` (any keys, any types; required props not enforced) | Write only declared prop keys with the declared type; fill every prop the component needs |
159
- | Size or depth of text, richtext and JSON values | Keep values to what the page renders; never paste whole documents into one field |
160
- | Publish re-validation (publish copies the draft to live) | Only publish a draft you wrote through the write path above |
161
- | Version restore re-validation (restore writes the stored snapshot) | After a restore, read the entry and write it back through `update_content_entry` so checks run |
162
-
163
- ## Normaliser output shapes
164
-
165
- What you read back after a write:
166
-
167
- | Field | Stored shape |
168
- |---|---|
169
- | richtext | `{ format, value, html }` envelope |
170
- | image | `{ id, url, name, altText }`. An asset id or media URL resolves to its asset; a site-relative path resolves to the asset the build adopted; an external URL on a project-scoped write is fetched and stored as a new asset. Only when none of that works (fetch refused or failed, no project scope) is it kept as `{ url }` (plus `altText` if given) |
171
- | group / repeater (zoned) | Zone envelopes `{ nonRepeatable, repeatable }`, walked field by field |
172
- | repeater given a bare array | Wrapped as `{ repeatable: [...] }` |
173
-
174
- Rich text written as HTML or markdown may be stored in the older Lexical format depending on a
175
- server flag. Portable Text arrays are always stored as Portable Text
176
- ([portable-text.md](portable-text.md)).
177
-
178
- A stored value of `{ html }` is the shape starter templates and `bcms-content.json` seed. Writes
179
- accept it and store the full envelope, so sending an entry's data back unchanged works. Do not
180
- write new rich text in that shape.
181
-
182
- ## Other refusal codes that exist today
183
-
184
- | Area | Codes | Where described |
185
- |---|---|---|
186
- | Content sidebar structure | `NAV_SYSTEM_FOLDER`, `NAV_TYPE_MISMATCH`, `NAV_KIND_MISMATCH`, `NAV_PAGE_BOUND_MODEL`, `SCHEMA_ACCESS_REQUIRED` | the folder tool descriptions |
187
- | Hosting | `HEADLESS_NO_BUILD`, `HEADLESS_NEEDS_CUSTOM_HOST`, `HEADLESS_ARTIFACT_TARGET` | [frameworks.md](frameworks.md) |
188
- | Components | `COMPONENT_*` publish codes | [components.md](components.md) §4 |
189
- | Content models | `FIELD_CONFIG_INVALID` | this file |
190
- | Entries | `FIELD_LOCKED` (403: the write changes or omits a `readOnly: "developer"` field without schema access; carries `field` and `lockReason`), `EXTERNAL_KEY_TAKEN` (409) | [content-modeling.md](content-modeling.md) §4, Safe re-runs above |
191
- | Forms | `FORM_FIELD_INVALID`, `FORM_DEFINITION_INVALID`, `FORMS_WORKER_NOT_CONFIGURED`, `FORMS_WORKER_SECRET_UNSET`, `FORM_SUBMISSION_NOT_FOUND`, `FORM_SECRET_DASHBOARD_ONLY` | [forms-and-interactions.md](forms-and-interactions.md) |
192
-
193
- `SCHEMA_ACCESS_REQUIRED` (403): see the owner boundary in SKILL.md.
194
-
195
- ## What each refusal means and what to do next
196
-
197
- | Answer | Meaning | Next step |
198
- |---|---|---|
199
- | 400 `{ errors }`, no code | A value failed the request schema or a field check | Fix exactly the named fields, write once more |
200
- | 400 `ref_missing` | A reference field names an entry id that is not in this workspace. `errors` is keyed by the field | Create or find the target first, then write the real id. Never invent an id |
201
- | 400 `FIELD_CONFIG_INVALID` | A model or component save carries an impossible setting: a limit that is not a number, `min` above `max`, `step: 0`, a pattern that does not compile, a prop `defaultValue` of the wrong type. The message names the field and the setting | Send a value that makes sense for that setting |
202
- | 400 `PROJECT_REQUIRED` | A delivery read with no project scope hit a slug that exists in more than one project of the workspace | Add `?projectId=` to the read, or use a project-scoped API key |
203
- | 409 `slug_taken` | The slug is in use. Entries: `slug` plus `suggestions`. Models: `code`, `suggestions` and `errors.slug` | Read the existing row first: on a re-run it is usually yours, so update it. Otherwise take one of `suggestions` |
204
- | 409 `EXTERNAL_KEY_TAKEN` | Another write created an entry with the same `externalKey` at the same moment | Send the row again: it matches that entry and answers `updated` or `unchanged` |
205
- | 403 `FIELD_LOCKED` | The write changes a `readOnly: "developer"` field and the caller has no schema access. Carries `field` and `lockReason` | Send the stored value back for that field, or ask an Admin or Developer to change it |
206
- | 403 `SCHEMA_ACCESS_REQUIRED` | A model save adds or removes a lock (`readOnly: "developer"`, `lockReason`) without schema access | Send the fields with their stored lock, or ask an Admin or Developer |
207
- | 409 `unique_conflict` | Another entry already holds this value in a unique field. `conflicts` names it | Match to that entry and update it; do not create a twin |
208
- | 409 `locale_conflict` | The translation group already has an entry in that locale. `errors` names `locale` or `translationOf` | Edit the existing translation |
209
- | 409 `Version conflict` (entries), `VERSION_CONFLICT` (sidebar, section zones) | Someone wrote since your read. The entry body carries `currentVersion` | Read again, re-apply your change to the fresh value, write with the new version |
210
- | 409 `stale-plan` | The site changed after the componentize plan was read; `digest` is the current one | `get_componentize_plan` again, re-confirm, resubmit with the new digest |
211
- | 409 `style-edits` | The conversion receipt lists changed classes or styles. `styleEdits` holds up to 40 tokens | Restore every token, rerun `npx @bettercms-ai/convert --verify-styles`, resubmit the receipt |
212
- | 400 `digest-mismatch` | The receipt was built for a different brief | Read the brief again and rebuild the receipt against it |
213
- | 422 `PRESENTATION_INVALID` | `update_project` got a `presentation` value the renderer cannot use. `error` names the key | Fix that key; other settings in the same call were not saved |
214
- | Warning `UNPROPPED_COPY` | A saved component holds copy no prop targets, so it is not editable. The line lists up to 8 leaves as `<blockType>#<blockId>.<path>` | Declare a prop with a matching `target` for each leaf. The save itself succeeded |
215
- | Warning `REFERENCE_NOT_FOUND` | A reference points at a trashed entry. Stored anyway | Repoint the field or restore the target |
216
-
217
- Component, form, hosting and sidebar codes: the table above.
1
+ ---
2
+ title: "Write limits"
3
+ description: "What the server checks on a content write (lengths, patterns, uniqueness, references, body size) and what it does not."
4
+ ---
5
+
6
+ # Write limits: what the server checks on a content write
7
+
8
+ Use this before you rely on a limit. "Exists" means the server refuses a bad value. "Does not
9
+ exist" means the value is stored, so you are the check.
10
+
11
+ ## The write path
12
+
13
+ Entry writes from the MCP and the Management API run `createEntry` / `updateEntry` in
14
+ `src/lib/content/entry-write.ts`:
15
+
16
+ 1. Request schema (Zod). Failure: 400 with `{ errors }`, keyed by field, no `code`.
17
+ 2. Value normalisation (shapes below). Failure: a validation result with `errors`.
18
+ 3. Reference existence. Every entry id in a reference field is looked up in the workspace. A
19
+ missing one is refused: 400 `ref_missing`, with `errors` keyed by the field that holds it. A
20
+ target that exists but sits in the trash is stored, and the result carries a `warnings` line
21
+ starting `REFERENCE_NOT_FOUND:` that names the field. A reference loop is refused with 400
22
+ and `errors._base`.
23
+ 4. Per-field value checks against the model. Failure: a validation result with `errors`.
24
+ 5. Uniqueness, inside the insert transaction. Failure: `kind: "unique_conflict"` with `conflicts`.
25
+
26
+ The batch route reports steps 2, 4 and 5 as `code: "UNIQUE_CONFLICT"` or `"VALIDATION"`.
27
+
28
+ Rule: read `errors` by field key, fix exactly those fields, write again once.
29
+
30
+ `update_content_entry` merges `data` into the stored value by top-level key: a field left out is
31
+ kept, and a group or repeater you send still replaces that whole key. `merge: false` REPLACES the
32
+ whole value object instead, deleting every optional field left out. Pass `ifMatch` (the
33
+ `optimisticVersion` from `get_content_entry`) so a 409 tells you someone wrote since your read; a
34
+ merge defaults to the version it read.
35
+
36
+ ## Value contract
37
+
38
+ What to send. "Checked" means a bad value is refused; "yours" means the server stores what it gets.
39
+
40
+ | Value | Send | Server |
41
+ |---|---|---|
42
+ | No value | Leave the key out. Never `null`, never `""` on an optional field | Yours. `null` skips every type check and is stored as written; `""` is stored as text. Both count as empty for `required` |
43
+ | No value, SEO writers | Send only the keys you change | `null` or `""` CLEARS the stored key ([seo.md](seo.md) §1) |
44
+ | Number | A JSON number: `29`, `39.99`. Never `"29"` | Checked: a string or a non-finite number is refused |
45
+ | Money | A `number` field in major units (`39.99`, not `3999` unless the source API is in cents) plus a sibling `select` holding the ISO 4217 code. There is no money type | Number checked; unit and currency are yours |
46
+ | Price label (`"$39/mo"`) | A separate text field, only when the page prints that exact string | Yours. Keep it in step with the number |
47
+ | `date`, date only | `YYYY-MM-DD` | Checked: a real calendar day, no time part |
48
+ | `date` with `includeTime: true`, `datetime` | ISO 8601 with seconds and an offset: `2026-10-14T09:00:00+05:30`, or `Z` for UTC | Time part checked. On a field with `timezone` in its config (`site`, `UTC` or an IANA name) a value without `Z` or an offset is refused. Without it the offset is accepted but not required, so a value without one is stored ambiguous: always send it |
49
+ | `select` | One of the declared option values | Checked |
50
+ | Plain text (`text`, `longtext`) | Words only. No tags, no entities, no markdown | Yours. Markup is stored and printed as written |
51
+ | Formatted prose | A Portable Text array in a rich text field ([portable-text.md](portable-text.md)) | Top-level `_type` checked |
52
+ | A list of things | A `repeater` or an `array`, never `<ul>` or line breaks in one string | Shape checked |
53
+ | A link, a button, an image | Their own fields, never `<a>` or `<img>` in a string | Per field type |
54
+
55
+ Booleans: send `false` explicitly when a condition (`showIf`) or the template reads the field.
56
+
57
+ ### Stable keys
58
+
59
+ Keys are edit addresses: the canvas and co-editing point at them. Regenerating one
60
+ orphans whatever pointed at it.
61
+
62
+ | Where | Key | Rule |
63
+ |---|---|---|
64
+ | Portable Text block, span, markDef, table row and cell | `_key` | Matches `^[A-Za-z0-9-]{1,32}$` (no underscore, no dot: the address grammar splits on them), unique in the value. Write short meaningful keys (`intro`, `q-shipping`), never a position |
65
+ | Repeater row | `_id` | Minted by the server on the first write when you send none. An `_id` you send is kept as is |
66
+ | Entry, component, page | id and slug | Never recreate to change content: update in place |
67
+
68
+ Rules:
69
+
70
+ - To change a keyed value, read it, edit the items you mean to change, and send every other item
71
+ back with the key it came with.
72
+ - A repeater row sent without `_id` on a later write gets a NEW one: the MCP and Management API
73
+ write path does not match rows to the stored ones. Always echo `_id` from the read.
74
+ - On a re-run of a load, match each source item to its stored item by content and reuse that key.
75
+ - `array` items (scalars) have no key. Their address is the index, so do not reorder them for style.
76
+
77
+ ## Dry run
78
+
79
+ `create_content_entry`, `create_content_entries`, `create_content_model`, `add_field`,
80
+ `compose_pages`, `create_redirect` and `import_redirects` take `dryRun: true`. The call runs every
81
+ check a real one runs and writes nothing:
82
+ no row, no ingested image, no webhook, no rebuild. The answer carries `dryRun: true` and, per item,
83
+ `would-create` (`would-update` for a composed page) or the refusal a real run gives. A batch dry run
84
+ also catches a slug repeated inside the batch; a repeated `unique` value shows only on the real run.
85
+
86
+ Rule: dry-run a bulk write first, fix the failed rows, then send it again without `dryRun`.
87
+
88
+ ## Safe re-runs
89
+
90
+ A load that stops halfway is run again. Write it so the second run changes nothing it already did.
91
+
92
+ `create_content_entries` rows and `create_content_entry` take `externalKey` (1 to 255 characters):
93
+ the item's id in the system it comes from. The key is unique per collection. Each keyed row answers:
94
+
95
+ | Status | Meaning |
96
+ |---|---|
97
+ | `created` | No entry had the key. A new draft is stored under it |
98
+ | `unchanged` | The entry exists and the row equals the one last sent. Nothing is written |
99
+ | `updated` | The entry exists and the row differs. `data` replaces its draft; a published entry stays published with pending changes |
100
+ | `failed` | The refusal a plain create or update gives (`FIELD_LOCKED`, a taken slug, a validation error), or the key repeated inside the batch |
101
+
102
+ "Equals" compares a hash of the `slug` and `data` you send, not the stored values (image URLs are
103
+ re-hosted on the way in), so send the same payload to get `unchanged`. With `dryRun: true` the
104
+ statuses are `would-create`, `would-update`, `unchanged` and `failed`. Optional `source` (the source
105
+ URL) and `runId` are kept with the entry. A row without `externalKey` always creates.
106
+
107
+ `list_content_entries` takes `externalKey` (exact match) and its summaries carry the key, so a
108
+ stopped run resumes by reading what is already stored. Only entries created with a key match.
109
+
110
+ Rule: give every migrated row its source id as `externalKey`, then re-run the whole batch freely.
111
+ Entries then need no local id map. Media, and a reference to an item not yet created, still do.
112
+
113
+ What has no `externalKey` (models, components, pages, redirects, repeater rows, Portable Text):
114
+
115
+ - Reuse stable identifiers: the same slug per entry, the same `_key` and `_id` per item (see Stable
116
+ keys), the same component id. Read the stored row and match by content before you mint anything.
117
+ - Skip a write whose source did not change. Keep a content hash per source page and asset.
118
+ - These answers mean "already done". Record them as success and move on:
119
+
120
+ | Answer | From | Why it is success |
121
+ |---|---|---|
122
+ | `ALREADY_COMPONENTIZED` | `componentize_sections` | The group is already placed as a component; nothing is written |
123
+ | `existing` on a row | `import_redirects` | The same rule is already stored |
124
+ | 409 `slug_taken` on a row you created earlier in this load | entry or model create without `externalKey` | Switch to update for that row |
125
+ | 409 `EXTERNAL_KEY_TAKEN` | a keyed entry create that raced another write | Send the row again: it matches that entry |
126
+
127
+ - A `create_redirect` 409 means a rule for that source exists, identical or not: the tool does
128
+ not tell the two apart. Read it with `list_redirects` and decide. On a re-run use
129
+ `import_redirects`, which answers `existing` for a rule already stored as sent.
130
+ - Batch writes report each row. One failed row does not stop the rest: record the failures, fix
131
+ them, re-run only those rows.
132
+ - Before a bulk load, write ONE entry per collection, read it back, open its page, then load the
133
+ rest. A wrong shape costs one entry instead of hundreds.
134
+ - Create reference targets before the entries that point at them, or the write is refused with
135
+ `ref_missing`.
136
+
137
+ ## Limits that exist
138
+
139
+ | Limit | Behaviour |
140
+ |---|---|
141
+ | `maxLength` / `minLength` in text field config | Counted in characters. An empty value is left to `required` |
142
+ | `pattern` in text field config | Tested against every non-empty value |
143
+ | Numeric limit config (`maxLength`, `minLength`, `min`, `max`, `step`) on model save | A numeric string (`"60"`) is stored as a number; an impossible value (`"abc"`, a negative length, `step: 0`, `min` above `max`, a pattern that does not compile) is refused with `FIELD_CONFIG_INVALID`, naming the field and the fix |
144
+ | Repeater `maxItems` | Enforced when a number |
145
+ | `select` value | Must be one of the field's options |
146
+ | Rich text top-level `_type` | A name nothing renders is refused, naming `_type` and `_key`. Top level only — a foreign mark keeps its text unmarked, a foreign inline child renders nothing |
147
+ | Reference target | Each referenced entry id must exist in the workspace: 400 `ref_missing`, keyed by field. A reference chain that loops back to the entry is refused with 400 and `errors._base` |
148
+ | Request body | 260 MB for the whole server |
149
+
150
+ ## Limits that do not exist (you hold them)
151
+
152
+ | Missing check | Rule for the agent |
153
+ |---|---|
154
+ | Limit config saved as a string before the model-save check existed | Write every numeric config value as a JSON number; re-save a model you find with string limits |
155
+ | Modular (block) field `blockSlugs`, `minItems`, `maxItems` | Check the block list against the field config yourself before writing |
156
+ | A reference to a trashed entry (stored, with a warning) | Read `warnings` after the write; repoint or restore the target |
157
+ | Markup inside plain text; a datetime with no offset; `null` on an optional field | Follow the value contract above |
158
+ | Component `overrides` (any keys, any types; required props not enforced) | Write only declared prop keys with the declared type; fill every prop the component needs |
159
+ | Size or depth of text, richtext and JSON values | Keep values to what the page renders; never paste whole documents into one field |
160
+ | Publish re-validation (publish copies the draft to live) | Only publish a draft you wrote through the write path above |
161
+ | Version restore re-validation (restore writes the stored snapshot) | After a restore, read the entry and write it back through `update_content_entry` so checks run |
162
+
163
+ ## Normaliser output shapes
164
+
165
+ What you read back after a write:
166
+
167
+ | Field | Stored shape |
168
+ |---|---|
169
+ | richtext | `{ format, value, html }` envelope |
170
+ | image | `{ id, url, name, altText }`. An asset id or media URL resolves to its asset; a site-relative path resolves to the asset the build adopted; an external URL on a project-scoped write is fetched and stored as a new asset. Only when none of that works (fetch refused or failed, no project scope) is it kept as `{ url }` (plus `altText` if given) |
171
+ | group / repeater (zoned) | Zone envelopes `{ nonRepeatable, repeatable }`, walked field by field |
172
+ | repeater given a bare array | Wrapped as `{ repeatable: [...] }` |
173
+
174
+ Rich text written as HTML or markdown may be stored in the older Lexical format depending on a
175
+ server flag. Portable Text arrays are always stored as Portable Text
176
+ ([portable-text.md](portable-text.md)).
177
+
178
+ A stored value of `{ html }` is the shape starter templates and `bcms-content.json` seed. Writes
179
+ accept it and store the full envelope, so sending an entry's data back unchanged works. Do not
180
+ write new rich text in that shape.
181
+
182
+ ## Other refusal codes that exist today
183
+
184
+ | Area | Codes | Where described |
185
+ |---|---|---|
186
+ | Content sidebar structure | `NAV_SYSTEM_FOLDER`, `NAV_TYPE_MISMATCH`, `NAV_KIND_MISMATCH`, `NAV_PAGE_BOUND_MODEL`, `SCHEMA_ACCESS_REQUIRED` | the folder tool descriptions |
187
+ | Hosting | `HEADLESS_NO_BUILD`, `HEADLESS_NEEDS_CUSTOM_HOST`, `HEADLESS_ARTIFACT_TARGET` | [frameworks.md](frameworks.md) |
188
+ | Components | `COMPONENT_*` publish codes | [components.md](components.md) §4 |
189
+ | Content models | `FIELD_CONFIG_INVALID` | this file |
190
+ | Entries | `FIELD_LOCKED` (403: the write changes or omits a `readOnly: "developer"` field without schema access; carries `field` and `lockReason`), `EXTERNAL_KEY_TAKEN` (409) | [content-modeling.md](content-modeling.md) §4, Safe re-runs above |
191
+ | Forms | `FORM_FIELD_INVALID`, `FORM_DEFINITION_INVALID`, `FORMS_WORKER_NOT_CONFIGURED`, `FORMS_WORKER_SECRET_UNSET`, `FORM_SUBMISSION_NOT_FOUND`, `FORM_SECRET_DASHBOARD_ONLY` | [forms-and-interactions.md](forms-and-interactions.md) |
192
+
193
+ `SCHEMA_ACCESS_REQUIRED` (403): see the owner boundary in SKILL.md.
194
+
195
+ ## What each refusal means and what to do next
196
+
197
+ | Answer | Meaning | Next step |
198
+ |---|---|---|
199
+ | 400 `{ errors }`, no code | A value failed the request schema or a field check | Fix exactly the named fields, write once more |
200
+ | 400 `ref_missing` | A reference field names an entry id that is not in this workspace. `errors` is keyed by the field | Create or find the target first, then write the real id. Never invent an id |
201
+ | 400 `FIELD_CONFIG_INVALID` | A model or component save carries an impossible setting: a limit that is not a number, `min` above `max`, `step: 0`, a pattern that does not compile, a prop `defaultValue` of the wrong type. The message names the field and the setting | Send a value that makes sense for that setting |
202
+ | 400 `PROJECT_REQUIRED` | A delivery read with no project scope hit a slug that exists in more than one project of the workspace | Add `?projectId=` to the read, or use a project-scoped API key |
203
+ | 409 `slug_taken` | The slug is in use. Entries: `slug` plus `suggestions`. Models: `code`, `suggestions` and `errors.slug` | Read the existing row first: on a re-run it is usually yours, so update it. Otherwise take one of `suggestions` |
204
+ | 409 `EXTERNAL_KEY_TAKEN` | Another write created an entry with the same `externalKey` at the same moment | Send the row again: it matches that entry and answers `updated` or `unchanged` |
205
+ | 403 `FIELD_LOCKED` | The write changes a `readOnly: "developer"` field and the caller has no schema access. Carries `field` and `lockReason` | Send the stored value back for that field, or ask an Admin or Developer to change it |
206
+ | 403 `SCHEMA_ACCESS_REQUIRED` | A model save adds or removes a lock (`readOnly: "developer"`, `lockReason`) without schema access | Send the fields with their stored lock, or ask an Admin or Developer |
207
+ | 409 `unique_conflict` | Another entry already holds this value in a unique field. `conflicts` names it | Match to that entry and update it; do not create a twin |
208
+ | 409 `locale_conflict` | The translation group already has an entry in that locale. `errors` names `locale` or `translationOf` | Edit the existing translation |
209
+ | 409 `Version conflict` (entries), `VERSION_CONFLICT` (sidebar, section zones) | Someone wrote since your read. The entry body carries `currentVersion` | Read again, re-apply your change to the fresh value, write with the new version |
210
+ | 409 `stale-plan` | The site changed after the componentize plan was read; `digest` is the current one | `get_componentize_plan` again, re-confirm, resubmit with the new digest |
211
+ | 409 `style-edits` | The conversion receipt lists changed classes or styles. `styleEdits` holds up to 40 tokens | Restore every token, rerun `npx @bettercms-ai/convert --verify-styles`, resubmit the receipt |
212
+ | 400 `digest-mismatch` | The receipt was built for a different brief | Read the brief again and rebuild the receipt against it |
213
+ | 422 `PRESENTATION_INVALID` | `update_project` got a `presentation` value the renderer cannot use. `error` names the key | Fix that key; other settings in the same call were not saved |
214
+ | Warning `UNPROPPED_COPY` | A saved component holds copy no prop targets, so it is not editable. The line lists up to 8 leaves as `<blockType>#<blockId>.<path>` | Declare a prop with a matching `target` for each leaf. The save itself succeeded |
215
+ | Warning `REFERENCE_NOT_FOUND` | A reference points at a trashed entry. Stored anyway | Repoint the field or restore the target |
216
+
217
+ Component, form, hosting and sidebar codes: the table above.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettercms-ai/mcp",
3
- "version": "0.83.0",
3
+ "version": "0.83.2",
4
4
  "type": "module",
5
5
  "description": "BetterCMS MCP server — schema, content, forms + components authoring tools over the Management API, authed via OAuth device-code.",
6
6
  "bin": {