The storefront MCP server (headless, agentic shopping)
- Version
- 3.x
- Updated
A customer's AI assistant can shop the store on their behalf — browse the catalog, configure a product through its rules, watch the price move, and work the cart — over the Model Context Protocol, with no browser and no rendered page anywhere in the loop.
This is the storefront half of CBX's MCP surface. The other half — authoring the catalog, running the store — is the back-office MCP server, and the two are deliberately unreachable from each other (§4). If you are integrating an assistant that manages a store, read that document instead; this one is about assistants that shop it.
Scope today: browse · configure · price · cart. Checkout is not here — addresses, delivery, payment and the PSP bridge still render pages, and exposing them is a separate phase. An agent takes the customer to the point of paying and hands them a URL.
All paths below are relative to the component root (components/com_configbox/ on Joomla).
1. The shape of it
One HTTP endpoint, POST /cbx-api/mcp, speaking JSON-RPC 2.0 over Streamable HTTP. The protocol
version is negotiated in-band (2025-06-18, 2025-03-26, 2024-11-05), so the URL carries no
version segment — and could not, because the URL is the OAuth audience: moving it would invalidate
every token ever issued.
2. Connecting
The self-service route (OAuth 2.1) — what a real agent uses
The MCP authorization spec is OAuth 2.1, and CBX hosts its own authorization server: it is a platform-neutral component, and none of its hosts (Joomla, WordPress, Magento 2) ships an AS to borrow. A client needs to be told nothing but the endpoint:
https://<your-store>/cbx-api/mcp
From there it discovers everything else. The unauthenticated 401 carries an RFC 9728 challenge:
WWW-Authenticate: Bearer error="invalid_token",
resource_metadata="https://<store>/.well-known/oauth-protected-resource/cbx-api/mcp"
which names the authorization server, whose own metadata
(/.well-known/oauth-authorization-server) names /cbx-api/oauth/{authorize,token,register}.
The client registers itself (RFC 7591, public clients only), sends the customer to /authorize, and
the customer signs in and consents in their own browser. The agent never sees the password.
| Step | What happens |
|---|---|
/register | Dynamic client registration. Public clients only — token_endpoint_auth_method: none, PKCE is the proof of possession. Redirect URIs must be HTTPS or loopback, no fragment. |
/authorize | Validates client_id, exact-match redirect_uri, PKCE code_challenge (S256 only) and the resource (RFC 8707). A guest gets the server's own sign-in card; then a consent screen naming the scopes in plain language. |
/token | authorization_code with PKCE verification and a single-use atomic burn; refresh_token with rotation. Access tokens live 1 h, refresh tokens 30 d. Both are opaque and stored server-side, so revocation is immediate. |
Why the AS has its own sign-in card rather than bouncing to the shop's login: a storefront customer
is frequently not a host-login account. On Joomla, CBX creates customers as platform users without a
frontend-login usergroup, so the host login refuses them outright. The card runs the same sequence as
ConfigboxControllerUser::loginUser() — resolve by e-mail, authenticate, log in, merge the guest's
cart and orders — so signing in there is equivalent to signing in on the shop. Failures answer one
message for every cause, so the form is not an account-enumeration oracle.
The store-minted route — for your own integrations
For a service you run yourself, mint a token on the CLI and skip the flow:
# An agent acting for one specific customer: the full configurator and that customer's cart.
php cli/joomla.php configbox:token:mint "Concierge bot" \
--scope=storefront:write --for-customer=1234
# An anonymous browsing tier: products and structure at public-group pricing, read-only.
php cli/joomla.php configbox:token:mint "Catalog bot" --scope=storefront:read
Either way the token goes in Authorization: Bearer ….
3. Identity is the design
Everything that makes this correct follows from one decision: every tool call runs as a specific customer.
| Token | Acts as | Sees |
|---|---|---|
storefront:read, no customer | the shared public user (the store's default group) | products and structure, at public-group pricing. Read-only. |
storefront:read --for-customer=N | customer N | the same, at N's group pricing. |
storefront:write --for-customer=N | customer N | the full configurator and N's cart. |
Pricing is a customer-group property (enable_see_pricing), and the runtime projections already
omit price keys for a group that may not see them. So there is no "hide prices from the agent" switch
to get right and no second code path to keep in step: get the identity right and correct pricing
falls out. A customer in a no-pricing group gets the same tools with the price keys absent — not
zeroed, absent, so there is nothing to leak.
storefront:write requires a bound customer and mint refuses it without one. The anonymous tier
is read-only precisely because all anonymous callers share one user, and therefore one cart: letting
them write would let them see each other's work.
4. Isolation is bidirectional
A customer-bound token is a standing impersonation credential. If it could also call the back-office entity tools, it would read every customer's data while wearing one customer's identity — the classic confused-deputy hole.
A token holds storefront scopes or back-office scopes. Never both. Enforced at mint
(ConfigboxApiTokenHelper::normaliseScopes()), and it shows in the tool listing:
- A storefront token sees only
cbx_shop_*. The genericcbx_get_entityis never offered to it, becauseANY_READ/ANY_WRITEare not satisfied by a storefront scope. - The
store:readfloor — a back-office convenience — is withheld from a storefront grant, on the MCP surface and the REST one. A storefront token gets403on/cbx-api/v1/tax-class/1. - A back-office token holds no storefront scope, so it never sees the shopping tools.
Neither set is a subset of the other. Within the storefront set, ownership is re-checked against the
acting customer on every call — a cart_position_id or cart_id belonging to someone else is
refused, not merely hidden.
5. The tools
Fifteen, all prefixed cbx_shop_.
Browsing — available to the public tier
| Tool | Does |
|---|---|
list_products | The configurable products, each with its base price as this customer sees it. |
get_product | One product's full structure: pages, questions, answers, prices, and the selection format each question takes. Pass cart_position_id to fold in a live configuration's state. |
Configuring — needs a customer-bound token
| Tool | Does |
|---|---|
start_configuration | Begins a configuration; returns the cart_position_id every later call takes. |
get_configuration | Where it stands: selections, open questions, completeness, running price. include_availability: true adds the compact "what can I still pick" view — see §6. |
set_selection | Choose or clear one answer. Returns the new price and what the choice changed. If it would clear other answers the server asks for confirmation; repeat with confirmed: true. |
preview_selection | What would change — price before/after, which questions would open or close — without committing. Leaves no trace. |
explain_question | Why a question or answer is unavailable: the governing rule taken apart into its conditions, which currently fail, and what would satisfy them. |
add_to_cart | Finishes a complete configuration into the cart. Refused while required questions are open. |
The cart — needs a customer-bound token
| Tool | Does |
|---|---|
get_cart | Every line with its summary and lineTotal, the cart totals, and a url the customer can open. |
set_cart_quantity | Change a line's quantity. Refused when the product takes its quantity from a question instead. |
remove_cart_line | Remove a line. Not undoable — the configuration goes with it. |
copy_cart_line | Duplicate a line as a new unfinished configuration — "the same again but in blue". |
edit_cart_line | Reopen a line for editing. Note it leaves the cart until re-added. |
save_cart | Save the cart for later and return its url. Not a copy — the saved cart stays live. |
list_saved_carts | The customer's saved carts, newest first, each with its url. |
6. Two things the protocol forced, and how they are solved
Statelessness — the configurator is session-stateful, MCP is not
A browser keeps a cookie; an MCP client sends one authenticated request at a time with no cookie. CBX keeps two things in the session, and both had to be restored per call:
- The in-progress configuration.
ConfigboxConfiguration::setSelection()ends instoreSelectionsInSession(); only a finished position is flushed to the database. Over MCP a selection evaporated with the request that made it. - Which cart is "the" cart. That is a session pointer; a cart row carries no "open" flag. With no session, every call built a brand-new cart.
ConfigboxStorefrontMcpHelper restores what a browser would have kept, rather than changing the
engine: persistConfiguration() flushes the position's selections after a write, and
restoreCartContext() points the session at the customer's newest cart that still accepts
additions — so a checked-out cart is never shown as the current basket.
Both bugs reported success in the very response that caused them —
set_selectionansweredaccepted: truehaving persisted nothing. They are pinned bytests/specs/api/mcp-storefront-statefulness.spec.ts, whose assertions all read back in a later request, with each call on its own cookie-less context. A version of that spec using a shared HTTP client passed against the broken build.
Availability — answers interact, and re-reading has to be affordable
Answering one question routinely makes answers elsewhere impossible. An agent that does not re-read availability will offer the customer an option that is already ruled out.
The obvious re-read is unaffordable. Measured on a real 21-question product:
| Route to "what can I still pick?" | Bytes |
|---|---|
| The full product structure | 100,856 |
get_configuration | 9,746 — but it carries no answers at all |
get_configuration with include_availability | 9,487 added |
So include_availability returns, for each question that still applies, its answers split into
selectable and unselectable as answer-id → title maps — nothing else. Small enough that
calling it after every selection is the obvious thing to do, which is what the server's instructions
tell clients to do. selectable is computed with the same expression the full structure uses, so the
two can never disagree.
7. What the customer sees and controls
An assistant holding a standing credential raises two questions for the person whose account it is, and the account page answers both:
- Connected assistants — which agents hold access, and since when.
- Recent activity — what each has done, in plain language ("Chose an option", "Added a configuration to the cart"), reads included.
- Turn off — revocation that bites on the assistant's next call. Scoped to the owner: a request to revoke someone else's token is refused, not merely hidden.
8. Response conventions worth knowing
- Money is decimal strings, never JSON numbers — a double cannot hold
0.1.formattedis display sugar in the store's locale; never parse it. - A top-level
totalis a count (list_products,list_saved_carts, and the entity API's matching-records-before-paging). One cart line's money islineTotal— nottotal, which would collide with the count convention, and notprice, which a reader would take for the unit price. - A price key that may not be shown is absent, not zero. Test for absence.
- A tool that succeeds returns a populated object. An empty payload is reported as an error, never as a success — a write that says it worked and did nothing is the worst shape a bug can take.
9. Where the pieces live
| What | Where |
|---|---|
| The tools | helpers/storefrontmcp.php (ConfigboxStorefrontMcpHelper) |
| Identity binding, the storefront area, the isolation rule | helpers/apitokens.php |
| MCP wiring — scopes, annotations, definitions, dispatch | helpers/mcp.php |
| Transport handover, audience validation, the 401 challenge | controllers/apiv1.php (handleMcp(), refuse()) |
| Projections — structure, configuration, availability, cart | helpers/configuratorapi.php |
| The OAuth authorization server | helpers/oauth.php, controllers/oauth.php |
Discovery routing (.well-known → oauth controller) | the host's system plugin (matchOauthDiscovery()) |
| The customer's activity log and revocation | helpers/apiactivity.php |
10. See also
- The back-office MCP server — authoring the catalog, running the store
- API tokens and scopes
- The runtime API — the same engine over plain HTTP, no MCP
- API surfaces — which surface answers what
- Admin Guide: Let customers use their own AI assistant