Skip to main content
Version: 3.x

The storefront MCP server (headless, agentic shopping)

Version
3.x
Updated
View markdown

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.

StepWhat happens
/registerDynamic 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.
/authorizeValidates 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.
/tokenauthorization_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.

TokenActs asSees
storefront:read, no customerthe shared public user (the store's default group)products and structure, at public-group pricing. Read-only.
storefront:read --for-customer=Ncustomer Nthe same, at N's group pricing.
storefront:write --for-customer=Ncustomer Nthe 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

ToolDoes
list_productsThe configurable products, each with its base price as this customer sees it.
get_productOne 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

ToolDoes
start_configurationBegins a configuration; returns the cart_position_id every later call takes.
get_configurationWhere it stands: selections, open questions, completeness, running price. include_availability: true adds the compact "what can I still pick" view — see §6.
set_selectionChoose 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_selectionWhat would change — price before/after, which questions would open or close — without committing. Leaves no trace.
explain_questionWhy a question or answer is unavailable: the governing rule taken apart into its conditions, which currently fail, and what would satisfy them.
add_to_cartFinishes a complete configuration into the cart. Refused while required questions are open.

The cart — needs a customer-bound token

ToolDoes
get_cartEvery line with its summary and lineTotal, the cart totals, and a url the customer can open.
set_cart_quantityChange a line's quantity. Refused when the product takes its quantity from a question instead.
remove_cart_lineRemove a line. Not undoable — the configuration goes with it.
copy_cart_lineDuplicate a line as a new unfinished configuration — "the same again but in blue".
edit_cart_lineReopen a line for editing. Note it leaves the cart until re-added.
save_cartSave the cart for later and return its url. Not a copy — the saved cart stays live.
list_saved_cartsThe 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 themset_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 structure100,856
get_configuration9,746 — but it carries no answers at all
get_configuration with include_availability9,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

WhatWhere
The toolshelpers/storefrontmcp.php (ConfigboxStorefrontMcpHelper)
Identity binding, the storefront area, the isolation rulehelpers/apitokens.php
MCP wiring — scopes, annotations, definitions, dispatchhelpers/mcp.php
Transport handover, audience validation, the 401 challengecontrollers/apiv1.php (handleMcp(), refuse())
Projections — structure, configuration, availability, carthelpers/configuratorapi.php
The OAuth authorization serverhelpers/oauth.php, controllers/oauth.php
Discovery routing (.well-known → oauth controller)the host's system plugin (matchOauthDiscovery())
The customer's activity log and revocationhelpers/apiactivity.php

10. See also