StoreLink runs a WooCommerce store on Kubernetes and puts a team MCP gateway in front of it. An administrator registers MCP servers once in a React dashboard. Every team member can then use the tools of all enabled servers through a single authenticated MCP endpoint, or through a chat agent in the dashboard.
- Multi-store provisioning: create, retry and delete independent stores from Stores, with per-user/team limits, automatic MCP registration and namespace isolation. Guide and verification.
- WooCommerce store: WordPress, WooCommerce and MariaDB in the cluster, with persistent storage and cash-on-delivery checkout.
- WooCommerce MCP server: list products, create product, list orders, get order and update order status, served over Streamable HTTP. Reachable only from the gateway.
- MCP gateway: aggregates the tools of every enabled upstream behind one endpoint, namespaces tool names per server, and routes each call to its server. Every upstream call has a timeout. Runs as two replicas that share MCP sessions, rate limits and configuration changes through PostgreSQL.
- Dashboard: server registration with live status (Connected, Unreachable, Disabled) and the tools each server exposes. Also covers member invitations, API keys, and an audit log of every tool call.
- Chat agent: an OpenAI Responses tool loop that calls tools through the gateway as the signed-in user and shows each tool call it makes.
- Access control: admin and member roles, per-user API keys stored as HMAC digests, immediate revocation, and upstream credentials held server-side.
- Deployment: one Helm chart with separate values for local k3d and single-node k3s.
flowchart LR
Shopper --> Ingress
Browser[Dashboard] --> Ingress
Client[MCP client] --> Ingress
Ingress --> WP[WordPress + WooCommerce]
Ingress --> GW[Gateway + dashboard API]
GW --> PG[(PostgreSQL)]
GW --> WooMCP[WooCommerce MCP]
GW --> TimeMCP[Time MCP]
GW --> OpsMCP[Operations MCP]
GW --> OpenAI[OpenAI Responses API]
WooMCP --> WP
WP --> DB[(MariaDB)]
| Component | Responsibility |
|---|---|
apps/dashboard |
React dashboard: stores, connections, access, activity and chat |
services/gateway |
Express backend, aggregated MCP endpoint, upstream registry, audit log and chat loop |
services/woocommerce-mcp |
MCP server for store products and orders, using the WooCommerce REST API |
services/time-mcp |
Store timezone and day-window tools |
services/operations-mcp |
Sales target and fulfillment policy tools |
packages/shared |
API key hashing, secret loading and MCP HTTP handling |
charts/storelink |
Helm chart with values-local.yaml and values-prod.yaml |
Tools are exposed as <server>__<tool>, for example woo__list_orders and time__get_day_window. The time and operations servers both provide get_status, which appears as time__get_status and ops__get_status. The system design describes routing, failure handling and the store integration, and the threat model covers credentials, upstream trust and member isolation.
- Linux with Docker Engine
- Node.js 24
- kubectl, Helm and k3d
- Git, curl and Python 3
- At least 4 GiB of memory for the k3d node
- An OpenAI API key, for the chat agent
scripts/install-dev-tools.sh installs Node, kubectl, Helm and k3d into .tools/bin if they are missing.
scripts/install-dependencies.sh
scripts/local-up.sh
export KUBECONFIG="$PWD/.data/kubeconfig"
kubectl -n storelink get podslocal-up.sh creates the k3d cluster, generates credentials into Kubernetes Secrets, builds and imports the images, and installs the chart. Run it again to deploy changes; data, passwords and key revocations persist across runs. The generated credentials are also saved to .data/cluster-secrets.json. Keep that file private.
| Service | URL |
|---|---|
| Storefront | http://store.storelink.localhost |
| WordPress admin | http://store.storelink.localhost/wp-admin/ |
| Dashboard | http://dashboard.storelink.localhost |
| MCP endpoint | http://dashboard.storelink.localhost/mcp |
Most systems resolve *.localhost to 127.0.0.1, and k3d listens on ports 80 and 443 there. If your resolver doesn't, add both hostnames to /etc/hosts.
To show the generated passwords:
node scripts/local-credentials.ts --show| Account | Login |
|---|---|
| Dashboard admin | admin@storelink.local |
| Dashboard member | member@storelink.local |
| WordPress admin | admin |
- Open the storefront and add a product to the cart.
- Go to checkout and enter a test address, for example Bengaluru, Karnataka,
560001. - Choose Cash on delivery and place the order.
- Find the order in WordPress admin under WooCommerce → Orders.
In the dashboard, open Access and generate an API key. It is shown only once. Then add the gateway to any MCP client that supports Streamable HTTP:
{
"mcpServers": {
"storelink": {
"type": "http",
"url": "http://dashboard.storelink.localhost/mcp",
"headers": { "Authorization": "Bearer <your API key>" }
}
}
}With Claude Code:
claude -p --mcp-config member-mcp.json --strict-mcp-config --allowedTools mcp__storelink \
"What orders came in today in the store's timezone? Then compare today's sales with the operations sales target."With MCP Inspector, run npx @modelcontextprotocol/inspector, choose Streamable HTTP, and enter the same URL and header.
The client sees nine tools across woo__, time__ and ops__. When an administrator changes a server, the gateway sends a tool-list change notification to the team's connected sessions. Calls to disabled tools, and calls made with revoked keys, are rejected. Idle sessions expire after 15 minutes.
scripts/external-chat.ts is a small terminal agent built on the same chat loop, useful for scripted checks:
STORELINK_GATEWAY_KEY_FILE=.data/member-key node --env-file=.env scripts/external-chat.ts "List today's store orders."Add your OpenAI key to .env and redeploy:
OPENAI_API_KEY=your-keyscripts/local-up.shThe key is stored in a Kubernetes Secret and used only by the gateway. Example prompts:
- "Create a product named Team Mug priced at 149.00."
- "What orders came in today?"
- "Compare today's order total with our operations sales target."
Each answer lists the tools it called, and Activity shows the matching audit records. Conversations and token usage are saved per user.
Open Stores → Create store, choose a name and short address, and wait for Ready. Members manage their own stores; admins manage their team’s stores. Each store gets independent persistent data and a tool prefix such as woo-north__*. Retry failed setup or confirm deletion from its card. Defaults allow two stores per user and three per team; Failed/Deleting stores count until removal finishes. See multi-store setup, production TLS and backups.
- Connections: add, edit, disable or remove MCP servers, each with an optional static header or bearer credential. The gateway pings each server every second, so a stopped or restarting server shows as Unreachable within about a second, and every status change is kept in the connection history. Public HTTPS servers can be added directly. Cluster-internal origins must be listed in
gateway.extraUpstreamOrigins. Credentials are encrypted with AES-256-GCM, stored in a Kubernetes Secret, and never returned by the API. - Access: invite members, and issue or revoke API keys.
- Activity: the audit log of every tool call, showing user, server, tool, time, duration and outcome.
Administrators see each server's calls, error rate and average and p95 latency for the last 24 hours on its Connections card. The same summary is available as JSON from GET /api/metrics?hours=<1-168>.
Each gateway replica serves Prometheus metrics on port 9464 at /metrics. The Ingress does not route it, so it is reachable inside the cluster only. With default-deny network policies on, a Prometheus server must also be listed in networkPolicies.metricsFrom. The pod carries prometheus.io/scrape annotations. To inspect it locally:
kubectl -n storelink port-forward svc/storelink-gateway 9464:9464
curl -s localhost:9464/metrics| Metric | Type | Labels |
|---|---|---|
storelink_tool_calls_total |
counter | team, server, slug, outcome |
storelink_tool_call_duration_seconds |
histogram | team, server, slug |
storelink_upstream_up |
gauge | team, server, slug |
storelink_upstream_status |
gauge | team, server, slug, status |
storelink_upstream_last_failure_timestamp_seconds |
gauge | team, server, slug |
Call counts and latency come from the shared audit log, so every replica reports the same totals; aggregate them with max, not sum. The storelink_upstream_* gauges are each replica's own view of the upstream. For example, the error rate per server over five minutes is sum by (server) (max by (server, outcome) (rate(storelink_tool_calls_total{outcome=~"failed|timeout|uncertain"}[5m]))) / sum by (server) (max by (server, outcome) (rate(storelink_tool_calls_total{outcome!="denied"}[5m]))).
Common chart values:
| Value | Default | Description |
|---|---|---|
chat.enabled |
false (set by local-up.sh) |
Enables the chat agent |
chat.model |
gpt-6-luna |
OpenAI model used by the chat agent |
chat.limits.perUserHour |
15 |
Chat turns per user per hour |
chat.limits.perUserConcurrent / perTeamConcurrent / globalConcurrent |
1 / 4 / 16 |
Concurrent chat turns |
gateway.extraUpstreamOrigins |
[] |
Cluster-internal origins allowed as upstreams |
gateway.healthIntervalMs |
1000 |
Interval between upstream health probes |
gateway.replicas |
2 |
Gateway replicas |
gateway.mcpRequestsPerMinute |
120 |
MCP requests per API key per minute, across replicas |
gateway.shutdownDelayMs |
5000 |
Time a stopping replica keeps serving while traffic moves away |
gateway.metrics.enabled, gateway.metrics.port |
true, 9464 |
Prometheus metrics listener inside the cluster |
networkPolicies.enabled |
true |
Only the listed components can reach the MCP servers, databases and WordPress |
networkPolicies.defaultDeny |
false |
Deny all other traffic; see Network policies |
store.host, gateway.host |
store.storelink.localhost, dashboard.storelink.localhost |
Public hostnames |
store.timezone |
Asia/Kolkata |
Store timezone, which defines "today" |
storage.className |
local-path |
Storage class for the persistent volumes |
ingress.tls.enabled |
false |
Serves both hosts over TLS from ingress.tls.secretName |
Each chat turn is limited to 8 model rounds, 20 tool calls and 120 seconds.
By default the chart only restricts who can connect to the MCP servers, PostgreSQL, MariaDB and WordPress. All other traffic is open, including outbound internet access, which keeps local development and debugging simple.
Setting networkPolicies.defaultDeny: true denies all other traffic in the namespace, in both directions, and allows only the connections each component uses: the gateway reaches PostgreSQL, the MCP servers, the Kubernetes API and public HTTPS; the Woo MCP reaches WordPress; WordPress reaches MariaDB. Anything else is opened through values:
| Value | Default | Opens |
|---|---|---|
networkPolicies.kubernetesApi.cidrs, .ports |
[0.0.0.0/0], [6443] |
The Kubernetes API for the gateway (narrow to kubectl get endpoints kubernetes) |
networkPolicies.publicHttps |
true |
Gateway egress to public HTTPS on port 443 (OpenAI, public servers) |
networkPolicies.metricsFrom |
[] |
Scraping the metrics port |
networkPolicies.gatewayEgress |
[] |
Other gateway destinations, such as cluster-internal MCP servers |
networkPolicies.wordpressEgress |
[] |
WordPress outbound traffic, such as plugin updates and WP-Cron |
For example:
networkPolicies:
defaultDeny: true
wordpressEgress:
- ports: [{ protocol: TCP, port: 443 }, { protocol: TCP, port: 80 }]
metricsFrom:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: monitoring }
gatewayEgress:
- to:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: my-tools }
ports: [{ protocol: TCP, port: 8080 }]A rule without to allows those ports to any destination. networkPolicies.enabled: false removes every policy.
The same chart deploys to a single-node k3s server. The differences are in values-prod.yaml and the Secrets you supply.
-
Install k3s with Traefik and NetworkPolicy enforcement (quick start). Open ports 80 and 443, and restrict SSH and Kubernetes API access.
-
Build both images and push them to a registry the server can pull from:
docker build -f images/app/Dockerfile -t <registry>/storelink/app:<tag> . docker build -f images/store/Dockerfile \ --build-arg WOOCOMMERCE_SHA256=5317434cb381b5fdc7c1dd6ee258324f08c390976aaf8d1199f2a0e9a3ab8586 \ -t <registry>/storelink/wordpress:<tag> .
-
Copy
charts/storelink/values-prod.yamlto a private file. Set the image references, hostnames and public URLs, the storage class and the volume sizes. Point DNS records for both hostnames at the server. -
Generate credentials into the cluster:
export KUBECONFIG=/path/to/production-kubeconfig STORELINK_STATE_DIR=.data/production node scripts/prepare-cluster-secrets.tsProvide
OPENAI_API_KEYin the environment and setchat.enabled: truein your values file. -
Install the chart once
storelink-tlsexists. A purchased certificate can be loaded by hand, or cert-manager can create that Secret:kubectl -n storelink create secret tls storelink-tls --cert=fullchain.pem --key=privkey.pem helm upgrade --install storelink charts/storelink -n storelink -f production-values.yaml --wait --timeout 10m kubectl -n storelink wait --for=condition=complete jobs -l storelink/component=store-bootstrap --timeout=120s
-
Schedule backups of PostgreSQL, MariaDB, WordPress uploads and Secrets, and enable Secret encryption at rest.
The storefront and dashboard Ingresses share ingress.tls.secretName (storelink-tls in values-prod.yaml). cert-manager can fill that Secret from Let's Encrypt.
Install cert-manager, then a ClusterIssuer named letsencrypt-prod. Its ACME server is https://acme-v02.api.letsencrypt.org/directory, and its solver is HTTP-01 on ingress class traefik. The account key stays in a Secret named by privateKeySecretRef. A Certificate in the storelink namespace lists the storefront and dashboard hostnames and writes storelink-tls. DNS for both names must reach the ingress, and port 80 must stay open for the challenge.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: ops@example.com
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
ingressClassName: traefik
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: storelink-tls
namespace: storelink
spec:
secretName: storelink-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- store.example.com
- dashboard.example.comAdditional stores do not reuse that Secret. With stores.scheme: https, stores.tlsSecretName: storefront-tls and stores.tlsClusterIssuer: letsencrypt-prod, each store Ingress is annotated cert-manager.io/cluster-issuer and gets its own Certificate inside its namespace (services/gateway/src/store-manifests.ts). The store stays out of Ready until that Certificate is Ready. The store NetworkPolicy allows Traefik to reach the HTTP-01 solver. Details are in multi-store DNS and TLS.
A single-node k3s deployment issued one Let's Encrypt certificate whose subject alternative names were the storefront and the dashboard. The chain verified, both sites answered over HTTPS, and /mcp rejected a call with no API key. DNS for that run used sslip.io records for the instance address. That host is no longer running.
Every helm upgrade creates a new release revision. Store, MCP server and gateway changes all ship the same way: new image tags or values in one upgrade. Locally, scripts/local-up.sh builds new images and runs the upgrade.
Helm versions the Kubernetes manifests, not the data on the persistent volumes. Back up both databases, the WordPress uploads and the Secrets before each upgrade. Upstream credentials are encrypted with a key derived from the API key pepper, so storelink-credentials and storelink-upstream-credentials must be restored together:
backup=~/storelink-backups/$(date +%Y%m%d-%H%M%S) && mkdir -p "$backup"
kubectl -n storelink exec deploy/storelink-postgres -- \
pg_dump -U storelink -d storelink --format=custom > "$backup/postgres.dump"
kubectl -n storelink exec deploy/storelink-mariadb -- \
sh -c 'mariadb-dump -uroot -p"$MARIADB_ROOT_PASSWORD" --single-transaction --routines wordpress' > "$backup/mariadb.sql"
kubectl -n storelink exec deploy/storelink-wordpress -- \
tar -C /var/www/html -czf - wp-content/uploads > "$backup/wordpress-uploads.tgz"
(umask 077; kubectl -n storelink get secret storelink-credentials storelink-upstream-credentials \
-o yaml > "$backup/secrets.yaml")helm history storelink -n storelink
helm upgrade storelink charts/storelink -n storelink -f production-values.yaml \
--set-string images.app=<registry>/storelink/app:<new-tag> \
--set-string images.store=<registry>/storelink/wordpress:<new-tag> \
--wait --timeout 10m
kubectl -n storelink wait --for=condition=complete jobs -l storelink/component=store-bootstrap --timeout=180s--wait returns once every workload passes its readiness probe. To change a single setting and keep everything else, use --reuse-values --set <key>=<value>. Change the number of gateway replicas through gateway.replicas; after a manual kubectl scale, the next upgrade fails with a field-ownership conflict until it is run once with --force-conflicts. Each revision runs its own store bootstrap Job (storelink-store-bootstrap-<revision>), and gateway pods apply database migrations when they start. Both steps are idempotent.
helm history storelink -n storelink
helm rollback storelink <revision> -n storelink --wait --timeout 10m
kubectl -n storelink wait --for=condition=complete jobs -l storelink/component=store-bootstrap --timeout=180sA rollback creates a new revision that reapplies the selected revision's manifests: images, configuration, and that revision's bootstrap Job. Images referenced by earlier revisions must still be available in the registry.
A rollback does not undo:
- PostgreSQL migrations. Files in
services/gateway/migrations/only add tables and columns and are safe to re-run. Older gateway versions run against the newer schema, and the added columns remain after a rollback. - WordPress and WooCommerce database changes. Plugin or core updates can change the MariaDB schema, and that data is not versioned by Helm.
- Data on the persistent volumes. Orders, products, users, keys and audit records written after the upgrade remain in
storelink-postgres,storelink-mariadbandstorelink-wordpress. - Encrypted upstream credentials. Gateways from releases before credential encryption cannot read the encrypted entries, so rolling back past that change leaves every upstream Unreachable. Restore
secrets.yamlfrom a backup taken before the upgrade (see below), or re-enter the credentials in the dashboard.
If an upgrade damages data, roll back first, then restore the backups:
kubectl -n storelink exec -i deploy/storelink-postgres -- \
pg_restore -U storelink -d storelink --clean --if-exists --no-owner < "$backup/postgres.dump"
kubectl -n storelink exec -i deploy/storelink-mariadb -- \
sh -c 'mariadb -uroot -p"$MARIADB_ROOT_PASSWORD" wordpress' < "$backup/mariadb.sql"
kubectl -n storelink exec -i deploy/storelink-wordpress -- \
tar -C /var/www/html -xzf - < "$backup/wordpress-uploads.tgz"
kubectl -n storelink replace --force -f "$backup/secrets.yaml"
kubectl -n storelink rollout restart deploy/storelink-gateway deploy/storelink-wordpressreplace --force recreates both Secrets from the backup; kubectl apply of the same file is rejected because the Secrets have changed since. The uploads restore brings back missing and changed files but leaves files added after the backup.
npm run typecheck
npm test
npm run build:dashboard
npm run format:check
helm lint charts/storelink -f charts/storelink/values-local.yaml
helm template storelink charts/storelink -f charts/storelink/values-prod.yamlCluster smoke tests run against the local deployment:
node scripts/smoke-cluster.ts # store tools and storefront checkout
node scripts/smoke-dashboard.ts # dashboard, invitations, keys and server management
node scripts/smoke-failures.ts # upstream outage, recovery and persistence
node scripts/smoke-pod-delete.ts # deleted upstream pod shows as Unreachable, then recovers
node scripts/smoke-multistore.ts # two-store lifecycle, isolation, retry/restart and one paid comparison
node scripts/smoke-scaling.ts # sessions, configuration changes and limits across two gateway pods (one live model call)
node --env-file=.env scripts/smoke-chat.ts # live chat flow (uses the OpenAI API)The smoke tests drive Google Chrome through Playwright. npm run dev:dashboard starts Vite with API requests proxied to the deployed gateway. Scripts that modify the cluster take a shared lock; wrap manual changes with node scripts/with-cluster-lock.ts -- <command>.
MCP sessions and rate-limit counters are stored in PostgreSQL, so they survive redeploys: sessions last 15 minutes after their last use, login counters 15 minutes and the chat quota an hour. Repeated checks use part of the next hour's chat and login allowance. To clear sessions and rate-limit counters:
kubectl -n storelink exec deploy/storelink-postgres -- \
psql -U storelink -d storelink -c 'DELETE FROM mcp_sessions; DELETE FROM rate_limits;'