# MCP server

> CBX can expose itself to an AI assistant over the Model Context Protocol , so the assistant can work with the catalog through real, typed tools instead of be…

Source: CBX documentation, version 3.x (released). Canonical page: https://docs.configbox.at/docs/technical/mcp_server. Last updated 2026-08-25.

---
CBX can expose itself to an AI assistant over the **Model Context Protocol (MCP)**, so the
assistant can work with the catalog through real, typed tools instead of being told about it in prose.

> **Scope today:** describe / list / get over every registered entity; create / update / delete over
> the catalog-authoring entities (`catalog:write`) and the store-running ones — settings, tax,
> shipping, payment, geography (`store:write`), customers, addresses, reviews (`customers:write`);
> rules, calculations and product transfer; seven admin **operations** that have no record to write,
> each its own tool (cache, migrations, charset, orphaned strings, system vars, order status); and
> introspection of the install and of its **customization layer**. Writes go straight to
> the live store: **no staging and no undo**. There IS a permission check — see §1 — and it is the
> same one on both transports.
>
> **Everything above is the back office.** Shopping *as a customer* — browsing, configuring, adding
> to a cart — is a separate toolset on the `storefront` scope area, reached over HTTP with a token
> bound to one customer, and its scopes deliberately never mix with the back-office ones. It arrived
> in 3.8.18 and is not documented here yet; the same ground without MCP is the
> [runtime API](https://docs.configbox.at/docs/technical/runtime_api).
>
> **Two transports, one server:** stdio (`configbox:mcp`, trust = shell access) and
> `POST /cb-api/v1/mcp` (bearer token, scopes decide what it may do). Both call the same
> `handleMessage()` over the same tool registry, so they cannot drift.

All paths below are relative to the component root (`components/com_configbox/` on Joomla).

> **The picture first:** `com_configbox_ai_surface.html` — open it in a browser — walks the same
> ground with diagrams: what MCP is, how a conversation runs, the two transports converging, the
> scope grid and the tools by area. This page is the reference behind it.

```mermaid
flowchart LR
  subgraph HOST["Host application (Claude Code, Claude Desktop, an IDE)"]
    CLIENT["MCP client\none per server"]
  end
  subgraph SERVER["ConfigBox MCP server — helpers/mcp.php"]
    GATE["scope gate\none table decides"] --> TOOLS["23 tools"]
  end
  CLIENT <-- "JSON-RPC 2.0\nstdio or HTTP" --> GATE
  TOOLS -- "PHP calls" --> MODELS["Kenedo models\nvalidation · MySQL"]
```

The model never touches the database. It asks the client; the client sends a message; the server
runs ordinary ConfigBox code — the same models the admin forms use, with the same validation — and
the scope gate sits between the message and the work.

---

## 1. Running it

The server speaks MCP's **stdio** transport: newline-delimited JSON-RPC 2.0 on stdin/stdout, running
until the client closes stdin. You normally register it with a client rather than running it by hand.

Whatever the transport, a conversation has the same three beats — and the second one is where the
credential shows: **the tool list a caller receives depends on the scopes it presented**, so one
server is a read-only catalog browser to one token and an operations console to another.

```mermaid
sequenceDiagram
  participant C as MCP client
  participant S as ConfigBox server
  C->>S: initialize
  S-->>C: protocol version · capabilities · instructions (names the scopes this connection holds)
  C->>S: tools/list
  S-->>C: only the tools the scopes allow — with annotations
  C->>S: tools/call { name, arguments }
  S-->>C: result — or isError:true, which the model can correct and retry
```

**It must run under the site's own PHP**, not just any PHP on your machine — the same environment a
web request or a `configbox:*` command gets. CBX's core is ionCube-encoded and the database host
is usually only resolvable from inside the application environment, so a stray host PHP fails on one
or both. On a containerised dev setup that means invoking through the container:

```
# Claude Code, local DDEV dev site (the container has the ionCube loader and can reach the DB).
# --read-only is the safe default; drop it when you want the assistant authoring.
claude mcp add configbox -- ddev exec php docroot/cli/joomla.php configbox:mcp --read-only

# A server / non-containerised install, where the system PHP is the site's PHP
claude mcp add configbox -- php /path/to/site/cli/joomla.php configbox:mcp --read-only
```

Per host platform:

| Host | Command |
|---|---|
| Joomla | `php cli/joomla.php configbox:mcp` |
| WordPress | `wp configbox mcp` *(wrapper not written yet)* |
| Magento 2 | `bin/magento configbox:mcp` *(wrapper not written yet)* |

Wrap whichever of these applies the way you would any other CLI command on that install — with
`ddev exec`, `docker compose exec -T`, or nothing at all.

### Over HTTP, for a remote client

`POST /cb-api/v1/mcp` speaks the same protocol to a client that is not on this machine. It needs a
bearer token, and the token's scopes are what the connection may do:

```bash
# Mint one. Grant only what the client actually needs.
php cli/joomla.php configbox:token:mint "assistant" --preset=author

curl -X POST https://example.com/cb-api/v1/mcp \
  -H "Authorization: Bearer cbx_1_..." \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

**It is the same server.** The endpoint decodes the body and calls
`ConfigboxMcpHelper::handleMessage()` — the same call the stdio loop makes, for the same message,
through the same dispatcher and the same tool registry. There is no second protocol implementation
and no second set of schemas, so the two transports cannot drift: a tool added for one is present in
the other before anybody writes a line of transport code. Only three things are transport-specific,
and they are all in the controller: authenticating the token and handing its scopes to the tool layer,
answering **bare** JSON-RPC rather than the cb-api envelope (the same exception the OpenAPI and JSON
Schema endpoints take — the document *is* the resource), and `202` for a notification.

```mermaid
flowchart TB
  STDIO["stdio — configbox:mcp\ntrust = shell access\ngrant = every scope, or --scope / --preset"]
  HTTP["POST /cb-api/v1/mcp\ntrust = bearer token\ngrant = the token's scopes"]
  STDIO --> HM
  HTTP --> HM
  HM["handleMessage()\nthe same call from both doors"] --> ET["executeTool()\none registry, one dispatcher"]
  TABLE["getToolScopes()\nthe one table: area:level per tool"]
  TABLE -. "filters tools/list" .-> HM
  TABLE -. "enforces tools/call" .-> ET
  ET --> M["Kenedo models · validation · MySQL"]
```

Only three things differ between the doors, and all three live in the HTTP controller: who is
calling, answering bare JSON-RPC instead of the REST envelope, and `202` for a notification. A test
pulls `tools/list` from both transports and compares them as data, so a tool added for one and
forgotten for the other fails there rather than in a client months later.

`GET` answers `405`: this server sends nothing the client did not ask for, and MCP's streamable-HTTP
transport allows exactly that from a server offering no stream. On a multilingual site an unprefixed
`GET` is first redirected to the language-prefixed URL; **POST the language-prefixed URL directly**
(`/en/cb-api/v1/mcp`) if your client does not preserve the body across a 301.

Options:

| Option | Effect |
|---|---|
| `--read-only` | Narrow the grant to every read scope — withhold create/update/delete entirely. Shorthand for `--scope` with every `<area>:read`. Recommended unless you specifically want the assistant authoring. |
| `--scope` | Narrow the grant to the named `<area>:level` scopes (`catalog:write`, `orders:read`, …; repeat for several). Combines with `--preset`. Defaults to every scope, because a stdio caller already has shell access — this is a self-imposed limit, not a credential check. |
| `--preset` | A named scope set — `author`, `fulfilment`, `insights`, `operator`, `everything` — expanded when the server starts and combined with `--scope`. No preset includes `code:write`. |
| `--in-process` | Run tool calls in the server process instead of spawning a worker per call. Much faster, but loses the isolation §5 explains. For debugging, and for the test suite's long-lived client. |
| `--call` | Internal. Runs one tool call read as JSON from stdin and exits — this is what the server spawns per call. Not for direct use. |

**Access control: one scope model, both transports.** Every tool names the scope it needs in one
table — `ConfigboxMcpHelper::getToolScopes()` — and that table is the whole authorization model.
`tools/list` filters by it and `executeTool()` enforces it, so a tool withheld from the listing is
also refused when a client calls it by name anyway. Listing is a hint a client may ignore; the check
that matters is the one next to the work. A tool missing from the table fails closed and says so.

Every `tools/call` also writes one audit line — the tool name, the caller (a token's id and name over
HTTP, or `stdio`), and the granted scopes, **never the arguments** (those can carry a customer's
address) — to the `authorization` KLog category (`configbox_authorization.log`), the same category
every other who-was-allowed-what decision logs to.

The vocabulary is the **bearer token's**, not a second one — `<area>:<level>`, seven areas and two
levels. The full reference — every entity's area, the presets, refusals on both surfaces, the legacy
migration — is `com_configbox_api_tokens_and_scopes.md`; `configbox:token:scopes` prints it from the
running code. The short form:

| Area | Holds | Grantable levels |
|---|---|---|
| `catalog` | Products, pages, questions, answers, lists, detail panes, examples, rules, formula and matrix calculations. | read, write |
| `store` | Settings, store identity, currencies, tax classes, shipping, payment methods, notifications, geography. | write only — every token already reads it, see the floor below |
| `customers` | Customer records, addresses, reviews, parties. | read, write |
| `orders` | Sales orders, quotes, their lines and tax rates, payments. | read, write |
| `conversations` | Advisor conversations, turns, events, recommendations, outreaches. | read, write |
| `code` | PHP the engine evaluates: code calculations, and transfer packages that carry them. | write only — reading a code calculation is reading the catalog |
| `system` | The install itself: migrations, charset, system variables, orphaned strings. | read, write |

Twelve scopes are grantable in total. Four rules are the whole model:

1. **Within an area, write implies read.** `catalog:write` can describe, list and get the catalog
   the same as `catalog:read` — a token minted to author products can always read what it authors.
2. **Across areas, nothing implies anything.** Seven independent switches, because the boundary that
   matters runs by what a token *touches*, not by what it *does*: a catalog importer must not refund
   an order, and an operations assistant must not touch the catalog, however "write" both are. A
   wrong product title is a typo someone fixes; a wrong order total is a refund that already went out.
3. **`store:read` is the floor.** Every token carries it, ungranted and unrevokable — it is not a
   scope `configbox:token:mint` accepts. Currencies, tax classes, shipping methods, countries: this
   is the vocabulary every other area is expressed in, and a token that could not read it could not
   make sense of a price, a product or an address. The floor is safe only because credential-bearing
   fields (a payment provider's secret, the licence key) are `apiSensitive` and never returned.
4. **`code:write` is never enough on its own.** Writing a code calculation's PHP needs
   `catalog:write` AND `code:write`; importing a package that carries code calculations needs both
   too — `cbx_check_product_package` reports `carriesCode` and the scopes still needed (`needsScopes`)
   before anything is written.

Five presets bundle common grants for `configbox:token:mint --preset` and `configbox:mcp --preset`:
`author` (`catalog:write`), `fulfilment` (`orders:write` + `customers:read`), `insights`
(`catalog:read` + `conversations:read`), `operator` (`store:write` + `system:write`), `everything`
(every area but code, at write). **No preset includes `code:write`** — permission to run PHP on the
server is always spelled out by hand, so it never rides in silently on a convenience.

So `orders:write,customers:read` is a real grant — a fulfilment assistant. Orders are frozen
checkout snapshots, so no order entity is writable through the generic tools; what `orders:write`
opens is `cbx_set_order_status` (and `cbx_clear_cache`, which any write scope opens), and the generic
writers — whose entity enum would come out empty — are withheld from that token's list entirely
rather than advertised as usable on nothing. A token holding only `catalog:write` gets
`cbx_update_entity` with only catalog entities in its enum, and can already read them, by rule 1.
Neither is a degraded version of a bigger grant; they are different jobs.

Authorization is checked at **two levels** wherever both apply: the tool's scope, and then the
*entity's own area*. The generic entity writers need **any** write-capable scope to exist at all (a
write scope in any area), and then the entity's area decides — `product` (catalog) needs
`catalog:write`, `tax-class` (store) needs `store:write`. That split is what lets one tool serve
every area without one grant leaking into another. Schemas are grant-aware too, so the entity enum a
caller is handed never invites a call that would be refused.

**Where the credential comes from depends on the transport, and only that.**

- **stdio** grants everything by default, because its trust boundary is *shell access to the site*,
  identical to every other `configbox:*` command. Anyone who can start the server could already run
  `configbox:run-task`, read the database or edit the files, so a credential prompt in front of it
  would be theatre. `--read-only` narrows it to every read scope.
- **HTTP** authenticates a bearer token (`ConfigboxApiTokenHelper`, the same one the REST surface
  uses) and hands *that token's* scopes to the tool layer as the grant for the request. An HTTP
  caller can only ever hold what its token was minted with.

**One thing scopes do not cover: a filesystem path.** The product-transfer tools take one — `path` to
import, `out` to export — and a path never reaches a model, so no entity whitelist constrains it.
That is harmless on stdio, where the caller could write the file itself anyway. It is not harmless
from a bearer token, so **over HTTP both arguments must be a plain file name** and resolve inside the
install's product-transfer directory. A bare name is demanded rather than a path sanitised because
sanitising is a losing game — `..`, symlinks, encoded separators and absolute paths each need their
own rule — whereas a basename has none of those shapes to begin with.

What also stands between a mistake and the live store: the entity whitelist, one id per delete (no
bulk form), the model's own validation, and its `canDelete()` checks — so a delete refused for being
referenced says what references it ("Could not delete product list, because it contains products").

### Scope permits; annotation asks

```mermaid
flowchart LR
  MODEL["Model\nproposes a call"] --> HOSTGATE
  subgraph HOSTGATE["Host gate — «should we?»"]
    H1["reads destructiveHint"] --> H2["asks the person"]
  end
  HOSTGATE -- "tools/call" --> SERVERGATE
  subgraph SERVERGATE["Server gate — «may it?»"]
    S1["the token's scope"] --> S2["then the entity's area"]
  end
  SERVERGATE --> RUN["runs"]
```

Two gates, two questions. The host asks whether a destructive call *should* happen — a judgement
about this moment, made by a person. The server asks whether this token *may* make it at all — a
policy, set when the token was minted. Neither can do the other's job.

A scope and an MCP tool annotation answer different questions. The scope says whether **this token**
may call the tool at all — policy, decided once, at mint time. The annotation says whether **this
call** is the kind a person should look at before it runs — judgement, made in the moment by whatever
host is holding the conversation. `ConfigboxMcpHelper::getToolAnnotations()` gives every tool the
protocol's standard hints — `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` (this
server never reaches outside the install, so that one is always `false`) — generated from the same
per-tool tables `getToolScopes()` reads, so a tool cannot carry one without the other.

**This is also why there is no `delete` scope.** The instinct is obvious: a delete is scarier than an
update, so surely it needs its own permission. It was tried and rejected: a scope and an annotation
would then be doing the same job with different failure modes — a delete-scoped token still lets a
model delete unattended, and a client that ignores `destructiveHint` (annotations are hints, not
enforcement) would be no safer for the extra scope. Scopes stay coarse — a full CRUD grant per area —
and the annotation is what lets a *host* insert a person before a `destructiveHint: true` call, which
is a better fit for "should someone confirm this" than a permission bit ever was.

---

## 2. The tools

Every tool below also carries MCP's standard annotations (`readOnlyHint`, `destructiveHint`,
`idempotentHint`, `openWorldHint`) — see "Scope permits; annotation asks" in §1 for what they mean and
why they exist alongside, not instead of, the scope column.

| Tool | Arguments | Returns |
|---|---|---|
| `cbx_describe_entity` | `entity` | The entity's fields and JSON types, which are required, which are read-only, the admin listing columns, the available filter names, the active languages, the entity's scope **area** and its `read`/`write` scopes, and — named explicitly — the fields this server *cannot* represent yet. |
| `cbx_list_entities` | `entity`, `filters?`, `limit?`, `offset?`, `language?` | The admin listing columns for matching records, plus the unfiltered `total` for paging. Capped at 200 rows. |
| `cbx_get_entity` | `entity`, `id`, `language?` | One full record. |
| `cbx_create_entity` | `entity`, `fields` | The new record and its id. Rejected, with the model's own messages, if the data is invalid. |
| `cbx_update_entity` | `entity`, `id`, `fields` | The updated record. **Send only the fields you want changed** — everything else keeps its value. |
| `cbx_delete_entity` | `entity`, `id` | Confirmation. One id per call; refused with an explanation if other records reference it. |
| `cbx_delete_product_deep` | `id`, `dry_run?` | A product AND its whole graph — pages, questions, answers, detail panes, examples, its own calculations, their translations and files, plus any child list a customization has added to the product form (`'deleteWithParent' => false` on that property keeps it out). Refused if anything OUTSIDE the product still needs one of those records; a calculation belonging to no product, or used by another one, is left alone. Separate from `cbx_delete_entity` on purpose: that one is refused the moment anything references the record, so a product can never be destroyed by a call that looked routine. No undo — `dry_run` reports the same plan and blockers and changes nothing. |
| `cbx_describe_rules` | `product_id` | Everything needed to write a conditional rule for that product: the vocabulary and its JSON Schema, the operators, worked examples, and every question and answer **with ids and titles**. |
| `cbx_describe_calculations` | `product_id` | The three calculation types, the term vocabulary and which term types this install has, the function registry with parameter counts, the matrix lookup modes, worked examples, and the product's questions and calculations **with ids and titles**. |
| `cbx_get_calculation` | `calculation_id` | One calculation, parent and body, in the form `cbx_set_calculation` accepts. |
| `cbx_set_calculation` | `calculation_id`, one of `formula` / `matrix` / `code` | Replaces the body. A **write** — withheld under `--read-only`. |
| `cbx_export_product` | `product_ids`, `out?` | Writes a transfer package and reports its path, size and what is **not** in it. A **write** — it puts a copy of the catalogue on disk — so it is withheld under `--read-only`. |
| `cbx_check_product_package` | `path`, `mode?`, `new_ids?` | What importing that package would do: the findings graded blocker / warning / notice, the warning kinds, whether it can proceed, and a `nextStep` sentence. Changes nothing, and stays available under `--read-only`. |
| `cbx_import_product_package` | `path`, `mode?`, `new_ids?`, `acknowledge_warnings?` | Imports it — see §3d, which is the part that matters. A **write**. |
| `cbx_describe_install` | — | What this install IS: version, host platform, active languages, both migration tracks, table charset conformance, orphaned translation rows, system variables, and whether it is customized. Read. Call it before proposing an operation, so the proposal is about *this* install. |
| `cbx_describe_customization` | — | The customization layer across every seam — see §3e. Read (needs `catalog:read`). Call it **early**. |
| `cbx_clear_cache` | — | Drops the catalog, assignment and rendered-string caches; the next request rebuilds lazily. Idempotent. Needs **any** write scope — belongs to whoever just wrote something, not to `system`. |
| `cbx_apply_migrations` | `clear_failed_flag?` | Runs every pending ConfigBox migration, core and customization; reports which ran. Idempotent. Needs `system:write`. |
| `cbx_unblock_migrations` | `set_version?`, `track?` | Clears the failed-migration freeze and, if `set_version` is given, moves that track's version pointer so every script at or below it counts as applied **without running** — the same loaded gun as the CLI's `--skip-version`. Needs `system:write`. |
| `cbx_convert_charset` | — | Converts every non-conforming table to `utf8mb4`/`utf8mb4_unicode_ci`. Idempotent; does nothing if `cbx_describe_install` already reports every table conforming. Needs `system:write`. |
| `cbx_purge_orphaned_strings` | — | Deletes `configbox_strings` rows whose owning record no longer exists; reports how many per type. Needs `system:write`. |
| `cbx_set_system_var` | `key`, `value` | Sets one internal bookkeeping variable (a version pointer, a flag) — **not** a store setting; write `settings` with `cbx_update_entity` for that. Needs `system:write`. |
| `cbx_set_order_status` | `order_id`, `status` | Sets an order's status through the order model, so status rules and any side effects still run. Refuses an unknown `order_id` by name rather than reporting a silent success. Needs `orders:write`. |

`entity` is not one set but three, and which you get depends on your scopes.

For the **read** tools it is every registered entity — the same registry the REST surface serves, so
anything `GET /cb-api/v1/` lists can be described, listed and read here under the same name.

The **write** tools are curated, and split by area:

| Area | Entities |
|---|---|
| `catalog` | `product`, `page`, `question`, `answer`, `product-list`, `product-list-assignment`, `calculation` (the parent row only — its body goes through `cbx_set_calculation`), `product-detail-pane`, `example` |
| `store` | `settings`, `store`, `currency`, `tax-class`, `shipper`, `shipping-method`, `payment-method`, `salutation`, `notification`, `country`, `state`, `county`, `city`, `zone` |
| `customers` | `customer`, `address`, `review` |

Each write tool's enum is filtered by the **entity's own declared operations** as well as by your
grant, so an entity appears only under the tools that can actually act on it. `settings` and `store`
are the case that matters: they are singletons — one row, seeded at install, declaring
`list/read/update` — so they appear under `cbx_update_entity` and under neither `cbx_create_entity`
nor `cbx_delete_entity`.

**Some entities are readable and never writable**, whatever scopes you hold, and the reasons are
recorded next to the list in `getEntities()`:

- `order-line`, `order-line-config`, `order-tax-rate` and the `quote-*` triplet are **frozen checkout
  snapshots**. An order's totals were computed from its lines at checkout; editing one line does not
  recompute them, so the order silently stops adding up. Change an order through
  `cbx_set_order_status`.
- `payment` mirrors what a payment service provider reported. Writing one makes the ledger claim
  money moved when none did.
- `calculation-code` and `calculation-matrix` are writable *already*, through `cbx_set_calculation`,
  which validates the body as a whole. Row-level CRUD validates rows and cannot validate that — and
  two ways to write one thing is the drift this surface exists to avoid.
- `chat-*` is advisor telemetry, written by the advisor as it runs. There is no authoring story for a
  conversation that already happened.

Describe first, always: `cbx_describe_entity` → everything else. Field names are not guessable, and a
translatable field is the case worth knowing:

- **Reading**, translations come back as flat, underscore-separated keys — `title_en_GB`,
  `title_de_DE` — from a single record *and* from a list. That is the shared layer's doing, not
  MCP's, so HTTP and the CLI answer identically. (A brief interlude nested them under the field;
  flat won — see §5 of the API contract.)
- **Writing**, the same flat keys are accepted — exactly what an admin form posts.
  Where both arrive the flat value wins. Underscored tags, not dashed — `title_en_GB`, never
  `title-en-GB`.
- **Which languages exist is a property of the store.** `cbx_describe_entity` reports the live set;
  do not hard-code one.

Writes run the model's own pipeline — `prepareForStorage` → `validateData` → `store` — so a record
written through MCP is validated exactly like one saved from the admin form, and a rejection carries
the same wording an admin would see ("Field Title cannot be empty.").

---

## 3. Schemas are generated, not written

Every tool's field list comes from the model's own `getPropertyDefinitions()` — the same metadata the
mini-ORM already uses to build admin forms and listings. Nothing is hand-maintained, so the schemas
follow the models instead of drifting from them.

Two consequences worth knowing:

**Translatable fields expand per language.** `KenedoPropertyTranslatable` reads
`<property>_<tag_key>` off the request (`KenedoLanguageHelper::getTranslationKey()` — underscore
keys like `title_en_GB`), not a bare `<property>`. So a store with en-GB and de-DE
active describes `title` as two fields:

```json
"title_de_DE": { "type": "string", "description": "Title (de-DE)" },
"title_en_GB": { "type": "string", "description": "Title (en-GB)" }
```

A schema advertising a plain `title` would be describing a field that stores nothing.

**Unrepresentable fields are named, not hidden.** Of the thirty-odd Kenedo property types, the generator maps
the scalar ones (`string`, `number`, `boolean`, `published`, `ordering`, `datetime`, `id`, and
`dropdown`/`radio` — the latter two carrying their `enum` when the choice set is static). What is left
— `childentries`, `image`, `file`, `multiselect`, `shapediver*` — is reported under
`unsupportedFields` with its type, rather than emitting schemas that would misrepresent them.

**Some fields only apply on some hosts, and that is NOT the same as unsupported.** ConfigBox runs on
Joomla, WordPress, Magento 2 and standalone from one codebase and one set of migrations, so every
field exists everywhere — same column, same schema, writable through these tools wherever you are.
What varies is whether the host has the concept: a product's `baseprice` means nothing on Magento,
where the Magento product owns the price. Such a field is hidden in the admin form there, can never
be `required` there, and nothing reads it — but writing it still succeeds and the value round-trips.

`cbx_describe_entity` tells you twice: `x-configbox-platforms` lists the hosts the field applies to,
and the field's `description` says so in prose. If you are writing on Magento and a value seems to
have no effect, check that list before assuming a bug.

A few of those fields also start a **newly created** record at a different value on the host where
they do not apply — `x-configbox-platform-defaults` names it beside the schema's `default`. It only
bites if you **omit** the field on create; a value you send is the value you get, everywhere.

(Until 2026-08, describe advertised the union of all platforms while the write path accepted only
the running one — so on Magento `baseprice` was described and then refused. Both now derive from the
same shape, which is why the field is present everywhere rather than absent somewhere.)

Two types are neither scalar nor unsupported:

- **`rule`** gets a schema of its own, so rules are authored structurally. See §3b.
- **`join` and `calculation`** are REFERENCES: the stored value is another record's id, so they are
  described as integers, with the description naming the `cbx_list_entities` call that finds valid
  ids and noting that an optional one is cleared with `null` (never `0` — these are real foreign
  keys). Treating them as unsupported was a bug with teeth: a question's `page_id` and an answer's
  `question_id` are joins, so an assistant reading the description concluded it could not create
  either, while `cbx_create_entity` had been accepting them all along. A calculation's `product_id`
  is a join too, which made calculations uncreatable through the documented surface.

**`required` respects `appliesWhen`.** A field that applies only to one question type is not required
for the others, so only unconditionally-required fields are listed. Otherwise the schema instructed
callers to send `calendar_days_min` with a set of radio buttons.

Dropdowns whose choices are dynamic (`modelClass`/`modelMethod`-driven rather than a static map) stay
free strings, because their valid set is not knowable from the definition; the model validates them.

---

## 3b. Rules

A question or an answer can carry a conditional rule that decides whether it applies. Rules are the
thing that makes a configurator more than a form, so an assistant that cannot write them can build a
catalog but not a product.

**The stored format is not the authored format.** CBX stores a rule as an infix token stream —
a flat array where conditions alternate with `combinator` items, brackets are bare nested arrays with
no marker, and negation is a sentinel that only means anything in first position:

```json
[ {"type":"QuestionProperty","questionId":"11","field":"selectedAnswer.id","operator":"==","value":"22"},
  {"type":"combinator","kind":"and"},
  [ {…}, {"type":"combinator","kind":"or"}, {…} ] ]
```

That is fine for an editor building it left to right and hostile to a model emitting it: every
combinator is positional, and a missing one changes the meaning silently. Worse, a malformed rule
does not throw — it evaluates to **false**, which hides the question it governs. A rule authored
wrongly looks exactly like one authored correctly until someone notices a missing option in the shop.

So the MCP surface speaks a nested form where the structure carries the meaning, and
`ConfigboxRuleAuthoringHelper` (`helpers/ruleauthoring.php`) translates both ways
(`ConfigboxMcpRulesHelper` survives only as a deprecated alias — which `helpers/mcp.php` itself
still calls by the old name):

```json
{"all": [ {"question": 11, "operator": "==", "value": 22},
          {"any": [ {"question": 13, "operator": "==", "value": 30},
                    {"question": 14, "operator": "==", "value": 40} ]} ]}
```

| Form | Means |
|---|---|
| `{"question": <id>, "operator": "==", "value": <answerId>}` | that question has that answer selected |
| `{"calculation": <id>, "operator": ">=", "value": 100}` | that calculation's result compares so |
| `{"customerGroup": "<field>", "operator": "==", "value": …}` | a field of the customer's group compares so |
| `{"all": [ … ]}` / `{"any": [ … ]}` | AND / OR, nesting freely |
| `{"not": … }` | inverts the rule — **top level only** |
| `null` | no rule: always applies |

`not` is top-level only because the engine is: `getConditionsCode()` looks for negation at position 0
and wraps the *whole* rule. A nested one would negate more than asked, so it is refused rather than
quietly mis-encoded.

**Validation refuses what cannot mean anything.** A rule referencing another product's question is
well-formed, stores fine, and can never be satisfied — so whatever it governs disappears with no
error anywhere. That, an answer that is not the question's, an unknown operator and a nested `not`
are all rejected with a message saying which.

**Reading a rule back.** `cbx_get_entity` returns the authoring form, so a rule can be read, edited
and sent back. `cbx_list_entities` decodes it the same way — `rules` is a listing column on both
questions and answers, and one field name must not mean the authoring form from one tool and the
engine's token stream from another. When a stored rule cannot be expressed in this vocabulary — a
custom condition type, `and` and `or` mixed on one level without brackets — it comes back as
`{"raw": "…", "reason": "…"}` instead, and `encode` accepts that unchanged so it round-trips
untouched. The decision is made by decoding, re-encoding, and comparing: the vocabulary is only
promised when the re-encoding *means the same thing*.

That comparison is semantic rather than byte-for-byte, deliberately. Rules in a real store were
written by several generations of editor and vary in key order, in whether ids are JSON numbers or
strings, and in whether a combinator says `"or"` or `"OR"`. None of that changes meaning, and a byte
comparison would have sent essentially every existing rule back as raw. What is therefore not
promised: re-storing a rule you read may normalise its key order and scalar types. What is promised:
it will evaluate identically.

**Custom condition types work too.** A store adds conditions by dropping a `CustomCondition<Name>`
class into its customization folder, and those declare whatever fields their author chose (efka's
`Dimensions` adds a `subValue`). Any type — built-in or custom — can be written by name:

```json
{"condition": "Dimensions", "questionId": 11, "operator": ">=", "value": 100, "subValue": "w"}
```

The remaining keys travel verbatim. `cbx_describe_rules` lists the type names this install has, and
says plainly that it can name them but **not** describe their fields: the discovery seam
(`ConfigboxCondition::getConditionTypeNames()`) reports names only, and a custom class' fields are
its own business. Read the class, or an existing rule that uses it.

The friendly forms are used on read only when the condition is exactly what they model — known type,
known operator, no extra fields. A built-in condition carrying an extra key decodes to the generic
form instead, because a friendly form would silently drop it.

---

## 3c. Calculations

A calculation is a named, product-scoped number the engine can evaluate — a price, a weight, a bound
on an input, a term inside a rule. Three kinds, sharing nothing but the parent row:

| Type | Body | Stored in |
|---|---|---|
| `formula` | a sequence of terms evaluated as an expression | `#__configbox_calculation_formulas` |
| `matrix` | a lookup table with a row and a column parameter | `#__configbox_calculation_matrices` + `_data` |
| `code` | PHP, evaluated | `#__configbox_calculation_codes` |

The **parent** (`name`, `product_id`, `type`) is an ordinary entity — create it with
`cbx_create_entity` on `calculation`. The **body** goes through `cbx_get_calculation` /
`cbx_set_calculation` — no longer because the body models lack metadata (they do not: `CalcCodes`
and `CalcMatrices` carry full property definitions and sit in the registry as `calculation-code`
and `calculation-matrix`, with committed schemas, so the REST surface serves both with default
CRUD), but because these tools treat the body as **one coherent object**: the term list of a
formula, a matrix with its axes and every cell, a code body parse-checked before it can be stored.
Row-level CRUD on the body tables validates rows; it cannot validate the whole. Call
`cbx_describe_calculations` first.

### Formula

```json
{"terms": [ {"question": 413}, {"operator": "*"}, {"number": 3} ]}
```

Terms: `{"number": n}`, `{"operator": "+|-|*|/"}`, `{"question": id, "field"?, "fallback"?}`,
`{"calculation": id}`, `{"customerGroup": field, "value"}`, `{"group": [ … ]}` for a bracket, and
`{"function": "round|min|max", "parameters": [[ …terms… ], …]}` — a function parameter is itself a
term list, so an argument can be any expression. `cbx_describe_calculations` reports the function
registry with each one's required and optional parameter counts, read from the term class rather than
hardcoded.

**The stored shape is flat** (`{"type": "QuestionProperty", "questionId": …}`), and that is load
bearing. A LEGACY shape nests the fields under `data`, and the evaluator still accepts it —
`getTermsCode()` flattens `data` before dispatching. But the reference scanners do **not**:
`scanTermsForQuestion()` hands the raw term to `containsQuestionId()`, which reads
`$termData['questionId']` directly. A formula stored the legacy way evaluates correctly and is
invisible to "is this question still in use?", so the question it depends on can be deleted out from
under it. Flat is the only shape right for both. (Reads still *accept* the legacy shape — the
round-trip comparison normalises it, or every editor-written formula would come back raw.)

### Matrix

```json
{"rows": {"question": 11}, "columns": {"calculation": 7}, "lookup": "nextHigher",
 "cells": [{"row": 100, "column": 200, "value": "12.50"}]}
```

**What an axis can be**: a question, a calculation, or nothing. That is the whole set.

**What the cell coordinates are**: numbers, not labels. The engine turns each axis into a number —
`floatval($selections[$questionId])` for a question (so an answer id for an answer-taking question,
the typed number for a numeric one), or the calculation's result — and then walks the cells
comparing coordinates against it. `lookup` decides how:

| `lookup` | Comparison | Meaning |
|---|---|---|
| `exact` | `==` | the coordinate must match; `round` (if set) is applied to the input first |
| `nextHigher` | `>=` | cells are scanned low to high, first match wins |
| `nextLower` | `<=` | cells are scanned high to low, first match wins |

So cells are **breakpoints**, and `round` only does anything in `exact` mode. A one-dimensional table
is expressed by leaving the other axis `null` and giving every cell coordinate `0` on it.

### Code

```json
{"code": "A * 2", "questions": {"a": 11}}
```

`A`–`D` are placeholders for up to four questions' selections; `questions` binds them. The engine
substitutes them only when **surrounded by spaces**, and also understands `Total`,
`TotalRecurring`, `QuestionSelection(id)`, `QuestionPrice(id)`, `QuestionPriceRecurring(id)`,
`QuestionProperty(...)` and `Calculation(id)`.

**It must be an EXPRESSION, not a statement.** The engine wraps it as `return ( … );`, so `A * 2` is
right and `return A * 2;` is a syntax error. That is not a cosmetic distinction:
`calculateByCode()` guards its `eval` with `catch (Exception)`, and a PHP syntax error is a
**ParseError**, which is an `Error` and not an `Exception` — it sails past that catch and takes down
whatever request asked for the price. A malformed calculation would break the storefront, not just
itself. `cbx_set_calculation` therefore parse-checks the code before storing (inside a closure that
is defined and never called, with the placeholders stood in as `0`) and refuses it with a message
saying what to write instead.

**On authoring PHP at all.** A code calculation is arbitrary PHP that runs on every price calculation
for its product, so writing one is materially "run this code on the site". It is exposed anyway,
because it does not move the trust boundary this server already documents in §1: whoever can start it
can already run `configbox:run-task` or edit the files. It needs **`catalog:write` AND `code:write`**
together — `code:write` alone is never enough (rule 4, §1) — and no preset grants `code:write`, so it
has to be given by name. `--read-only` withholds it along with everything else, and the tool
description says plainly what it is. If you want an assistant authoring formulas and matrices but not
PHP, grant `catalog:write` and simply never grant `code:write`: everything else in the area still
works, and only `cbx_set_calculation`'s `code` body and a package import that carries code are
refused.

### Custom terms

Exactly like custom rule conditions: a store drops a `CustomCalcTerm<Name>` class into its
customization folder, and `ConfigboxCalcTerm::getTermTypeNames()` discovers it. Any term type can be
written by name with its fields passed verbatim:

```json
{"term": "Dimensions", "questionId": 11, "subValue": "w"}
```

`cbx_describe_calculations` lists the available type names and flags which are custom. As with
conditions, it can name them but not describe their fields.

### Attaching a calculation

`calcmodel` and its siblings (`calcmodel_recurring`, `calcmodel_weight`, `calcmodel_id_min_val`,
`calcmodel_id_max_val`) are `calculation`-typed references, so they are ordinary writable integers —
see §2. That closes the loop: create the calculation with `cbx_create_entity`, give it a body with
`cbx_set_calculation`, then point a question at it with `cbx_update_entity`, and the runtime prices
with it. Send `null`, not `0`, to detach one.

Until this was fixed the feature was a dead end — a calculation could be authored but nothing could
be made to reference it, so it never affected a price.

---

## 3d. Moving products between installs

Three tools mirror the Product Transfer screen and the `configbox:product:*` commands:
`cbx_export_product` writes a package, `cbx_check_product_package` says what importing it would do, and
`cbx_import_product_package` does it. The mechanics — the two modes, the two-pass import, what the
package does and does not carry — are in
[`com_configbox_product_transfer.md`](https://docs.configbox.at/docs/technical/product_transfer). What is specific to this
surface is **who is allowed to say yes**.

The screen shows the findings and collects a tick per kind of warning. The CLI, having nobody to ask,
takes `--force`. An MCP caller is a model, and the only thing between *"the user asked me to sync
staging to live"* and a deleted product is that the import tool refuses to proceed past a warning
unless the caller **asserts** a person accepted it:

| Level | `cbx_import_product_package` |
|---|---|
| **blocker** | Always refuses. `acknowledge_warnings` does not apply — a blocker is not a warning. |
| **warning** | Refuses unless `acknowledge_warnings: true`. The refusal **lists the warnings**, so a caller that has shown them to nobody cannot claim it did not know what it was accepting. |
| **notice** | Returned as information. |

Two consequences worth stating, because both are deliberate:

- **`cbx_check_product_package` cannot set the acknowledgement.** It is the read-only half on purpose,
  and it stays available under `--read-only` — it is the tool an assistant should reach for first, and
  refusing it would push it towards the one that writes. Its `nextStep` says in words what to do:
  whether the package is importable, whether a person has to accept warnings first, and — when an id
  clash is blocking in `exact` mode — that `new_ids: true` would resolve it.
- **`new_ids` only means anything in `exact` mode**, where the package keeps its record ids. Where an
  id it wants belongs to a *different* product on this install, `new_ids` imports that item under a new
  id and rewrites the imported product's own references to match, instead of overwriting somebody
  else's record. Without it such a clash is a blocker.

An `exact`-mode import deletes records the package lacks, and deleting a question takes any saved cart
configuration referring to it. That is why "no answer" has to mean no.

**When one of the three fails, the error is assembled by the tool rather than left to the generic catch.**
`callTool()` reports `$e->getMessage()`, which for a system failure is an apology plus an identifier and
for anything deep in an import drops the trail. So these tools answer with the trail, what already
landed, and where the logs are:

> The import failed: import of product 20931 in exact mode -> import of Pages ID 71 -> import of
> Questions ID 402 -> import of Answers ID 118 -> Storing a record of model "ConfigboxModelAnswers": A
> system error occurred. See identifier "yxlv8pi1tj" in the ConfigBox error log. 1 of 2 product(s) were
> imported before the failure and are STILL PRESENT (each product commits on its own): Workbench
> (CBX-DEMO-0001) as product 1. Logs are in …

Only these three tools do that — the generic catch is shared with every other tool and their specs assert
on its shape. See [product transfer §7c](https://docs.configbox.at/docs/technical/product_transfer) for the log channels, and note
that a caller-fixable failure (a path that is a directory, a target that is not writable) travels as its
own sentence with no log hint attached, because there is nothing to look up.

---

## 3e. The customization layer

A CBX store is routinely not stock. It can add question types, rule conditions, calculation terms,
whole models, fields on existing models, view and template overrides, observers, boot-time system
overrides and its own migration track. To a model reading generic guidance, none of that is visible —
and a custom question type is exactly the case where guessing produces a rejected write and a dead
conversation. `cbx_describe_customization` reports it, and **it should be called early**.

The payload is honest about being two halves of unequal strength, because they are:

**Detected is reliable.** The files exist in this install or they do not, and it is the framework's
own discovery seams that are asked — `ConfigboxCondition::getConditionTypeNames()`,
`ConfigboxCalcTerm::getTermClassNames()`, and for question types the model's own `question_type`
choices plus `getCustomQuestionTypes()`, which is what the admin dropdown is built from. Asking those
rather than scanning directories is deliberate: a second discovery would eventually disagree with the
admin UI, and the disagreement would surface as a type an assistant can name and an admin cannot
select.

**Understood is partial, and how partial depends on the seam.**

- **Question types** carry the AI self-description contract (`getSelectionFormatHint()`,
  `getSelectionSchema()` — see `docs/customization/com_configbox_question_types_and_ai.md`), so a
  custom one can be driven as fluently as a stock one. The payload's `selfDescribing` says which
  types actually implement it, by reflection rather than by assumption. Where it is true, read a real
  question of that type through the runtime API for its live format and bounds.
- **Custom conditions and calc terms have no such contract.** The discovery seam reports names only,
  and the fields a custom class declares are its own business. The payload says exactly that instead
  of inventing a schema — a fabricated one would be worse than the silence. They are still *writable*
  by name, because unknown keys travel verbatim:
  `{"condition": "Dimensions", "questionId": 11, "operator": ">=", "value": 100, "subValue": "w"}`.

Two traps this tool had to learn, both worth knowing if you extend it:

- **Origin cannot be read off a class name.** The obvious test — a `CustomCondition` prefix means
  custom — is wrong, and wrong in the direction that mislabels every real customization as core: the
  component ships `CustomConditionExample.php` inside its *own* `classes/rule_condition_types` as the
  worked example customizations are written from. The prefix describes authoring style. The
  **directory** decides. `class_exists()` cannot decide it either — these classes load lazily, so a
  real custom condition may simply not be loaded yet when you ask.
- **A stock question type has no class at all.** The stock twelve are the base class plus a view; it
  is normally a *custom* type that ships a `ConfigboxQuestion<Type>` subclass. So `class: null` is the
  expected answer for a stock type, not a gap — and a scan for classes finds zero of the twelve.

**Two migration tracks.** A customized store versions its own migrations separately from ConfigBox's.
`cbx_describe_install` reports `installed_customization_version` and `pending_customization` beside
the core ones; the customization scripts run *after* the core ones, against the already-migrated
schema.

**Added fields need nothing special.** A `model_property_customization` that adds a field to a stock
model produces a real field on that entity, so `cbx_describe_entity` reports it like any other and it
is written like any other.

---

## 4. How writes work (and the traps in it)

Kenedo has no "make me a record from an array" seam. Every property reads *itself* off the request
(`KenedoProperty::getDataFromRequest`), and that is the only code that knows a translatable field is
really one request key per language, that a boolean arrives as `"1"`, and so on. So rather than
reimplement that per property type and drift from it, the write tools put the caller's fields where
the properties look, let the model read them, and put the request back — using
`KRequest::getSnapshot()`/`restoreSnapshot()`, which is what makes that safe.

Several things that are not obvious, each found by a create or update actually failing:

**Partial updates need the current record as a seed.** `getDataFromRequest()` reads *every* property,
so a field the caller did not send would come back as its default and overwrite what is stored. An
update therefore seeds the request with `getRecord($id)` first, then overlays the caller's fields, so
"unsupplied" reads as "unchanged". `getRecord()` is the right seed because the translatable property
appends its per-language keys to it — the exact keys the properties will look for.

*With one caveat worth knowing*: those per-language keys are resolved through CBX's cache, and
a CLI process can lag behind data the web process just wrote (see §5b). While it does, `title-en-GB`
comes back `""` even though `title` resolves correctly, and a partial update that does not resend the
titles fails validation with "Field Title cannot be empty." It is a staleness window, not a permanent
state — but it is why a record created seconds earlier over HTTP can refuse a rules-only update.

**`ALLOW_RAW` used to eat backslashes — fixed at the source.** `KenedoProperty::getDataFromRequest()`
ran an unconditional `stripslashes()` over every property tagged `ALLOW_RAW`, which `rules` and a
code calculation's `code` both are. That was magic_quotes_gpc compensation, and magic_quotes was
removed in **PHP 5.4** — so on any supported PHP it had no added slashes to strip and simply ate real
ones.

The KRequest filters are NOT the problem and were never the problem: the `raw` sanitation case
returns the value untouched, and KRequest already handles the historic case properly, stripping only
`if (ini_get('magic_quotes_gpc'))`. The damage was the second, unconditional strip layered on top.
`KenedoPropertyTranslatable`'s own ALLOW_RAW branch never had it, which is the other half of the
evidence that it was an artifact rather than a contract.

Measured before removing it: a rule whose value contained a quote reached the database as **invalid
JSON** (`\"` → `"`), and a code calculation lost every backslash it had. Nothing compensated on the
way in — the admin form posts a plain hidden input, verbatim. Invisible when it goes wrong, too: a
malformed rule does not throw, it evaluates false and hides the question it governs.

The call is gone from `KenedoProperty` and from `KenedoPropertyJson`, which had copied it. Callers
need no compensation.

**Values stored before this fix were saved *through* the strip and may already be missing
backslashes.** Removing it does not repair them. A legacy rule or calculation that looks subtly wrong
is worth reading with that in mind rather than treating it as a new bug.

**Creates go through `initData()`, not `getDataFromRequest()`.** `initData()` is the framework's own
"new record" seam — the one `KenedoView` uses to render an empty admin form. It reads the request the
same way and then fills what is still empty from each property's declared default. Without it,
`product.visualization_type` arrives NULL and its NOT NULL column rejects the insert. The admin form
never notices because it posts every field.

**Two corrections are applied on top of `initData()`, and both mirror what a form actually sends:**

- *A declared default that the property would itself reject is cleared.* `initData()` applies
  defaults without checking them against the property's own rules, and definitions can disagree with
  themselves — `product.detail_panes_display_method` declares `default => 'accordeon'` while its
  `choices` list contains only `'tabs'`, so a seeded record fails its own validation. The field is
  `invisible`, so the admin form posts nothing for it and an empty non-required value passes; clearing
  it reproduces that rather than inventing a value. Logged as a warning, because the model definition
  is the thing that wants fixing.
- *NULLs on NOT NULL columns become empty strings.* A browser never posts PHP NULL — an untouched
  input posts `""`, and an unrendered field posts nothing, which also reads as `""`. `initData()` is
  the only thing producing NULL, and those NULLs reach `insertObject()` verbatim
  (`product.shapediver_model_data`). Nullability is asked of the schema (`SHOW COLUMNS`, memoised),
  **not** inferred: `product.taxclass_id` is a *nullable* foreign key, and blanking it produced `0`,
  which no tax class has.

---

## 5. Architecture

The same core/wrapper split as the CLI suite: the whole implementation is platform-agnostic, and each
host contributes only the few lines that are genuinely host-specific.

```
host repo   thin console wrapper           Joomla: administrator/components/com_configbox/
                                                   src/Console/McpCommand.php
              │ boots Kenedo
              │ supplies the argv to re-invoke this host's CLI for a worker
              ▼
component   helpers/mcp.php  (ConfigboxMcpHelper)
              ├─ JSON-RPC 2.0 / MCP: initialize, ping, tools/list, tools/call
              ├─ tool registry + schema generator
              └─ tool execution against KenedoModel
```

`ConfigboxMcpHelper::runStdioLoop()` takes a *dispatcher* callable, which is how the host injects
process spawning without the core knowing anything about the host.

### Why a fresh process per tool call

Correctness, not performance:

1. Four controllers end the request outright by design — `adminorderslip`, `invoice`, `quotation`,
   `ipn`, where a file download or a payment-webhook body *is* the whole response. A future tool
   reaching one of those would take a long-running dispatcher down with it.
2. A fatal error inside one tool call kills the whole server; a child process contains it.

Three earlier reasons no longer apply, and are recorded here so nobody re-derives them from old
notes. `KenedoController::ajaxDelete()` used to end in `die()` in the base class (the JSON tasks now
echo and return). `runTask()` used to leak its injected parameters and the impersonated user into the
next call (it now snapshots and restores both). And the strongest of them — that models memoised
their query results per call signature, so a long-running server would answer a repeated read with
rows that a write had since changed — went away when **record memoization was removed**:
`getRecord()`/`getRecords()` query every time, and every write path (`store()`, `delete()`,
publishing, re-ordering) additionally forgets the derived caches for the table it touched
(`ConfigboxAssignmentsHelper::forgetForTable()`, `KenedoPropertyTranslatable::forget()`). A
long-running `--in-process` server therefore reads its own writes. The two reasons above stand on
their own, so the design is unchanged.

That correction is what lets the **test suite** hold one `--in-process` server open per Playwright
worker instead of booting one per call (`tests/support/mcp-client.ts`): 3.7s per call became ~50ms,
and the authoring specs went from 7.2 to 1.0 minutes. The isolation reasons above still apply to the
production server, which is why only the test client does this.

### What a long-lived server is, and is not, exposed to

Measured rather than reasoned about, because "it reads its own writes" is a much weaker claim than
"it reads *everyone's* writes", and only the second one makes a long-lived server safe.

- **Reads never touch the runtime cache.** Tools go through `KenedoModel` directly; the only
  `ConfigboxCacheHelper` calls in `helpers/mcp.php` are `purgeCache()` on the WRITE path. So the
  product/calculation cache is not on a read path at all.
- **The one real per-request memo is dropped per call.** `ConfigboxAssignmentsHelper` memoises
  id-to-id relations, and `dispatchInProcess()` calls `forget()` before every tool call for exactly
  that reason.
- **`getCacheGeneration()` DOES memoise once per process and never re-reads** — a genuine
  long-running-process wart, since the design assumes other processes pick a bump up "on their next
  request". It is harmless here: `purgeCache()` writes a fresh `uniqid()` stamp rather than deriving
  it from the memo, so a bump still reaches every other process; the possibly-stale prefix is only
  used to drop this process's own APCu entries, and a CLI process has none that matter.
- **Verified under concurrency**, with a second process and the web tier writing while a backend
  suite ran against the same site: renames, inserts, deletes and a whole web-built product structure
  were all visible immediately, in both directions. `tests/specs/api/mcp-session-freshness.spec.ts`
  keeps asserting it, so re-introducing a cache on a read path fails a test instead of quietly making
  the suite assert against stale rows.

A fresh process is the only cheap isolation the framework offers. The worker boots with automatic
migrations suppressed — the server process already applied anything outstanding, and a migration
firing inside a short-lived worker is the last place you would want to meet it. With that in place a
tool call costs well under a second on a local DDEV install.

A worker that wedges is killed after 120s and the call comes back as a tool error, so one bad call
cannot hang the session. Note this needs a real poll loop: `stream_set_timeout()` has no effect on
`proc_open()` pipes, and draining stdout to EOF before reading stderr deadlocks as soon as a worker
emits enough stderr to fill the buffer (a single PHP deprecation dump will do it) — so both pipes are
read as they fill, under one deadline.

---

## 5b. The CLI sees a different cache than the web

Worth knowing before trusting any read this server makes about data written elsewhere.

CBX's catalog cache is APCu-backed where APCu is available and **file-backed otherwise**. PHP
CLI usually ships `apc.enable_cli=0`, so the MCP server — which is a CLI process — falls to the file
cache, while the web workers use APCu. `purgeCache()` bumps a shared generation file to bridge them,
but the two views are not instantaneous.

Measured on a freshly seeded product: the **web request saw 12 questions and the CLI saw none**,
repeatably, for as long as a whole test run; the same product's translations came back empty from the
CLI and correct from the web. Both caught up later.

Two consequences:

- **Authoring tools must read through the models, not the runtime cache.** `cbx_describe_rules` and
  the rule validator originally used `ConfigboxCacheHelper::getAssignments()` — the natural way to ask
  "which questions does this product have" — and returned nothing for a product that plainly existed.
  They read through `ConfigboxModelPages`/`AdminQuestions`/`AdminAnswers` now, like every other
  tool here. Use the models when adding tools; the cache is for the storefront.
- **Do not build a test fixture over HTTP and read it back through MCP in the same breath.** That is a
  race in the harness, not in the code under test. `tests/specs/api/mcp-rules.spec.ts` builds its
  product *through MCP* for exactly this reason.

Whether the two caches should be reconciled properly is a separate question, and a real one — it
affects every MCP read, not just rules.

---

## 6. Extending it

- **A new entity** — add it to `ConfigboxMcpHelper::getEntities()` (tool-facing name => model class).
  Its schema generates itself. Keep the writable list to genuine catalog entities: much of what the
  component's other controllers (roughly ninety of them) manage is store configuration, carrying
  property types the generator would misrepresent.
- **A new property type** — add it to `getScalarTypeMap()` only if a JSON scalar represents it
  *honestly*. If it does not, leaving it in `unsupportedFields` is the correct outcome, not a gap.
  Read the type's article under `docs/technical/property-types/` first: what a type stores is often
  not what its column suggests. `multiselect` and `translatable` are `derived` (their values are not
  in the model's table at all), `groupPrice` and `calculationOverride` are JSON lists in one `text`
  column, and `paymentmethodparams` holds `key="value"` lines whose shape depends on the chosen
  connector. `rule` is the one type that earns a structured sub-schema instead of a scalar.

---

## 6b. The chat advisor and this server

The visitor-facing **chat advisor** (`docs/technical/com_configbox_chat_advisor.md`) is the other
AI surface over the same store — with the opposite trust model. This server runs with operator
privileges over the catalog; the advisor runs inside an anonymous visitor's session over that
visitor's own cart position, through the runtime API, and never through MCP. Keep it that way:
this server is a stdio process an operator starts, and must never be reachable from a visitor
request.

The two surfaces are designed to close each other's loops:

- **Content**: the advisor's product knowledge is the catalog's prose — descriptions and detail
  panes. `product-detail-pane` is writable here precisely so an authoring assistant can close the
  advisor's `content` wishlist items (visitor asked for specs the store doesn't state) by writing
  the missing pane; the advisor picks it up from the next conversation on.
- **Telemetry**: the advisor's journal is readable here (`chat-conversation`, `chat-turn`,
  `chat-event`, read-only), so an analysis agent can mine real visitor conversations with real
  queries. The advisor's `feature-request` wishlist items are field evidence for what this
  server and the runtime API should grow next.
- **One registry**: the entity names and schemas served here are the same registry the REST
  surface and the type generator use — there is no second description of the store anywhere in
  the AI stack.

---

## 7. See also

- `docs/technical/com_configbox_chat_advisor.md` — the visitor-facing AI surface, and §11 there
  for the full picture of how the two dock.
- `docs/technical/com_configbox_mcp_server_internals.md` — how the server is built, and how a
  customization's model reaches the tool surface.
- `docs/technical/com_configbox_cli_commands.md` — the CLI suite this reuses the architecture of.
- `docs/technical/com_configbox_product_transfer.md` — how the transfer itself works; §3d above only
  covers what this surface adds to it.
- `docs/technical/com_configbox_kenedo_view.md` — the property/model metadata the schemas come from.
- `docs/technical/com_configbox_property_types.md` — **what each property type actually stores**, which
  is what decides whether it maps to a JSON scalar, a sub-schema, or `unsupportedFields`. One article
  per type under `docs/technical/property-types/`.
- `docs/technical/com_configbox_type_generation.md` — the other consumer of the same definitions.
