# The storefront MCP server (headless, agentic shopping)

> 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 …

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/technical/storefront_mcp. Last updated 2026-08-26.

---
**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](https://docs.configbox.at/docs/4.0-preview/technical/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

```mermaid
flowchart LR
  subgraph AGENT["The customer's AI assistant"]
    C["MCP client"]
  end
  subgraph STORE["The CBX store"]
    R["POST /cbx-api/mcp\nresource server"]
    A["/cbx-api/oauth/*\nauthorization server"]
    E["Configurator engine\nrules · calculations · cart"]
  end
  C -- "1. discovers" --> A
  C -- "2. bearer token" --> R
  R -- "acts AS one customer" --> E
  A -. "issues customer-bound token" .-> C
```

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:

```http
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:

```bash
# 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 generic `cbx_get_entity` is never offered to it,
  because `ANY_READ`/`ANY_WRITE` are not satisfied by a storefront scope.
- The `store:read` floor — a back-office convenience — is **withheld** from a storefront grant, on the
  MCP surface *and* the REST one. A storefront token gets `403` on `/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 in
  `storeSelectionsInSession()`; 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_selection` answered
> `accepted: true` having persisted nothing. They are pinned by
> `tests/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`. `formatted` is
  display sugar in the store's locale; never parse it.
- **A top-level `total` is a count** (`list_products`, `list_saved_carts`, and the entity API's
  matching-records-before-paging). One cart line's money is **`lineTotal`** — not `total`, which would
  collide with the count convention, and not `price`, 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](https://docs.configbox.at/docs/4.0-preview/technical/mcp_server) — authoring the catalog, running the store
- [API tokens and scopes](https://docs.configbox.at/docs/4.0-preview/technical/api_tokens_and_scopes)
- [The runtime API](https://docs.configbox.at/docs/4.0-preview/technical/runtime_api) — the same engine over plain HTTP, no MCP
- [API surfaces](https://docs.configbox.at/docs/4.0-preview/technical/api_surfaces) — which surface answers what
- Admin Guide: [Let customers use their own AI assistant](https://docs.configbox.at/docs/4.0-preview/admin-guide/ai-assistant/let-customers-use-their-own-assistant)
