# Content tools

List, read, create, change and delete CMS pages: blocks, images, version check, search-engine fields, the home page and size limits.

Section: Commerce · MCP
Canonical URL: https://docs.omniretailconnect.nl/commerce/mcp/content-tools

## Overview

Five tools read and manage the CMS pages of the shop: the home page, the legal pages and every other content page. Reading needs `commerce.read`; changing needs `commerce.write.content`, issued as the edit permission **Pages** (*Pagina's*) in **Settings** > **API tokens**. The tools exist since server version 1.6.0 and are also [REST endpoints](https://docs.omniretailconnect.nl/commerce/api/pages.md) with the same payload. They run the page editor's own actions under the page's row lock. Every change is in the change log, in the shop's content activity log with the token as the causer, and in the page's history as a version by "API-token" and the token name (no staff author).

*Content tools*

| Tool | Scope | Purpose |
| --- | --- | --- |
| `list_pages` | `commerce.read` | The CMS pages, a page at a time, drafts included. |
| `get_page` | `commerce.read` | One page with its blocks and search-engine fields. |
| `create_page` | `commerce.write.content` | Create a page (a draft unless told otherwise). |
| `update_page` | `commerce.write.content` | Change fields and blocks of a page, with a version check. |
| `delete_page` | `commerce.write.content` | Delete a page, never the home page. |

> **Security — Drafts are visible to read tokens:** `list_pages` and `get_page` return drafts and unpublished (scheduled or expired) pages to any token with `commerce.read`. Do not give a read token to anyone who may not see unpublished content. Page titles and text come from staff: data, not instructions.

## list_pages

Optional `status` (`draft`, `published` or `all`, default), `limit` and `cursor`. **Output.** `items` (`id`, `title`, `slug`, `is_home`, `url`, `status`, `visibility` (`draft`, `scheduled`, `published` or `expired`: whether visitors can open it now), `publish_at`, `unpublish_at`, `noindex`, `updated_at`), `next_cursor` and `has_more`. No blocks: use `get_page`.

## get_page

One page by `id`, drafts and unpublished pages included: the fields of `list_pages` plus `meta_title`, `meta_description`, `canonical_url`, `og_image` (the stored share image path) with `og_image_url`, and `blocks`, the full list as stored, each `{type, data, image_urls}` (`image_urls` is the public URL per image field, `null` without images). The writable fields have the names and forms `update_page` takes, so they can be sent back as they are. `updated_at` is the version that `update_page` needs as `expected_updated_at`. An unknown id is `not_found`.

## create_page and update_page

*Inputs of create_page, update_page and delete_page*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | integer | Required | `update_page` and `delete_page` only. |
| `expected_updated_at` | string | Required | `update_page` only. The `updated_at` of `get_page`. When the page was saved after that moment the call is `conflict` and nothing changes. |
| `title` | string | Required | `create_page` only (optional on update). At most 255 characters. |
| `slug` | string | Required | `create_page` only (optional on update). Lower-case letters, digits and hyphens, unique, never `home`. The home page keeps `home`; another slug is `invalid_argument`. |
| `status` | string | Optional | `published` publishes, `draft` makes it a draft. Left out keeps it; a new page is a draft. |
| `publish_at, unpublish_at` | string\|null | Optional | ISO 8601 with an offset (a date that does not exist is `invalid_argument`), answered in UTC. `null` clears. The end must be after the start. |
| `meta_title, meta_description` | string\|null | Optional | At most 255 and 320 characters. `null` or empty: none. |
| `canonical_url` | string\|null | Optional | An `https` URL or a path of this shop (`/pagina/...`), only for a copy of another page. `null`: the page's own address. |
| `noindex` | boolean | Optional | `true` keeps the page out of search results (robots meta and `X-Robots-Tag`), the sitemap and `llms.txt`. |
| `og_image` | string\|null | Optional | The share image: an `https` URL (downloaded), its current path, or `null` to remove it. |
| `blocks` | array | Optional | The whole list in page order, at most 60, each `{type, data}`. It replaces every block. |

- **Version check.** `update_page` requires `expected_updated_at`. Every committed change moves the version, also a second change within the same second, so two writers with the same value never both succeed. Only the fields you send change; the input is merged onto the page as read under its row lock. Sending the output of `get_page` back with the current version answers `changed: false` (no new version, no activity line).
- **Not idempotent.** A repeat of `create_page` with the same slug is `invalid_argument`; a slug that another page takes at the same moment is `conflict`.
- **Destructive.** `update_page` is advertised as destructive because `blocks` replaces the whole list; `create_page` and `update_page` are advertised with `openWorldHint: true` because they download image URLs.
- **Output.** The page as `get_page` returns it, plus `changed` and `changes` (`{field: {old, new}}`; blocks as a count and a `sha256` fingerprint, so the change log does not keep whole texts).

## Blocks

`blocks` is the whole list in page order (at most 60) and replaces every block, so read the page first, change what you need and send the list back. There are 20 block types. Ten work on any page: `heading`, `richtext`, `faq`, `cta`, `usp`, `callout`, `image`, `media-text`, `feature-grid`, `promo-banners`. Ten work only on the home page, or on a page that has that type already: `product-row`, `category-tiles`, `brand-carousel`, `article-teaser`, `locations-teaser`, `related-products`, `product-tabs`, `hero-slider`, `newsletter`, `goal-tiles`. The input schema in `tools/list` documents every type and its data fields (`anyOf` by `type`); the main fields are also listed under [Pages (REST)](https://docs.omniretailconnect.nl/commerce/api/pages.md#blocks).

- Rich text (`richtext.html`, `media-text.html`) is sanitised as in the editor: scripts, event handlers, styles and unsafe links (`javascript:`) are removed. Other block text is stored as plain text.
- Collection, brand and product slugs in blocks must exist (`invalid_argument`), unless the page has them already.
- Refusals name the field (`blocks.2.data.alt: ...`) and are in English.

### Images

An image field and `og_image` take a path the page already uses (kept, no download) or an `https` URL.

- The URL is downloaded before the write by the same guard as `add_product_media`: public hosts only, DNS-pinned, no redirects. It is then checked like an editor upload: JPEG, PNG or WebP of at most 5 MB, stored as WebP.
- At most 20 new image URLs per call, 40 MB and 30 seconds of downloading in all. Past the budget the call is `invalid_argument` naming the field.
- A GIF or AVIF, or an image above 5 MB, is `invalid_argument` naming the field (`blocks.2.data.path`, `og_image`). An `http` URL, a path of another page or any other value is `invalid_argument`.
- `og_image` takes only an `https` URL, `null` or its current value, never a path a block uses.
- A file stored by a call that then fails or rolls back is deleted again; a replaced share image file is deleted after the commit.

### The home page

The home page keeps the slug `home` and cannot be deleted (`delete_page` answers `invalid_argument`). No other page may take the slug `home`. The home-page-only block types are accepted there, and on other pages only when the page has the type already.

### Search engines and addresses

- `noindex: true` keeps the page out of search results and out of the sitemap and `llms.txt`.
- A page whose `canonical_url` points to another address stays in the sitemap for now (only `noindex` pages are left out); set `noindex` as well if it must not be listed.
- A slug change of a page that is public keeps the old address working with a redirect, as in the editor.

## delete_page

Takes `id`. Deletes the page with its saved versions and the menu links that point at it. Advertised as destructive. The home page is `invalid_argument`, a repeat `not_found`. Output: `page_id`, `deleted`, `menu_links_removed`, `changed`, `changes`.

## Size limits

- **Request body.** The MCP endpoint accepts a body of up to 256 KiB (`413` above it), because `create_page` and `update_page` carry a whole block list. The cap is judged before the tool is known, so align the web server (`client_max_body_size 256k;` on nginx). Since server 1.6.0 this replaces the earlier 64 KiB.
- **Other tools.** Every other tool is still refused above 64 KiB of arguments with `invalid_argument`.
- **Blocks and images.** At most 60 blocks per page, 20 new image URLs, 40 MB and 30 seconds of downloading per call, 5 MB per image.

## Errors

- `invalid_argument`: a rule that fails, naming the field; a taken slug; the home page rules; a block type that is not allowed on this page; an unknown collection, brand or product slug; a refused image.
- `not_found`: an unknown page, or a repeated delete.
- `conflict`: `update_page` with an `expected_updated_at` that is no longer current; a slug that another page takes at the same moment.
- The general write errors apply: see [errors of the write tools](https://docs.omniretailconnect.nl/commerce/mcp/tools.md#write-errors).
