MCP Server Guide
How AI clients such as Claude Code read and edit pages through the CMS MCP server — API key scopes, connecting a client, and the full tool reference.
The CMS exposes a Model Context Protocol (MCP) server that lets AI clients — such as Claude Code — read and edit pages programmatically through the same permission model as the REST API.
Prerequisites
- Open the visual editor after every deploy of your tenant app that adds or changes blocks, fields, or layouts. The CMS works from a stored snapshot of your block and layout registries, and that snapshot is refreshed only when someone opens a page in the visual editor — never by a deploy, and never by an MCP call. The
whoamitool'sblockRegistryfield shows the snapshot'sblockCountandschemaSyncedAt; if it isnullthe registry has never been synced and the block tools refuse withREGISTRY_NOT_SYNCED. See Block registry sync. - An API key with the required scopes (see below).
Creating an API key
Go to Settings → API Keys in the dashboard and create a key. Select the scopes that match the operations you need:
| Scope | What it grants |
|---|---|
mcp:connect | Required — lets the key authenticate to the MCP endpoint |
admin:pages:read | search_pages, get_page, validate_page, list_block_types, get_block_schema, list_collections, suggest_internal_links, list_layouts, get_layout_schema, get_layout, list_layout_menus |
admin:pages:write | create_page, delete_page, update_page, add_block, update_block_props, move_block, remove_block, update_seo, update_collection_data, create_collection, add_collection_field, set_layout_field, add_nav_item, update_nav_item, move_nav_item, remove_nav_item |
admin:pages:publish | publish_page, unpublish_page, publish_layout |
admin:assets:read | search_assets |
admin:assets:write | upload_asset_from_url, delete_asset, update_asset |
admin:data-models:read | list_data_models, get_data_model, list_data_model_records, get_data_model_record |
admin:data-models:write | create_data_model_record, update_data_model_record |
A key that can read, write, and publish pages needs all four scopes: mcp:connect, admin:pages:read, admin:pages:write, admin:pages:publish.
delete_asset and update_asset need admin:assets:read as well as admin:assets:write. Both require an expectedUpdatedAt token, and search_assets — an admin:assets:read tool — is the only MCP read that returns one, so a write-only key can never obtain a token and can never call either tool.
Endpoint URL
https://<your-host>/api/mcp/mcp
The path ends in /mcp twice — the first segment is the route group, the second is the streamable-HTTP transport discriminator.
Connecting from Claude Code
claude mcp add --transport http glokki https://<your-host>/api/mcp/mcp \
--header "Authorization: Bearer ek_live_…"
Replace <your-host> with your tenant's hostname (e.g. cms.example.com) and ek_live_… with the plain-text key shown once at creation time.
After adding, verify the connection:
/mcp
Claude Code will call whoami and display the tenant slug.
Errors and rate limits
The endpoint answers a refused request with the real reason, not a generic 401. A missing/bad bearer token, a missing mcp:connect scope, an exhausted call budget, and a backend outage are four different failures and get four different responses:
| Status | When | Body | Notable headers |
|---|---|---|---|
401 | No Authorization header, or the bearer token is invalid, revoked, or expired | {"error":"invalid_token","error_description":"…"} — OAuth-shaped, wire-compatible with the MCP client's own auth-discovery flow | WWW-Authenticate: Bearer error="invalid_token", …, pointing at /.well-known/oauth-protected-resource |
403 | The key is valid but lacks the mcp:connect scope needed to open the connection at all | {"error":{"code":"INSUFFICIENT_SCOPE","message":"…","details":{"requiredScope":"mcp:connect"}}} | — |
429 | The mcp:call budget — tenant-wide or this key's share — is exhausted | {"error":{"code":"RATE_LIMITED","message":"…","details":{"retryAfter":<seconds>,"scope":"tenant"|"api_key","category":"mcp:call"}}} | Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset |
503 | Authorizing the request itself failed unexpectedly — a database or Redis outage, or a bug in the auth path | {"error":{"code":"MCP_UNAVAILABLE" (or a DB error code),"message":"…","details":{"retryAfter":5}}} | Retry-After: 5, Cache-Control: no-store |
A missing scope for an individual tool (e.g. calling publish_page without admin:pages:publish) never reaches this table — the connection is already open, so the refusal comes back as a normal MCP tool result with isError: true and HTTP 200 instead, the same shape as the publish-budget refusal below.
On 429 and 503, read Retry-After and back off — do not re-handshake. Both mean "you're fine, slow down" (over budget, or a backend struggling), not "your credentials are bad," so re-authenticating only adds connection load to a system that's already throttled or degraded. If your client treats any non-2xx as a cue to re-handshake and retry, carve 429 and 503 out of that path explicitly.
Rate limits
| Category | Tenant limit | Per-key limit | Enforced |
|---|---|---|---|
mcp:call | 300 requests / 60s | 150 requests / 60s | Every request to the endpoint (handshake and tool calls), before any tool runs. Refusal is the transport 429 above. |
mcp:publish | 20 calls / 60s | 10 calls / 60s | publish_page, unpublish_page, and publish_layout only, after the scope check. Refusal is an MCP tool error, not a transport 429 — see below. |
The per-key figure is half the tenant figure (rounded up) — one key can't consume the whole tenant budget, but many keys together are still capped at the tenant number.
A publish_page/unpublish_page call is charged against both budgets: the HTTP request counts toward mcp:call, and the tool counts again toward mcp:publish. They're different resources — request volume vs. side-effect volume (CDN purge, SEO audit, webhooks) — so a client can be well within its call budget and still get throttled on publishing.
The publish refusal looks different
mcp:publish is enforced inside the tool handler, not in the preflight, so exhausting it never produces an HTTP 429. It comes back as an ordinary MCP result:
{
"isError": true,
"content": [
{
"type": "text",
"text": "{\"code\":\"RATE_LIMITED\",\"message\":\"Publish limit reached (20/min). Retry after 7s.\",\"details\":{\"retryAfterSeconds\":7,\"category\":\"mcp:publish\"}}"
}
]
}
HTTP status is 200 — the request was understood and answered, and the answer is "not now." A client that checks only the HTTP status code will read this as success. Check isError on every tool result, and read details.retryAfterSeconds (inside the JSON-encoded content[0].text) the same way you'd read Retry-After on a 429.
Two codes arrive this way, not one. Both carry details.retryAfterSeconds and both mean wait, then retry:
code | When | What it means |
|---|---|---|
RATE_LIMITED | The mcp:publish budget is exhausted | You published faster than the budget allows. |
RATE_LIMIT_UNAVAILABLE | The limiter itself is unreachable (e.g. Redis is down) | Nothing is wrong with your call. Publishing fails closed while the budget can't be counted — an unmetered publish tool is exactly the write storm the budget exists to prevent. |
RATE_LIMIT_UNAVAILABLE is the tool-level equivalent of the transport 503: transient, retryable, not a reason to re-handshake and not a reason to give up. A client that only recognises RATE_LIMITED will treat a limiter outage as a hard failure and abandon work that would have succeeded seconds later.
Tenant overrides apply to mcp:publish, not mcp:call
A tenant can override its rate limits from the super-admin settings screen. That override reaches mcp:publish — an operator running a bulk import can raise it — but deliberately not mcp:call. mcp:publish is the scarce, side-effect-heavy resource an operator has a real reason to tune per tenant; mcp:call is transport-level infrastructure, checked in the preflight before any tenant-specific work happens, on every request including ones that turn out to be refused (bad key, missing scope). Wiring an override into that path would cost a tenant-config lookup on every MCP request — successful or not — to serve a knob nobody needs on the cheap path. This is a decision, not a gap: if a call-budget override doesn't seem to take effect, that's why.
Available tools
| Tool | Required scope | Description |
|---|---|---|
whoami | mcp:connect | Returns tenant info and the block-registry snapshot's blockCount and schemaSyncedAt (or null if never synced). |
list_block_types | admin:pages:read | Lists all registered block types (type, label, category, slots). |
get_block_schema | admin:pages:read | Returns field descriptors and default props for one block type. Use before add_block. |
list_layouts | admin:pages:read | Lists the page layouts this tenant's app defines, with the freshness of the server-side layout-schema mirror. |
get_layout_schema | admin:pages:read | Returns the field descriptors for one section (header or footer) of one layout. |
get_layout | admin:pages:read | Returns the stored draft and published data (each /cdn/-enriched) for one section of a layout, and whether the draft has unpublished changes. |
list_layout_menus | admin:pages:read | Lists every menu-tree field in one section of a layout as a fieldPath — e.g. drawerSections.0.items for a menu nested inside a repeater row — addressable by add_nav_item, update_nav_item, move_nav_item, and remove_nav_item. |
set_layout_field | admin:pages:write | Sets one field inside a layout section (header or footer) by dotted fieldPath — a scalar, a repeater element, or a whole menu-tree array. Requires expectedUpdatedAt and a value (an omitted value is refused, not treated as clearing the field); setting a menu-tree field replaces the whole array while preserving each node's existing _id. See Layout writes and navigation. Returns the new draftUpdatedAt. |
add_nav_item | admin:pages:write | Inserts a new item into a menu-tree field addressed by fieldPath, under parentId (or the root) at index. Any _id on node is ignored — a fresh one is always minted. Refuses an unknown parentId with PARENT_NOT_FOUND and a resulting depth past the field's maxDepth with MAX_DEPTH_EXCEEDED. See Layout writes and navigation. Returns the new node's itemId and the new draftUpdatedAt. |
update_nav_item | admin:pages:write | Merges patch into one existing navigation item, addressed by itemId. _id and children in patch are ignored. Refuses an unknown itemId with NODE_NOT_FOUND. See Layout writes and navigation. Returns itemId and the new draftUpdatedAt. |
move_nav_item | admin:pages:write | Re-parents/reorders an existing navigation item, addressed by itemId, to a new parentId (or the root) and index. Refuses nesting under itself or one of its own descendants with CYCLIC_MOVE, an unknown itemId with NODE_NOT_FOUND, and a resulting depth past maxDepth with MAX_DEPTH_EXCEEDED. See Layout writes and navigation. Returns itemId and the new draftUpdatedAt. |
remove_nav_item | admin:pages:write | Deletes a navigation item and its whole subtree, addressed by itemId. Refuses an unknown itemId with NODE_NOT_FOUND. See Layout writes and navigation. Returns the removed itemId and the new draftUpdatedAt. |
search_pages | admin:pages:read | Searches/filters pages (status, text, locale) with pagination. Returns list rows — no component trees. |
get_page | admin:pages:read | Returns a full page: componentTree with node IDs, SEO, and the updatedAt token needed for edit operations. |
suggest_internal_links | admin:pages:read | Semantic internal-link suggestions (OpenAI embeddings). Pass pageId or text + locale; returns outbound targets and, for a page, inbound candidates with path, score and alreadyLinked. Needs the Internal links scan to have run once (indexed: false otherwise) and the ai-internal-links feature. |
create_page | admin:pages:write | Creates a new draft page. Slug must start with /. Returns the new page ID and its updatedAt token. |
delete_page | admin:pages:write | Permanently deletes a page. Requires expectedUpdatedAt (from get_page). A live page cannot be deleted, regardless of status — unpublish or archive it first. See Deleting and renaming pages. |
update_page | admin:pages:write | Renames a page and/or changes its slug. Requires expectedUpdatedAt. A title change reverts a published page to draft; a slug change does not. See Deleting and renaming pages. |
validate_page | admin:pages:read | Dry-runs block validation for a page. Returns dotted error paths so you can fix props before publishing. |
add_block | admin:pages:write | Inserts a new block. Server seeds defaultProps and generates the node ID. Returns the node ID and the new updatedAt. |
update_block_props | admin:pages:write | Patches a block's props by node ID with a shallow merge, then re-validates that block. A key cannot be removed — null is stored as a value. See Patching block props. Returns the new updatedAt. |
move_block | admin:pages:write | Re-parents or reorders a block by node ID. Returns the new updatedAt. |
remove_block | admin:pages:write | Deletes a block and its entire subtree by node ID. Returns the new updatedAt. |
update_seo | admin:pages:write | Sets a page's SEO metadata (meta title/description, canonical, robots, Open Graph). Requires expectedUpdatedAt; returns the new updatedAt. |
list_collections | admin:pages:read | Lists collections with custom field schemas, urlPrefix, layoutLocked, and entry counts. Call before creating entries. |
create_collection | admin:pages:write | Creates a new collection. Slug and urlPrefix must be unique and urlPrefix must not be a reserved route, and must not already contain standalone pages (a page at the prefix itself is fine — that is the listing page). Returns the new collection's id, name, slug, and urlPrefix. |
add_collection_field | admin:pages:write | Adds one new field to an existing collection's schema. required has no default — state it explicitly. A field with a defaultValue/defaultNow is backfilled into every existing entry; a collection above 10,000 entries is refused with COLLECTION_TOO_LARGE_TO_MIGRATE rather than migrated. Refuses with COLLECTION_FIELD_EXISTS for a duplicate key, COLLECTION_TOO_MANY_FIELDS at the 50-field cap, COLLECTION_REQUIRED_FIELD_NEEDS_DEFAULT for a required field with no valid default on a non-empty collection, and (retryable) STALE_COLLECTION on a concurrent write — see Collections. Returns the collection id, the field key, and the new total field count. |
update_collection_data | admin:pages:write | Patch-merges a collection entry's custom field values (validated against the collection schema). Requires expectedUpdatedAt; returns the new updatedAt. |
search_assets | admin:assets:read | Lists/searches media assets. Each row carries url (for block image props), storageKey, size, width/height, folderPath and updatedAt (the token delete_asset/update_asset require); the asset id is the value for collection image/file fields. |
upload_asset_from_url | admin:assets:write | Fetches a public https URL server-side into the media library (SSRF-guarded, importKey-idempotent, content-hash deduped). Returns assetId, url, storageKey, folderPath and dedupedBy. |
delete_asset | admin:assets:write | Permanently deletes an asset and its variants. Requires expectedUpdatedAt (from search_assets). Refuses with ASSET_IN_USE when anything still references it, listing the pages/entries in the error's details.usages — there is no force option. |
update_asset | admin:assets:write | Renames an asset, sets its alt text, and/or moves it to a different folder (creating the folder tree if needed; pass folderPath: null to move to the root). Requires expectedUpdatedAt (from search_assets); renaming does not change the asset's URL. |
list_data_models | admin:data-models:read | Lists the tenant's data models — the reference data blocks point at (authors, venues, service hours). Returns each model's key, name, and display field. Call get_data_model for a model's field schema before writing records. |
get_data_model | admin:data-models:read | Returns one data model's field schema (fieldsSchema), display field, and description. Read this before create_data_model_record or update_data_model_record: record data is validated against this schema, so guessing field names fails the write. |
list_data_model_records | admin:data-models:read | Lists a data model's records, newest first, with optional text search. Returns records and a total count; each record carries an updatedAt token to pass to update_data_model_record. |
get_data_model_record | admin:data-models:read | Fetches one record of a data model by its id or by its stable key. Returns the record's data plus the updatedAt token required by update_data_model_record. |
create_data_model_record | admin:data-models:write | Adds one record to a data model. data is validated against the model's field schema (see get_data_model). key is an optional stable identifier, lowercase letters, digits, and hyphens, unique within the model. Returns the new record and its updatedAt token. |
update_data_model_record | admin:data-models:write | Replaces a record's data (and optionally its key). Pass expectedUpdatedAt from get_data_model_record or a prior edit; a mismatch returns STALE_RECORD so you can re-read rather than overwrite someone's change. Returns the updated record and its new updatedAt token. |
publish_page | admin:pages:publish | Publishes a page. Runs the full block-validation gate — use validate_page first if unsure. Returns the new updatedAt. |
unpublish_page | admin:pages:publish | Returns a published page to draft. Returns the new updatedAt. |
publish_layout | admin:pages:publish | Publishes a layout's draft header/footer to production. Promoting an unchanged draft is not an error: promoted comes back false and no CDN purge fires. Counts against the mcp:publish budget, the same as publish_page/unpublish_page. See Layout writes and navigation. |
Linking a new page
create_page→add_block/update_block_propswith the content.suggest_internal_linkswith the newpageId— the draft text is embedded on the fly and compared with every published page in the same locale.- For each
outboundsuggestion, insert a link withupdate_block_props(rich-textcontentprops take an<a href="{path}">, URL props takepathdirectly). publish_page. Publishing re-indexes the page, so the next call sees it as a target for other pages.
inbound lists pages that should link to your page — useful when the new page would otherwise be an orphan.
The expectedUpdatedAt edit loop
All write operations that modify an existing page require an expectedUpdatedAt token — including update_seo. This token is an optimistic-locking guard: if another agent or the dashboard saves the page between your read and your write, the server rejects the request with a STALE_PAGE conflict error.
Every successful edit response carries the page's new updatedAt, so you can chain edits without an extra get_page between writes:
1. create_page(...) → note the returned `updatedAt`
(or get_page(pageId) for an existing page)
2. add_block / update_block_props / move_block / remove_block / update_seo
with expectedUpdatedAt = <updatedAt from the previous step>
3. the edit response carries a fresh `updatedAt` → use it for the next edit
4. on a STALE_PAGE conflict, call get_page again and retry from its token
Never reuse a stale updatedAt across multiple writes; always use the value returned by the most recent read or edit.
Editing a published page reverts it to draft
Any content write to a page whose status is published (or approved) sets its status to draft: add_block, update_block_props, move_block, remove_block, update_seo, update_collection_data, and update_page with a title. (A slug-only update_page is the one write that doesn't.) A write that leaves the content exactly as stored — the same props, SEO, collection data or title — changes nothing: the status stays, no version is recorded and updatedAt does not move. The page is not taken offline — the last published version keeps being served at its slug — but your edits are not live until you call publish_page, and the dashboard now lists the page as a draft. The block and SEO tools return only updatedAt, not the new status, so don't infer liveness from a write's response: when a batch of edits to a live page is done, publish it.
Patching block props
update_block_props merges propsPatch into the block's existing props one level deep: { ...props, ...propsPatch }. Two consequences:
- Arrays and objects are replaced, not merged. To change one item of a list prop (an FAQ entry, a repeater row), read the block with
get_page, edit the array, and send the whole array back. - A key cannot be removed. There is no "unset" operation, and a key you omit is simply left as it was. Sending
nullfor an optional field is accepted, butnullis stored as the value — it does not delete the key or restore the default. The block component then receivesnull(notundefined), so a default parameter or a schema.default(...)does not kick in, and a component that isn't written to handlenullmay render the literal empty state or fail.nullon a required field is rejected withBLOCK_VALIDATION_FAILED.
To "clear" a field, write its empty value of the right type instead — "" for text, [] for a list, false for a switch — or the field's default from get_block_schema's defaultProps. To get rid of a stray key entirely, remove the block and add it again.
Deleting and renaming pages
Both delete_page and update_page require expectedUpdatedAt — get it from get_page, same as any other edit — and a page's locale cannot be changed after creation by either tool.
delete_page
delete_page(pageId, expectedUpdatedAt) permanently deletes a page; this cannot be undone.
A live page cannot be deleted at all. The server refuses with CANNOT_DELETE_PUBLISHED (422) whenever the page still has a published version, regardless of how you call it and regardless of status. That distinction matters: any edit to a published page (including update_page itself) reverts its status to draft without clearing what was published, so the page keeps being served live at its slug even though it now reads as a draft — and delete_page still refuses it. To make such a page deletable, take it offline first: unpublish_page works even when status is already draft (it clears the live pointer either way), or archive it from published. Once nothing is live, delete_page succeeds and there is nothing left to purge.
update_page
update_page(pageId, expectedUpdatedAt, title?, slug?) takes a title, a slug, or both. The two fields route through different service logic and behave oppositely with respect to a published page:
- A slug change enforces the collection's
urlPrefixwhen the page is a collection entry and propagates the new slug to the published version — so a published page stays live, just under a new URL. It also tries to create a redirect from the old slug, but this is not guaranteed:updateSlugsilently skips it when another page (in any locale) still uses the old slug, or when a redirect from that source already exists. The response'sredirectCreatedfield tells you whether one was actually made — check it instead of assuming an old link now redirects, or an indexed URL will start 404ing. - A title change reverts a published page to draft, the same as editing any other content on it. There is no separate warning for this — the response's
statusfield is the only signal, so always check it after callingupdate_pagewith atitle.
If you pass both title and slug in one call, the title is written first, then the slug. That's not because updateSlug needs to read the stored slug — a title-only write never touches the slug, so updateSlug would see the same original value either order — it's so the slug write's result, including the fresh updatedAt and whether a redirect actually landed, is what the response reflects.
Collections
Collection entries are pages that belong to a collection and carry structured custom fields (collectionData).
Collections can be created and extended through MCP, but never updated or deleted — there is no update_collection, no delete_collection, and no way to change or remove an existing field once it has been added. This is a deliberate design decision: an agent sending a short fieldsSchema array to replace the schema would silently delete fields and take the meaning of existing entry data with them.
Prefer passing the complete fieldsSchema to create_collection when creating a new collection, rather than building the schema up afterward with repeated add_collection_field calls — add_collection_field is for extending a collection that already exists. Building a schema one field at a time is the riskier path: it can run into the 50-field cap partway through, and it races. MCP clients may dispatch tool calls concurrently, and two add_collection_field calls against the same collection do not both land — the loser is refused with STALE_COLLECTION (see below) rather than silently dropping a field. So add_collection_field calls against the same collection must be issued sequentially, never in parallel; on a STALE_COLLECTION refusal, re-read the collection and retry.
create_collection→ name, slug,urlPrefix, and — preferably — the completefieldsSchemafor the collection in the same call. Slug andurlPrefixmust each be unique across the tenant;COLLECTION_SLUG_TAKENandCOLLECTION_URL_PREFIX_TAKENare distinct refusals, so a retrying agent knows which value to change. SomeurlPrefixvalues are reserved —/api, the dashboard routes, the admin routes and the auth routes — and are refused independently of any conflict with another collection.add_collection_field→ adds one new field to an existing collection's schema, for a collection that wasn't given its full schema up front.requiredhas no default and must be stated explicitly on every new field.COLLECTION_FIELD_EXISTSmeans that key is already taken. A field carrying adefaultValueordefaultNowis backfilled into every existing entry and into stored page versions; a collection above 10,000 entries is refused withCOLLECTION_TOO_LARGE_TO_MIGRATErather than migrated inline — the guard isentryCount > MIGRATION_MAX_ENTRIES, so a collection with exactly 10,000 entries is not refused, only one with more. Three more refusals guard this call:COLLECTION_TOO_MANY_FIELDS(422) — the collection already holds 50 fields (MAX_COLLECTION_FIELDS). Refused before any write is attempted.COLLECTION_REQUIRED_FIELD_NEEDS_DEFAULT(422) — thrown only when all three hold: the new field isrequired: true; its effective default (defaultNowon a date/datetime field resolves to the current date/datetime, otherwisedefaultValue) is either absent or fails the same validator existing entries are judged by; and the collection already has at least one entry. An empty collection always accepts the field — there is nothing to backfill yet. In practice: a requiredurldefault must be a real URL (""is refused), a requiredmultiselectdefault must select at least one option ([]is refused),validation.min/max/patternapply to the default the same as to any entry value, andimage/file/relation/dataModelRefdefaults must be valid UUIDs.defaultNowalways passes. AdataModelRefsfield has no default — its stored value is always an array of record UUIDs, filled in per entry.STALE_COLLECTION(409) — the collection'supdatedAtchanged between this call's read and its write, typically because anotheradd_collection_fieldcall landed first. This is the only one of the three that is retryable: re-read the collection and try the call again. It exists because two concurrentadd_collection_fieldcalls would otherwise silently drop one of the fields.
list_collections→ pick the collection, read itsfields(key, type, required, options).- For image/file fields, get an asset id (not a url) via
search_assetsorupload_asset_from_url. Delivery rewrites it to a/cdn/url when it serves the entry, on bothpages/by-slugandcollections/<slug>/entries. create_pagewithcollectionId+collectionData. Field defaults fill themselves; the collection'surlPrefixis auto-prepended to your slug (the response carries the final slug).update_collection_datapatch-merges field changes later (sameexpectedUpdatedAtloop as block edits). Patching a published entry reverts it to draft (the response carries the newstatus).search_pageswithcollectionIdlists a collection's entries.
Collections with layoutLocked: true reject block edits on their entries (LAYOUT_LOCKED) — only collection data and SEO are editable. Publishing an entry re-validates collectionData against the collection's current schema and fails with COLLECTION_DATA_INVALID if the schema evolved since the entry was written; validate_page reports the same problems as collectionErrors before you publish.
Reserved field keys
A field's key cannot be title, slug, status, id, locale, published_at, created_at, or updated_at — every one of them is already a built-in page column (used by sort_field, the delivery API, and the rest of the CMS), and a collection field can't reuse one. Both create_collection's fieldsSchema and add_collection_field's field are checked against the same list.
This refusal is a schema-validation error, not a coded ServiceError — the Zod schema the MCP tool declares for its input checks it directly, so the call never reaches the service layer. It arrives as a JSON-RPC InvalidParams error, not an isError: true tool result with a code field to branch on. Reaching for title first when building a Blog collection is the obvious mistake this exists to catch — use something like heading or post_title instead.
Asset uploads and folders
upload_asset_from_url fetches public https URLs only (private/internal addresses are rejected) and returns everything needed to use the asset immediately:
| Field | Use it for |
|---|---|
url | The value for a block image prop — /cdn/<storageKey>, rewritten by the tenant's asset proxy. |
storageKey | The bare key behind that url, if you need to build the path yourself. |
assetId | The value for a collection image/file field. |
width, height | Intrinsic dimensions, when known. |
folderPath | Where the asset landed, so you can confirm the tree you asked for. |
deduped, dedupedBy | Whether this call created an asset (false), matched a previous importKey ("importKey" — nothing was downloaded), or matched identical bytes after the fetch ("contentHash"). |
search_assets rows carry the same url, storageKey, width, height and folderPath, plus size and the updatedAt token that delete_asset and update_asset require.
The two forms are not interchangeable. A block image prop holds the url; a collection image/file field holds the asset id, which delivery resolves to a /cdn/ url when it serves the page. If you write a bare asset id into a block prop by mistake, add_block/update_block_props resolve it to that asset's url before validating, so the write still succeeds; an id that doesn't resolve to one of this tenant's assets is left as-is and rejected with BLOCK_VALIDATION_FAILED.
Idempotent imports
Pass importKey to make a re-run cheap: the key is looked up before the fetch, so a resumed import re-downloads nothing. It defaults to the source URL with its fragment stripped, which is usually what you want; pass your own when the source URL is unstable (a signed or expiring link). A hit returns the existing asset with deduped: true and dedupedBy: "importKey", and never moves it between folders.
Content-hash dedup still runs after the fetch, so byte-identical files collapse even without a key — but a source that re-encodes on every request (some galleries do) produces different bytes each time, and only importKey makes that idempotent.
Folders
Always pass folderPath so the media library stays organized, in the format (collection name)/(page title) for entries — e.g. Blog/My Launch Post — or Pages/(page title) for standalone pages.
folderPath builds a real nested tree: each segment is created or matched under the previous one, so Gallery/2026/Palm Sunday yields three levels and two albums with the same title under different years stay apart. Segments are matched by slug, so the same path always resolves to the same folder. A path exceeding the maximum depth of 10 is rejected.
Deleting and moving assets
delete_asset(assetId, expectedUpdatedAt) permanently deletes an asset: it removes the S3 original (leaving the database row intact to retry if that fails), makes a best-effort attempt to delete every generated variant, then deletes the database row regardless of whether the variant deletes succeeded. Variant-delete failures aren't checked or retried, so a variant object can occasionally outlive the asset it belonged to. This cannot be undone. The token comes from search_assets, the only MCP read that returns an asset's updatedAt.
There is no force delete through MCP. If anything still references the asset, the server refuses with ASSET_IN_USE (409) instead of deleting it, and lists what references it — pages, collection entries, data-model records, and the tenant's branding logo, whichever apply — in the error's details.usages, capped at the first 20 entries; details.total carries the true count, so you can tell whether the list you got is the whole story. That list is the point: it's how you tell apart three identically-named files when only one of them is actually the one in use. Remove or re-point those references first and delete again; there is no force flag exposed through this tool.
delete_asset and update_asset share the same expectedUpdatedAt guard as the page tools, but a stale token means something different here. On a page, STALE_PAGE almost always means someone else edited it. On an asset, STALE_ASSET (409) usually doesn't: background jobs write to an asset row after upload — image-variant generation fills in width, height, processingStatus and variants; the AI alt-text path fills in aiSuggestedAlt and sometimes alt — and any of those bumps updatedAt on their own, so this refusal fires routinely with no human edit involved. The refusal carries the asset's current filename, alt, folderPath, size and storageKey plus the new updatedAt — every field that can actually tell you something changed here, not the full search_assets row. width and height are left out on purpose: the image-processing worker writes them after upload, so a difference there would read as "someone replaced the file" when it's really just processing catching up — exactly the misleading signal this refusal exists to avoid. Compare the fields you do get against the row you read. If size or storageKey differ, the file itself was replaced (a replace swaps the bytes, the storage key, the size and the dimensions while leaving filename, alt and folder alone); that is a real human edit, so don't retry a delete blind. If they all match, nothing either tool can change differs and retrying with the new updatedAt is normally safe — with one caveat the payload cannot close: a focal-point or per-locale alt-text edit (focalPoint, altTexts) shows up in neither the refusal nor search_assets, so a match does not prove the change was machine-generated. details.processing is true while image processing is queued or still running — the one cause the guard can prove outright, so it's the one it states. It is always false for a non-image asset (processingStatus is null there), so for a PDF the comparison above is the only signal you get.
update_asset(assetId, expectedUpdatedAt, filename?, alt?, folderPath?) renames the asset, sets its alt text, and/or moves it to a folder — pass any combination of the three. folderPath creates the folder tree if it doesn't already exist (same nested-path rules as upload_asset_from_url); pass folderPath: null to move the asset back to the root. Renaming never changes the asset's delivery URL: the URL is derived from storageKey, and updateAssetSchema has no field that can touch it.
A rename and a move are two separate writes, so the order matters. The folder path is resolved (and validated — depth, segment names) before the rename is written, so an unusable folderPath refuses with nothing applied. If the move still fails after the rename committed, the tool answers PARTIAL_UPDATE instead of a plain failure: details.renameApplied is true, details.originalCode carries the real error, and details.updatedAt is the post-rename token — retry update_asset with just the folderPath and that token, no search_assets needed.
Block registry sync
The block registry (types, schemas, slots) lives in your tenant app. The CMS keeps a snapshot of it, and refreshes that snapshot only when someone opens a page in the visual editor — every successful editor load re-sends the registry. Deploying your tenant app does not refresh it, and no MCP tool can: an agent cannot trigger the sync itself. A person has to open the editor.
After every deploy that adds a block, adds or changes a field, or changes slots, open any page in the visual editor once (against the deployed app — the editor loads whatever the Frontend URL setting points at), wait for it to load, then close it. Until then the MCP tools keep working from the old snapshot:
whoamistill reports the oldblockCountandschemaSyncedAt— compareschemaSyncedAtagainst your deploy time to tell whether the snapshot is current;list_block_typesdoesn't list the new block, andget_block_schema/add_blockrefuse it withBLOCK_TYPE_NOT_FOUND(404);validate_pageandpublish_pagereport a page containing the new block asBLOCK_VALIDATION_FAILED("Unknown block type … not in tenant registry"), and a field whose type changed is validated against its old type;- a brand-new prop on an existing block is not refused — props the snapshot doesn't know are stored without validation.
If whoami reports blockRegistry: null, the editor has never been opened for this tenant: the block tools, validate_page and publish_page refuse with REGISTRY_NOT_SYNCED (422) until it is.
Layout registry sync
Layout schemas — the header/footer field descriptors a tenant app declares with defineLayout() — reach the CMS the same way the block registry does: a snapshot re-sent every time someone opens the visual editor, never read from the tenant app live and never refreshed by a deploy. After a deploy that adds or changes a layout field, open the editor once; until then set_layout_field refuses the new field with FIELD_PATH_NOT_FOUND and validates changed fields against their old type. list_layouts, get_layout_schema, and list_layout_menus all read this mirror (list_layout_menus needs it to know which fields are menu-tree fields in the first place), so if a tenant has never opened the editor — no mirror row exists yet — all three refuse with LAYOUT_REGISTRY_NOT_SYNCED (422). The fix is the same as for an empty blockRegistry: open any page in the visual editor, wait for it to load, then close it.
get_layout is the exception: it reads the stored header/footer data straight from the content database and never consults the schema mirror, so it answers even when the registry has never synced.
get_layout's hasUnpublishedChanges is not scoped to the section you asked for — it is true if the draft differs from published on either the header or the footer, regardless of which section you queried. Every other field in the response (draft, published, draftUpdatedAt) is section-scoped; this one flag covers the whole layout, both sections.
list_layout_menus returns each menu field's fieldPath — e.g. items, or drawerSections.0.items for a menu-tree nested inside a repeater row. That fieldPath is the address add_nav_item, update_nav_item, move_nav_item, and remove_nav_item take to add, update, move, and remove nodes in that menu — see Layout writes and navigation.
Blind spot: a menu-tree nested under a plain object field is invisible to list_layout_menus. A tenant app can nest a menu-tree repeater inside a plain z.object(...) field (for example, a mobileNav: z.object({ sections: [...] }) drawer field) instead of putting it directly under the section or inside a repeater. The SDK's serializer has no case for a bare object field, so it collapses the whole wrapper to a single fieldType: "text" descriptor with no items — and list_layout_menus walks descriptors, so a menu nested under one of these is never enumerated; get_layout_schema reports the wrapper field itself as "text". get_layout is not affected by this: it returns the section's stored data as-is, wrapper and all, so the nested menu's nodes are visible there even though list_layout_menus cannot address them and get_layout_schema mislabels the field they live in. This is the same blind spot the visual editor's own props panel already has, not something MCP introduced — an "object" field type would need changes in five separate enumeration sites plus a new UI control. If you hit this, the fix is on the tenant side: lift the repeater out of the wrapper to the section's top level (or directly into another repeater), which is a shape both tools already handle.
Layout writes and navigation
Five tools write layout data: set_layout_field, and the four navigation tools add_nav_item, update_nav_item, move_nav_item, and remove_nav_item. All five take layoutKey, an optional locale (defaulting to the tenant's default locale, same as the read tools), and section ("header" or "footer"), and all five share the same guards.
fieldPath comes from a different read depending on what you're addressing. For a menu-tree field, get it from list_layout_menus. For anything else, get it from get_layout_schema's fields, joining field names and array indices with dots. A path can address:
- a top-level field of the section, of any type (
logoHref,ctas); - one row of a
repeaterorarrayfield, by index (ctas.0) — the value replaces the whole row; - a field inside such a row (
ctas.0.label), at any depth of repeaters/arrays.
It cannot address a key inside a plain object field: the schema has no object field type, so a tenant's nested z.object field is a single "text" field and mobileNav.sections fails with FIELD_PATH_NOT_FOUND (see the blind spot below). Nor can it address a node inside a menu-tree by index — use the navigation tools' itemId.
expectedUpdatedAt is required on all five and comes from get_layout's draftUpdatedAt. It is null for a layout that has no draft yet — pass null on the first write to a layout, not an omitted field. There is one token per layout and locale, shared by the header and the footer — not one per section. A write to the header changes the token, so a token you read for the footer is stale afterwards (and vice versa): get_layout(section: "footer") returns the same draftUpdatedAt as get_layout(section: "header"). When editing both sections, chain every write — header or footer — off the draftUpdatedAt returned by the previous one. Edits made in the dashboard's visual editor move the same token. A stale or mismatched token is refused with STALE_LAYOUT_DRAFT (409); the error's details.draftUpdatedAt carries the actual current value, so a retry costs one re-read of that field, not a fresh get_layout call — though re-reading with get_layout is still the safer choice if the draft content itself may have moved on you too. Each of the five write tools returns a fresh draftUpdatedAt in its own response, so — as with the page expectedUpdatedAt loop — you can chain several edits without a get_layout between them.
set_layout_field on a menu-tree field preserves each node's existing _id. The array you pass replaces the whole field, but a node that already carries a string _id keeps it — only a node with no _id (or a malformed one) gets a freshly generated one. value must be an array for a menu-tree field; anything else (an object, null, a string) is refused with INVALID_FIELD_VALUE rather than silently replacing the menu with nothing. add_nav_item, update_nav_item, move_nav_item, and remove_nav_item are the narrower, single-node path: each mutates or relocates one node in place and leaves every other node untouched. add_nav_item is the one exception to id preservation — any _id you put on its node argument (or on any of that node's children) is discarded, and a fresh id is minted unconditionally, so pasting a node copied from elsewhere in the tree can never leave two nodes sharing one id.
itemId is the _id field on a node inside get_layout's returned draft — not something list_layout_menus hands you. list_layout_menus returns { fieldPath, label, maxDepth, nodeCount } for each menu field; it carries no node ids at all. To get an itemId, read the section with get_layout and take the _id off the node you want, from its header/footer data.
A draft written before any of these five tools ever touched it may have nodes with no _id. A seed script or an import that never went through add_nav_item or set_layout_field has no reason to stamp one. The first add_nav_item call against that field stamps every node in the array with an id as a side effect of normalizing the tree before inserting into it; a get_layout read after that call shows _id on every node. Nothing stamps ids on a read — only a write does.
A fieldPath ending on an array index into a menu-tree field is refused by all five write tools, with INVALID_FIELD_VALUE. Menu-tree data is stored as an array, so a path like items.0 would otherwise resolve exactly like a repeater row — but a menu node's real address is its _id, not its position (positions shift under the very edits the navigation tools perform). Address a specific node with the navigation tools' itemId parameter instead.
The four navigation tools refuse a fieldPath that isn't a menu-tree field, with NOT_A_MENU_TREE.
These write tools inherit the same blind spot list_layout_menus has, for the same reason. A menu-tree nested under a plain z.object wrapper (see above) is invisible to list_layout_menus because the SDK's serializer collapses it to a "text" descriptor with no items — and these tools resolve fieldPath by walking that exact same schema mirror. A fieldPath that tries to reach past the collapsed wrapper (e.g. mobileNav.sections.0.items) does not resolve to a menu at all; it fails with FIELD_PATH_NOT_FOUND at the first segment past the wrapper. The fix is the same one described in Layout registry sync: the tenant needs to lift the repeater out of the wrapper.
The five write tools depend on the schema mirror the same way list_layouts, get_layout_schema, and list_layout_menus do, and refuse with LAYOUT_REGISTRY_NOT_SYNCED (422) until a tenant has opened the visual editor at least once — and after that they validate against whatever the editor last synced, so open the editor again after deploying layout changes. publish_layout does not consult the schema mirror at all — like get_layout, it works even when the registry has never synced.
Writes land on the draft. Nothing is live until publish_layout runs — or until some page using that layout is published, which promotes the same draft as a side effect (the same promotion publish_layout itself performs).
publish_layout promotes the header and footer together — there is no per-section publish, and no layout version history. A footer draft left half-edited ships the moment you publish after a header-only change, with nothing to review or roll back afterward: unlike a page, a layout keeps no version snapshots. This is pre-existing behaviour shared with page publishing's own promotion path, not something specific to the MCP tools.
publish_layout on an already-published draft is not an error. Its response's promoted field is false, and neither a CDN purge nor an activity-log entry is created, because nothing actually changed. publish_layout also counts against the mcp:publish rate-limit budget, the same as publish_page and unpublish_page.