Skip to main content
Version: 4.0 preview

MCP server

Version
4.0 preview
Updated
View markdown

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.

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.

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.

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:

HostCommand
Joomlaphp cli/joomla.php configbox:mcp
WordPresswp configbox mcp (wrapper not written yet)
Magento 2bin/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:

# 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.

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:

OptionEffect
--read-onlyNarrow 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.
--scopeNarrow 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.
--presetA named scope set — author, fulfilment, insights, operator, everything — expanded when the server starts and combined with --scope. No preset includes code:write.
--in-processRun 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.
--callInternal. 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:

AreaHoldsGrantable levels
catalogProducts, pages, questions, answers, lists, detail panes, examples, rules, formula and matrix calculations.read, write
storeSettings, store identity, currencies, tax classes, shipping, payment methods, notifications, geography.write only — every token already reads it, see the floor below
customersCustomer records, addresses, reviews, parties.read, write
ordersSales orders, quotes, their lines and tax rates, payments.read, write
conversationsAdvisor conversations, turns, events, recommendations, outreaches.read, write
codePHP the engine evaluates: code calculations, and transfer packages that carry them.write only — reading a code calculation is reading the catalog
systemThe 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

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.

ToolArgumentsReturns
cbx_describe_entityentityThe 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_entitiesentity, filters?, limit?, offset?, language?The admin listing columns for matching records, plus the unfiltered total for paging. Capped at 200 rows.
cbx_get_entityentity, id, language?One full record.
cbx_create_entityentity, fieldsThe new record and its id. Rejected, with the model's own messages, if the data is invalid.
cbx_update_entityentity, id, fieldsThe updated record. Send only the fields you want changed — everything else keeps its value.
cbx_delete_entityentity, idConfirmation. One id per call; refused with an explanation if other records reference it.
cbx_delete_product_deepid, 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_rulesproduct_idEverything 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_calculationsproduct_idThe 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_calculationcalculation_idOne calculation, parent and body, in the form cbx_set_calculation accepts.
cbx_set_calculationcalculation_id, one of formula / matrix / codeReplaces the body. A write — withheld under --read-only.
cbx_export_productproduct_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_packagepath, 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_packagepath, mode?, new_ids?, acknowledge_warnings?Imports it — see §3d, which is the part that matters. A write.
cbx_describe_installWhat 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_customizationThe customization layer across every seam — see §3e. Read (needs catalog:read). Call it early.
cbx_clear_cacheDrops 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_migrationsclear_failed_flag?Runs every pending ConfigBox migration, core and customization; reports which ran. Idempotent. Needs system:write.
cbx_unblock_migrationsset_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_charsetConverts 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_stringsDeletes configbox_strings rows whose owning record no longer exists; reports how many per type. Needs system:write.
cbx_set_system_varkey, valueSets 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_statusorder_id, statusSets 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:

AreaEntities
catalogproduct, page, question, answer, product-list, product-list-assignment, calculation (the parent row only — its body goes through cbx_set_calculation), product-detail-pane, example
storesettings, store, currency, tax-class, shipper, shipping-method, payment-method, salutation, notification, country, state, county, city, zone
customerscustomer, 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 — prepareForStoragevalidateDatastore — 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:

"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:

[ {"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):

{"all": [ {"question": 11, "operator": "==", "value": 22},
{"any": [ {"question": 13, "operator": "==", "value": 30},
{"question": 14, "operator": "==", "value": 40} ]} ]}
FormMeans
{"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
nullno 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:

{"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:

TypeBodyStored in
formulaa sequence of terms evaluated as an expression#__configbox_calculation_formulas
matrixa lookup table with a row and a column parameter#__configbox_calculation_matrices + _data
codePHP, 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

{"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

{"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:

lookupComparisonMeaning
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

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

AD 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:

{"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. 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:

Levelcbx_import_product_package
blockerAlways refuses. acknowledge_warnings does not apply — a blocker is not a warning.
warningRefuses 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.
noticeReturned 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 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.mdwhat 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.