Custom Store Management (CSM)
A headless commerce back office for WordPress — catalogue, orders, payments, returns and a storefront assistant, driven by a React admin and 237 MCP tools.
- Client
- EpsolDev
- Category
- Platform Engineering
- Completed
- 2026-08-14
- Built with
- WordPress, PHP, React.js, TypeScript, Vite, Tailwind CSS, MySQL, REST API
- Topics
- API Development, Backend, CMS, E-commerce
WordPress Plugin Headless Commerce MCP Server In production
CSM is the commerce instance of a platform architecture that also powers Custom Blog Management and EpsolDev Management. Same skeleton — convention autoloader, singleton loader, a React SPA mounted inside wp-admin, a capability-gated admin API beside a public storefront API, and an in-plugin MCP server. Different domain: a complete storefront back office.
It is by far the largest of the three, and it runs a live store.

Every screenshot on this page is the running admin. Customer names, email addresses, order references and money figures are blurred — the product is the subject, not the shop's customers.
The split that shapes everything
| Entity kind | Where it lives | Why |
|---|---|---|
| Products, categories, brands, pages | WordPress post types and taxonomies | editorial, low write volume, wants revisions and media |
| Orders, carts, reviews, returns, stock history | 28 custom database tables | transactional, high write volume, needs real indexes and ledgers |
Products are a post type that is deliberately not publicly queryable. The store is headless, so the only way in is the API.
Catalogue

Variants are stored as normalised JSON in post meta rather than a separate variant table, and every write path funnels through one canonicalisation step — so a variant written by the SPA, the storefront API or an MCP tool comes out identically shaped.
The search index behind this screen exists because of one measurable failure. WordPress core ANDs search terms across title, excerpt and content only, so black leather jacket returned zero results while leather jacket returned dozens — because "black" lived inside variant JSON that core never looks at. The fix is a denormalised index built from every searchable surface and refreshed on write.
Orders

Orders live in their own tables with a model and service split, and status transitions are stock-aware: cancelling or refunding restores inventory rather than silently leaving it committed.
Money paths get invariants, not just tests The payment layer states three rules in code: the amount is never taken from the client, the secret key never leaves the server, and payment is only ever believed from the provider — the verification step re-reads the intent and checks both status and amount received. Checkout races the browser return against the webhook, so a single intent can only ever produce one order.
Inventory

Stock is a ledger, not a number. Every adjustment — single, bulk, or an order transition that restores stock — writes a history row. You can always answer "why is this SKU at 3?" Low-stock and out-of-stock are views over the same data rather than separate flags that can drift.
Reviews and Q&A

Reviews move through a moderation queue — pending, approved, rejected — and support images and threaded comments, each with its own table and controller.

Product questions add a content-gap view: approved questions that still have no approved answer. That list is the highest-value copy work in the store, because each row is a real shopper objection nobody has answered yet. An AI draft-answer endpoint exists to speed it up and deliberately does not persist — a human still approves every word.
Coupons and offers

The database UNIQUE index on the coupon code is the source of truth, so a duplicate returns a clean 409 instead of racing two admins into two coupons with the same code.
The storefront assistant

The public bot is a separate controller from the admin AI, on purpose. It structurally cannot reach the admin tool registry, has no edit-mode parameter at all, and replays history server-side so a visitor cannot forge an assistant turn.
It also caches its own answers, but only when every gate passes:
- the question is self-contained, with no "it" or "that one"
- the reply was a real answer, not a refusal or a handoff
- every tool it used was impersonal
- PII redaction changed nothing — if redaction would alter the text, the turn is discarded rather than cached
Chat insights turn those sessions into a work queue: what shoppers ask, which topics recur, and where the assistant had no good answer.

Model choice is resolved per feature, not globally, with capability metadata attached to each one — that metadata is what stops a provider that can only run asynchronously from being routed to a synchronous consumer. Keys are decrypted inside the resolver and never logged or returned.
SEO

A per-surface SEO inventory with a 0–100 completeness score, a public sitemap endpoint, a redirects controller, and a 10-class JSON-LD engine with a documented merge strategy for repeated types. The AI-crawler policy is first-class: allow all, allow search but block AI training, block all AI, or a custom per-bot policy.
Access control

An umbrella store capability with 38 granular per-feature capabilities layered over it, so a warehouse role can touch inventory without seeing revenue. Rate limiting is keyed on client address and target, so brute-forcing one account cannot lock out the whole store and a shared proxy address does not collapse into a single bucket.
Transactional email

Templates are editable per message, with preview and test send. Only the human-facing copy is editable; the HTML shell stays code-owned, because hand-edited table markup is exactly how transactional email breaks in Outlook.
Operating it through machines

237 MCP tools from an auto-discovering registry — drop in a file that registers itself and it appears in tools/list. Plus a full OAuth 2.1 authorization server implemented inside the plugin, so a desktop AI client connects through browser consent instead of a pasted token, while static bearer auth keeps working.
Mutating tools on an approval-required token use a two-step protocol:
1. call tool -> dry-run preview + a one-time approval token,
bound to (token id + tool name + hash of the exact args)
2. re-call it -> executes once, then the grant is consumed
Approval for one call provably cannot authorise a different one.
Shipping the storefront from the back office

The storefront repository is connected with a fine-grained token, encrypted at rest and never echoed back to the browser. From this screen you can branch off latest main, open a pull request, read the CI and mergeability rollup, and merge only when it is both mergeable and green — with rollback and deploy history.
The storefront it ships is Luxwearz.
Other engineering worth naming
Non-destructive image pipeline. New uploads convert to WebP; existing media is regenerated in batches by an admin action, an MCP tool and a self-scheduling cron. Full-size requests are short-circuited to the derivative. Explicit file URLs are emitted rather than relying on header negotiation, so it stays CDN-safe and needs zero storefront changes. Originals are never modified or deleted.
A tripwire that is a test. The expected MCP tool count is asserted in CI. It was migrated from counting files to counting registrations after a shared helper broke the original assumption.
Scale
| PHP | 402 files, ~96,800 lines, 39 services, 51 controllers |
| Admin SPA | 341 TS and TSX files, ~91,700 lines, 69 routes |
| Data | 28 custom tables, 27 migrations, 7 post types |
| API | 2 namespaces, 341 route registrations |
| Automation | 237 MCP tools, 6 cron schedules |
| Access control | umbrella capability plus 38 granular |
Counts taken from the shipped main branch at v1.15.55.
Stack
PHP 8.1 WordPress 6.5 React 18.3 TypeScript 5.5 Vite 5 Tailwind 3.4 TanStack Query 5 Radix UI Stripe MySQL Playwright PHPStan
Zero runtime Composer dependencies — the payment integration is written against the HTTP API directly rather than vendoring an SDK into someone else's WordPress.