EpsolDev Management (EPD)
The headless backend running this very site — four surfaces from one WordPress plugin, with drift tripwires that fail loudly when the API contract moves.
- 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, DevOps
WordPress Plugin Headless MCP Server In production
EPD runs this website's own back office. It is the agency-site instance of the same architecture behind Custom Store Management and Custom Blog Management — and the smallest and cleanest of the three, because it was written last, with both siblings' lessons already banked.
Services, portfolio, team, testimonials, technologies, FAQs, content pages and leads. WordPress supplies media, auth and roles; everything else is purpose-built.
Four surfaces, one plugin
| Surface | Auth | Consumer |
|---|---|---|
| Admin REST | session cookie, nonce, capability check | the bundled React SPA |
| Public REST | none for reads, anti-spam on writes | this website, automations |
| MCP server | bearer token, JSON-RPC 2.0 | AI agents |
| React SPA | WordPress session | operators, inside wp-admin |
An MCP token can operate every resource but can never mint another token or gain an admin session. That boundary is a capability check the token type structurally cannot satisfy — not a policy note in a document.
Decisions that came from being burnt
Migrations self-heal on version change, because the deploy path never fires activation hooks. This site deploys by git pull. The WordPress activation hook never runs again after the first install, so schema and capability changes would silently never apply. Instead the loader compares a stored version against the code constant on each admin or API boot and runs what is pending — guarded by a process-level static, so the steady-state cost is a single option read.
Drift tripwires generated from live registries. Route and tool counts are asserted against what the server actually registered at request time, never against a hand-kept list:
// Bump in the SAME commit that adds or removes a route.
private const EXPECTED_ADMIN_ROUTES = 170;
If a route appears or disappears without that constant moving, the API Docs page raises a drift warning. The same pattern guards the tool catalogue.
Computed values beat stored counters. A technology usage count sat at zero on every row — not by coincidence but by construction: read everywhere, written nowhere. Replacing it with one indexed pass over post meta, memoised per request, was chosen over a materialised counter because a drifted counter is worse than an obvious zero. The real fix turned out to be on the input side — catalogue-backed autocomplete that visibly flags an off-catalogue entry, so the data never diverges in the first place.
Email templates store overrides, never full copies. Only the human-facing lines are editable; the HTML shell stays code-owned, because hand-edited table markup is exactly how transactional email breaks in Outlook. Saving a field whose value equals the shipped default drops it, which buys three properties at once:
- untouched templates keep inheriting code improvements
- "reset" is just unsetting
- the interface can show a truthful per-field "modified" badge
Bundle size is a reviewed number. Importing the shared-component barrel from a statically reachable page dragged drag-and-drop, form and validation libraries into the entry chunk, for a measured +24.6 KB gzipped. The standing rule since: anything reachable from the app root imports kit files directly, never the barrel.
Scale
| API | 2 namespaces, 170 admin and 19 public method-endpoints |
| Admin SPA | 23 pages, 26 routes |
| Data | 5 custom tables, 7 post types, 1 taxonomy |
| Automation | 96 MCP tools |
| Access control | umbrella capability plus 30 granular, and a Site Manager role |
| Static analysis | PHPStan level 8 with strict rules |
Stack
PHP 8.1 WordPress 6.5 React 18.3 TypeScript 5.5 Vite 5 Tailwind 3.4 TanStack Query 5 Radix UI dnd-kit cmdk Recharts MySQL
Secrets are encrypted at rest with libsodium and an AES fallback, keyed from the WordPress salts, with a versioned wire prefix so decryption never has to guess the scheme.