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
EpsolDev Management (EPD)

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

SurfaceAuthConsumer
Admin RESTsession cookie, nonce, capability checkthe bundled React SPA
Public RESTnone for reads, anti-spam on writesthis website, automations
MCP serverbearer token, JSON-RPC 2.0AI agents
React SPAWordPress sessionoperators, 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

API2 namespaces, 170 admin and 19 public method-endpoints
Admin SPA23 pages, 26 routes
Data5 custom tables, 7 post types, 1 taxonomy
Automation96 MCP tools
Access controlumbrella capability plus 30 granular, and a Site Manager role
Static analysisPHPStan 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.

All projects