---
title: "MCP Server Guide"
url: "https://glokki.com/docs/mcp-server"
locale: "en"
updated: "2026-10-14T21:05:57.851Z"
---

# 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

1. **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 `whoami` tool's `blockRegistry` field shows the snapshot's `blockCount` and `schemaSyncedAt`; if it is `null` the registry has never been synced and the block tools refuse with `REGISTRY_NOT_SYNCED`. See [Block registry sync](#block-registry-sync).
2. 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

```bash
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:

```json
{
  "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](#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](#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](#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](#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](#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](#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](#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](#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](#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](#layout-writes-and-navigation).                                                                                                                                                                                                                                                                                                                                                                                     |

## Linking a new page

1. `create_page` → `add_block` / `update_block_props` with the content.
2. `suggest_internal_links` with the new `pageId` — the draft text is embedded on the fly and compared with every published page in the same locale.
3. For each `outbound` suggestion, insert a link with `update_block_props` (rich-text `content` props take an `<a href="{path}">`, URL props take `path` directly).
4. `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 `null` for an optional field is accepted, but `null` is **stored as the value** — it does not delete the key or restore the default. The block component then receives `null` (not `undefined`), so a default parameter or a schema `.default(...)` does not kick in, and a component that isn't written to handle `null` may render the literal empty state or fail. `null` on a required field is rejected with `BLOCK_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 `urlPrefix` when 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: `updateSlug` silently skips it when another page (in any locale) still uses the old slug, or when a redirect from that source already exists. The response's `redirectCreated` field 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 `status` field is the only signal, so always check it after calling `update_page` with a `title`.

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.

1. `create_collection` → name, slug, `urlPrefix`, and — preferably — the complete `fieldsSchema` for the collection in the same call. Slug and `urlPrefix` must each be unique across the tenant; `COLLECTION_SLUG_TAKEN` and `COLLECTION_URL_PREFIX_TAKEN` are distinct refusals, so a retrying agent knows which value to change. Some `urlPrefix` values are reserved — `/api`, the dashboard routes, the admin routes and the auth routes — and are refused independently of any conflict with another collection.
2. `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. `required` has no default and must be stated explicitly on every new field. `COLLECTION_FIELD_EXISTS` means that key is already taken. A field carrying a `defaultValue` or `defaultNow` is backfilled into every existing entry _and_ into stored page versions; a collection above 10,000 entries is refused with `COLLECTION_TOO_LARGE_TO_MIGRATE` rather than migrated inline — the guard is `entryCount > 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 is `required: true`; its effective default (`defaultNow` on a date/datetime field resolves to the current date/datetime, otherwise `defaultValue`) 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 required `url` default must be a real URL (`""` is refused), a required `multiselect` default must select at least one option (`[]` is refused), `validation.min`/`max`/`pattern` apply to the default the same as to any entry value, and `image`/`file`/`relation`/`dataModelRef` defaults must be valid UUIDs. `defaultNow` always passes. A `dataModelRefs` field has no default — its stored value is always an array of record UUIDs, filled in per entry.
   - **`STALE_COLLECTION` (409)** — the collection's `updatedAt` changed between this call's read and its write, typically because another `add_collection_field` call 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 concurrent `add_collection_field` calls would otherwise silently drop one of the fields.
3. `list_collections` → pick the collection, read its `fields` (key, type, required, options).
4. For image/file fields, get an **asset id** (not a url) via `search_assets` or `upload_asset_from_url`. Delivery rewrites it to a `/cdn/` url when it serves the entry, on both `pages/by-slug` and `collections/<slug>/entries`.
5. `create_page` with `collectionId` + `collectionData`. Field defaults fill themselves; the collection's `urlPrefix` is auto-prepended to your slug (the response carries the final slug).
6. `update_collection_data` patch-merges field changes later (same `expectedUpdatedAt` loop as block edits). Patching a published entry reverts it to draft (the response carries the new `status`).
7. `search_pages` with `collectionId` lists 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:

- `whoami` still reports the old `blockCount` and `schemaSyncedAt` — compare `schemaSyncedAt` against your deploy time to tell whether the snapshot is current;
- `list_block_types` doesn't list the new block, and `get_block_schema` / `add_block` refuse it with `BLOCK_TYPE_NOT_FOUND` (404);
- `validate_page` and `publish_page` report a page containing the new block as `BLOCK_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](#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 `repeater` or `array` field, 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](#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`.
