# Catalogue write tools

Write tools for product SEO, web address, variants, properties, labels, sale prices, price breaks, brands, the label set and featured products.

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

## Overview

Besides the [first write tools](https://docs.omniretailconnect.nl/commerce/mcp/tools.md#write-tools), a token with `commerce.write.products` (and, for featured products, `commerce.write.collections`) can change the parts of the catalogue that make a shop findable and attractive: SEO fields, web addresses, variant details, properties, labels, sale prices, quantity price breaks, brands and the label set. These tools are in the server since version 1.3.0 (SEO, address, variants, properties, labels, brands, featured products) and 1.4.0 (sale prices and price breaks). Every tool is also a [REST endpoint](https://docs.omniretailconnect.nl/commerce/api/product-writes.md) with the same payload.

*Catalogue write tools*

| Tool | Scope | Purpose |
| --- | --- | --- |
| `update_product_seo` | `commerce.write.products` | Set the own SEO title and meta description of a product. |
| `change_product_slug` | `commerce.write.products` | Change the web address of a product. |
| `update_variant` | `commerce.write.products` | Change the EAN and/or tax class of a variant, by SKU. |
| `set_product_properties` | `commerce.write.products` | Replace the hand-set properties of a product. |
| `set_labels` | `commerce.write.products` | Replace the labels of a product. |
| `set_variant_sale, end_variant_sale` | `commerce.write.products` | Put a variant on sale for a period, or end its sales. |
| `set_price_breaks` | `commerce.write.products` | Replace the quantity price breaks of a variant. |
| `create_brand, rename_brand, change_brand_slug, delete_brand` | `commerce.write.products` | Manage brands. |
| `set_brand_logo, remove_brand_logo` | `commerce.write.products` | Set or remove the logo of a brand. |
| `create_label, update_label, move_label, delete_label` | `commerce.write.products` | Manage the label set. |
| `feature_product_in_collection, unfeature_product_in_collection, move_featured_product_in_collection` | `commerce.write.collections` | Feature products on top of a category listing. |

The [common rules of the write tools](https://docs.omniretailconnect.nl/commerce/mcp/tools.md#write-tools) apply: set semantics where possible (`changed` and `changes` in every result), one database transaction together with the change-log row, the PIM ownership rules, and the write limit of 30 calls per token per minute. A token without the scope does not see these tools at all: a call answers JSON-RPC `-32602`.

## Reference lists (read)

Four read tools give the ids the write tools take. They need `commerce.read` only and never write.

*Reference list tools*

| Tool | Scope | Purpose |
| --- | --- | --- |
| `list_brands` | `commerce.read` | The brands by id, a page at a time. |
| `list_labels` | `commerce.read` | The product labels in display order. |
| `list_tax_classes` | `commerce.read` | The tax classes (VAT rates). |
| `list_properties` | `commerce.read` | The product properties with their known values, a page at a time. |

- `list_brands` and `list_properties` take `limit` and `cursor` and return `items`, `next_cursor` and `has_more`. A brand has `id`, `name`, `slug`, `url`, `logo_url`, `product_count` (any status; a brand in use cannot be deleted) and `pim_managed` (a PIM product uses it, so it cannot be renamed). A property has `id`, `name`, `handle`, `type` (`list` or `number`), `unit` and `values` (`[{id, label}]`, at most 100, `values_truncated`).
- `list_labels` and `list_tax_classes` take no input and return everything with `truncated` (more than 200 labels or 100 tax classes). A label has `id`, `name`, `slug`, `tone` (`neutral`, `primary`, `accent`, `dark`, `success`), `position` and `product_count`. A tax class has `id`, `name` and `default`.

`get_product` now also returns what the full-list writes replace, so a client can read it first: `slug`, `seo` (`meta_title`, `meta_description`), `labels` and `properties`, and per variant `sales` and `price_breaks`. See the [tool reference](https://docs.omniretailconnect.nl/commerce/mcp/tools.md#get_product).

## update_product_seo

Set the shop's own SEO title and meta description of a product. Allowed on PIM products. At least one of the two fields besides `id`. Plain text, whitespace collapsed.

*Inputs of update_product_seo*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | integer | Required | Product id. |
| `meta_title` | string\|null | Optional | At most 255 characters. `null` or empty removes the own title; the shop then uses the product name. |
| `meta_description` | string\|null | Optional | At most 320 characters. `null` or empty removes it; the fallback is the description. |

**Output.** `product_id`, `seo` (`meta_title` and `meta_description` as stored), `changed` and `changes`.

## change_product_slug

Change the web address of a product (`/producten/{slug}`) with `id` and `slug` (made URL-safe: lower case, dashes; at most 255 characters). Allowed on PIM products, because the shop owns the address.

- A slug that is empty after cleaning, or used by another product, is `invalid_argument`.
- A published product keeps its old address as a permanent redirect to the new one (`changes.redirect` holds the old path). A draft gets none.
- The check and the write are serialised per slug with the admin; a busy lock answers `conflict` after 5 seconds.
- Output: `product_id`, `slug`, `url`, `changed`, `changes`.

## update_variant

Change the EAN and/or the tax class of a variant, found by SKU. At least one of the two. Both are PIM-owned on a PIM product (`field_owned_by_pim`).

*Inputs of update_variant*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `sku` | string | Required | SKU of the variant. |
| `ean` | string\|null | Optional | A valid GTIN (8, 12, 13 or 14 digits, correct check digit). `null` or empty clears it. |
| `tax_class_id` | integer | Optional | A tax class id from `list_tax_classes`. |

**Output.** `sku`, `product_id`, `variant` (`id`, `sku`, `ean`, `tax_class_id`, `tax_class`, `price`), `changed`, `changes`. The price customers pay stays the same when the tax class changes; `changes.price` shows a rounding difference.

## set_product_properties

Set the hand-set properties of a product with `id` and `properties`: the complete list (it may be empty) of `{property_id, values}`, each property once, at most 50 properties and at most 50 values of at most 100 characters each. Ids and known values come from `list_properties`.

- The list replaces every value set by hand. The tool is advertised as destructive: an empty list wipes them. Values that come from variants stay.
- A known label is reused (ignoring case); a new one becomes a new value of the property; a number property keeps the first value.
- An unknown property refuses the whole call (`invalid_argument`, nothing stored).
- Output: `product_id`, `properties` (`property_id`, `name`, `values`), `changed`, `changes`.

## set_labels

Set the labels of a product with `id` and `label_ids`: the complete list of distinct ids from `list_labels` (it may be empty), at most 50. It replaces the product's labels and is advertised as destructive: an empty list wipes them. Allowed on PIM products. An unknown label is `invalid_argument`. Output: `product_id`, `labels` (`id`, `name`, in label order), `changed`, `changes`.

## set_variant_sale and end_variant_sale

A sale price of one variant next to its regular price, which a sale never changes. Scope `commerce.write.products`.

*Inputs of set_variant_sale*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `sku` | string | Required | SKU of the variant, at most 64 characters. |
| `amount_minor` | integer | Required | Tax-inclusive sale price in cents, 1 to 100000000, below the regular price as stored. |
| `starts_at` | string\|null | Optional | ISO 8601 with a time and an offset (`2026-11-20T00:00:00+01:00` or `...Z`). Omitted, `null` or in the past: now. The price history is never backdated. |
| `ends_at` | string\|null | Optional | Same format, exclusive, after the start and after now. Omitted or `null`: no end. A date that does not exist (30 February) is `invalid_argument`. |

- A sale that overlaps the period is replaced: a running one ends when the new one starts, a planned one is deleted. When the only overlapping sale already is this one, nothing changes.
- `end_variant_sale` (`sku`) ends the running sale now (it stays in the price history) and deletes the planned ones. It is advertised as destructive. Without a sale nothing changes.
- Both write the sale periods of the price history that the optional "lowest price of the last 30 days" line reads. Whether that line is shown is the shop owner's setting and is not touched.
- No regular price, a sale price not below it, or a period that ends too early is `invalid_argument`. On a PIM-linked product whose prices the PIM owns, both tools are `field_owned_by_pim`.
- Output: `sku`, `product_id`, `regular_price`, `sales` and `price_breaks` (as in `get_product`), `changed`, `changes` (`sale` with the replaced or ended sales and the new one, prices as money objects).

## set_price_breaks

Replace the quantity price breaks (staffelprijzen) of one variant. Inputs: `sku` and `price_breaks`, the complete list of `{min_quantity, amount_minor}` (tax-inclusive price per unit from that quantity on). An empty list removes them all; the tool is advertised as destructive.

- At most 10 breaks, `min_quantity` 2 to 9999 and unique, every price above zero and below the regular price, and a larger quantity is never dearer per unit. Otherwise `invalid_argument` with the rule in the message.
- On a PIM-linked product whose prices the PIM owns: `field_owned_by_pim`.
- Read the current list with `get_product` first. Output as `set_variant_sale`, with `changes.price_breaks`.

## Brands

- `create_brand` (`name`, at most 255 characters) and `rename_brand` (`id`, `name`). A name must be new ignoring case, so a repeated create is `invalid_argument` and never makes a duplicate. A brand that a PIM-linked product uses cannot be renamed (`field_owned_by_pim`). Renaming keeps the address.
- `change_brand_slug` (`id`, `slug`): the address `/merken/{slug}`. A slug another brand uses is `invalid_argument`. The old address becomes a permanent redirect. Brand addresses are changed one at a time; a busy lock answers `conflict` after 5 seconds.
- `delete_brand` (`id`): only a brand that no product uses (of any status). Otherwise `invalid_argument`. Its old address redirects to the brands overview. Destructive; a repeat is `not_found`. Output: `brand_id`, `deleted`, `changed`, `changes`.
- `set_brand_logo` (`id`, `image_url`, `alt`) downloads the image with the rules of `add_product_media` (https, public host, no redirects, at most 10 MB, no SVG) and makes it the brand's only logo; JPEG and PNG are stored as WebP. Every call stores the image again. `remove_brand_logo` (`id`) deletes the logo; without a logo it changes nothing. It is advertised as destructive.
- Output of the others: `brand` (`id`, `name`, `slug`, `url`, `logo_url`, `product_count`, `pim_managed`), `changed`, `changes`. Assign a brand to a product by name with `update_product`.

## Labels

- `create_label` (`name`, at most 40 characters and new ignoring case; optional `tone`: `neutral` (default), `primary`, `accent`, `dark`, `success`) adds a label at the end of the label order. A repeat is `invalid_argument`.
- `update_label` (`id`, at least one of `name`, `tone`) renames or recolours one. The slug stays, because themes style by it.
- `move_label` (`id`, `direction` `up` or `down`) swaps it with its neighbour; nothing happens at either end. Not idempotent.
- `delete_label` (`id`) deletes it and takes it off its products. Destructive; a repeat is `not_found`.
- Output: `label` (`id`, `name`, `slug`, `tone`, `position`, `product_count`) or, for delete, `label_id` and `deleted`, plus `changed` and `changes`.

## Featured products

Scope `commerce.write.collections`. Featured products sort above all other products of a category listing. `feature_product_in_collection`, `unfeature_product_in_collection` and `move_featured_product_in_collection` all take `collection_id` and `product_id`; the product must be in the category, otherwise `invalid_argument`. The move tool also takes `direction` (`up` or `down`).

- Featuring puts the product on top; already on top changes nothing; featuring another later puts that one on top.
- Unfeaturing puts it back among the others; a product that is not featured changes nothing.
- Moving swaps it with its neighbour among the featured products. A product that is not featured is `invalid_argument`. Not idempotent.
- A category of the hidden PIM import tree is `invalid_argument`. The category's product list is locked for the call; a busy lock is `conflict`.
- Output: `product_id`, `collection`, `featured`, `featured_product_ids` (top first, at most 100), `changed`, `changes`.

## Errors

*Errors of the catalogue write tools*

| Code | When |
| --- | --- |
| `invalid_argument` | Failed validation: an unknown argument, a taken name or slug, a brand that is still in use, a product that is not in the category, a refused image URL, a sale price not below the regular price, a break list that breaks a rule, or an unknown id in a list. The message names the rule. |
| `not_found` | The product, SKU, brand, label or category does not exist; a repeated delete. |
| `field_owned_by_pim` | On a PIM-linked product: EAN and tax class, sale prices and price breaks when the PIM owns prices, or renaming a brand a PIM product uses. SEO, web address, labels and featured products are the shop's own and always editable. |
| `conflict` | The data changed during the call, or another change of the same data (a slug, a brand address, a category product list) kept it locked for 5 seconds. Read again and retry. |
