Urumi MCP Gateway
A WooCommerce store and a team MCP gateway, both on Kubernetes. An admin registers MCP servers once. Team members then use every enabled tool through one authenticated URL, from an MCP client (Claude Code, Cursor, MCP Inspector) or from the dashboard's chat agent. Every tool call is attributed to a user and audited. One Helm chart runs on local k3d and, with a different values file, on a VPS.
New here? Start with docs/RUN_ON_YOUR_COMPUTER.md — a
plain-language, step-by-step guide to running it on your own laptop.
Design: docs/DESIGN.md · Threat model: docs/THREAT_MODEL.md
· Requirement IDs (K1…D8): docs/REQUIREMENTS.md · ADRs: docs/adr/
Architecture
flowchart LR
browser["Browser"]
client["External MCP client<br/>(Inspector, Claude Code)"]
subgraph k8s["Kubernetes namespace urumi"]
traefik["Traefik Ingress"]
dash["dashboard<br/>(nginx, React build)"]
gw["gateway<br/>/api/* · /mcp · chat loop<br/>callTool → audit_log"]
pg[("Postgres<br/>users, keys, upstreams, audit")]
woo["woo-mcp<br/>(ClusterIP only)"]
every["server-everything<br/>(ClusterIP only)"]
wp["WordPress + WooCommerce"]
db[("MariaDB")]
job["bootstrap Job<br/>(Helm hook)"]
end
gitlab["GitLab MCP<br/>gitlab.com/api/v4/mcp"]
claude["Claude API"]
browser -- "app.localtest.me /" --> traefik
browser -- "store.localtest.me" --> traefik
client -- "app.localtest.me/mcp + Bearer key" --> traefik
traefik --> dash
traefik -- "/api, /mcp" --> gw
traefik --> wp
gw --> pg
gw -- "x-woo-mcp-secret" --> woo
gw --> every
gw -- "Bearer PAT (encrypted at rest)" --> gitlab
gw -- "x-api-key" --> claude
woo -- "REST API + Application Password" --> wp
wp --> db
job -. "installs WP + Woo, writes Secret urumi-woo-api" .-> wp
| Component | Source | Runs as | Reachable from |
|---|---|---|---|
| gateway | apps/gateway |
Deployment urumi-gateway |
Ingress app.<host>/api, /mcp |
| dashboard | apps/dashboard |
Deployment urumi-dashboard (nginx) |
Ingress app.<host>/ |
| woo-mcp | apps/woo-mcp |
Deployment urumi-woo-mcp |
gateway pods only (NetworkPolicy + shared secret) |
| server-everything | apps/everything/Dockerfile |
Deployment urumi-everything |
gateway pods only (NetworkPolicy) |
| WordPress + Woo | official wordpress image |
Deployment + PVC | Ingress store.<host> |
| MariaDB | official mariadb image |
StatefulSet + PVC | in-cluster |
| Postgres 16 | official postgres image |
StatefulSet + PVC | gateway pods only (NetworkPolicy) |
| bootstrap | charts/platform/files/bootstrap |
Helm post-install,post-upgrade hook Job |
— |
| GitLab MCP | hosted by GitLab | external | gateway, over the internet |
Prerequisites
| Tool | Version | Why |
|---|---|---|
| Docker | Docker Desktop, ≥ 6 GB RAM | k3d nodes and image builds |
| k3d | v5 | local cluster (scripts/cluster-up.sh) |
| kubectl | any recent | scripts pin the k3d-urumi context |
| helm | v3 | the one chart |
| Node.js | ≥ 22.13 (.nvmrc) |
workspaces, tests, e2e MCP client |
| jq, curl, openssl | any | e2e scripts, scripts/gen-env.sh |
scripts/dev-setup.sh installs them (Homebrew on macOS; apt + official installers on
Ubuntu/WSL2) and prints the versions it found. It does not pin versions.
You also need:
- An Anthropic API key (chat: D2, D4).
- A GitLab fine-grained personal access token with the permission "MCP tool: Execute"
(third upstream; see below). A classic PAT is refused by GitLab with
403 insufficient_granular_scope.
Zero to running
git clone https://github.com/1012aaditya/urumi-mcp-gateway.git urumi && cd urumi
npm install
# 1. Secrets file (gitignored, mode 600). Generates every *_PASSWORD, *_SECRET and
# GATEWAY_ENC_KEY with openssl; refuses to overwrite an existing .env.local.
scripts/gen-env.sh
# 2. Fill in the two values a human must provide:
# ANTHROPIC_API_KEY=sk-ant-...
# GITLAB_MCP_TOKEN=glpat-... (fine-grained, "MCP tool: Execute")
$EDITOR .env.local
# 3. Cluster: k3d `urumi` (1 server + 1 agent), registry localhost:5005, Traefik on 80/443,
# namespace `urumi`, Secret `urumi-env` from .env.local. Safe to re-run.
scripts/cluster-up.sh
# 4. Build and push all images (gateway, dashboard, woo-mcp, everything) to the k3d registry.
scripts/build-images.sh
# 5. Install the chart. The bootstrap hook installs WordPress + WooCommerce and starts woo-mcp.
helm upgrade --install urumi charts/platform -n urumi -f charts/platform/values-local.yaml --wait
# 6. Run the eight demo flows.
tests/e2e/run.sh all
If you edit .env.local later (e.g. add a key), run scripts/secrets-apply.sh and
kubectl -n urumi rollout restart deploy/urumi-gateway.
What the two hand-filled values gate
| Missing | Effect |
|---|---|
ANTHROPIC_API_KEY |
Gateway starts and logs chat is disabled. POST /api/chat answers 503 "Chat is not configured". D2 and D4 fail; the rest pass. |
GITLAB_MCP_TOKEN |
tests/e2e/seed-upstreams.sh stops with GITLAB_MCP_TOKEN is empty, so the seed step fails and run.sh all reports every step D1–D8 as FAIL Dn: seed failed. A token without the right scope registers, but the gitlab upstream stays down and the seed's "all three up" check fails the same way. |
The flows themselves only call woo and everything tools; GitLab is required because the
seed (and D3's tool listing) checks that all three upstreams are up. The gateway itself does not
need GitLab: register a different third server (ADR 002's fallback is DeepWiki,
https://mcp.deepwiki.com/mcp, no credential) from the dashboard if you only want to click
through.
URLs (*.localtest.me is public DNS that resolves to 127.0.0.1, no /etc/hosts edits):
| URL | What |
|---|---|
http://store.localtest.me |
storefront |
http://store.localtest.me/wp-admin |
WooCommerce admin: WP_ADMIN_USER / WP_ADMIN_PASSWORD |
http://app.localtest.me |
dashboard: SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD |
http://app.localtest.me/mcp |
MCP endpoint for external clients (Authorization: Bearer urk_…) |
Read the seed admin login from your own .env.local (grep '^SEED_ADMIN_' .env.local).
Expected e2e output — one seed line, then one line per flow; diagnostics go to
tests/e2e/.out/<step>.log. The exit code is non-zero if any step fails.
PASS seed
PASS D1
PASS D2
PASS D3
PASS D4
PASS D5
PASS D6
PASS D7
PASS D8
Single steps: tests/e2e/run.sh D3 D5 (seeds first if there is no current run).
Timing (approximate, not a benchmark): a fresh install from nothing — cluster, four image
builds, chart install with the WooCommerce download — takes on the order of 10 minutes on a
laptop, most of it image builds and the bootstrap Job. run.sh all takes a few minutes; D7 alone
waits for one health cycle down (≤ 25 s) and back up (≤ 60 s).
Demo walkthrough (manual D1–D8)
tests/e2e/run.sh all creates two members, usera@urumi.test (User A) and userb@urumi.test
(User B); their temporary passwords are kept in tests/e2e/.out/users.json. To click through by
hand, create them yourself under Members (admin), which shows each temporary password once.
- D1 — store checkout. Open
http://store.localtest.me, add Demo Tee to the cart, check out with Cash on delivery. Inwp-admin→ WooCommerce → Orders, the order is there. - D2 — User A chat. Sign in to the dashboard as User A → Chat: "Create a product called
Urumi Mug priced 499". The reply lists
woo__create_productunder Tools used. Place an order for it on the storefront, then ask "What orders came in today?". "Today" is the Asia/Kolkata calendar day bydate_created. - D3 — User B external client. As User B → My keys → create a key and copy it (shown
once). The page also shows an MCP client configuration snippet. Then:
Transport Streamable HTTP, URLnpx @modelcontextprotocol/inspectorhttp://app.localtest.me/mcp, headerAuthorization: Bearer urk_…→ List Tools showswoo__*,everything__*,gitlab__*→ callwoo__list_orderswith{"date":"today"}. - D4 — two servers in one prompt. As User A in Chat: "Call everything's echo tool with
message 'urumi' and list today's store orders." Tools used shows an
everything__echoand awoo__list_orderscall. A woo + gitlab prompt ("summarise today's orders and open a GitLab issue with the summary") uses the same path but writes to GitLab, so the scripted D4 useseverythinginstead. - D5 — revoke. Admin → Members → User B → keys → revoke (or User B revokes it under My keys). In the Inspector, List Tools again → 401.
- D6 — disable a server. Admin → Upstreams → Disable
everything. Its tools vanish from Tools, from the chat tool set, and from the Inspector's next List Tools (the endpoint is stateless, so there is no push; re-list). Enable restores them. - D7 — kill an upstream.
kubectl -n urumi scale deploy/urumi-everything --replicas=0(or delete its pod). Within one health cycle (15 s + 3 s) Upstreams showseverythingdown with its last error;wootools keep working in chat and in the Inspector. Scale back to 1 and it returns to up. - D8 — audit. Admin → Audit: every call above, with user, source (
chat/mcp), server, tool, time, duration, outcome. Members see only their own rows.
Local → VPS
The same chart and templates; only the values file and the out-of-band Secrets change.
charts/platform/values-prod.yaml differs from values-local.yaml in:
| Setting | Local | Prod (values-prod.yaml) |
|---|---|---|
store.host, app.host |
store.localtest.me, app.localtest.me |
placeholders store.example.com, app.example.com |
store.tls, app.tls |
off | off until a certificate Secret (store-tls, app-tls) exists |
gateway.publicOrigin |
http://app.localtest.me |
https://app.example.com |
gateway.cookieSecure |
false |
true |
bootstrap.environmentType |
local (App Passwords over HTTP) |
production (HTTPS required) |
wooMcp.forwardedProto |
empty | https (in-cluster HTTP calls claim HTTPS to WordPress) |
*.persistence.storageClass |
local-path, 1–2 Gi |
cluster default, 5 Gi |
*.image.repository / tag |
k3d-urumi-registry.localhost:5005/…:dev, Always |
ghcr.io/<owner>/urumi-mcp-gateway/…, tag empty, IfNotPresent |
Resource requests/limits are the same in both (set in values.yaml).
Steps on a single-node k3s VPS (Traefik is k3s's default ingress, matching ingress.className):
- DNS:
Arecords for both hosts → the VPS IP. - Images: push the four images to a registry the VPS can pull from; set every
*.image.repositoryandtagin a copy ofvalues-prod.yaml, plus the hosts andgateway.publicOrigin. - Secrets, created out-of-band (the chart never renders a Secret;
tests/infra/chart-checks.shenforces this):kubectl -n urumi create secret generic urumi-env --from-env-file=<file>with the same keys as.env.local, generated on the server, never committed. - TLS: either install cert-manager with a Let's Encrypt
ClusterIssuerand aCertificateper host writingstore-tls/app-tls, or configure Traefik's ACME resolver (k3sHelmChartConfig). Then setstore.tls.enabled/app.tls.enabled: true. Neither cert-manager nor ACME is part of this chart. helm upgrade --install urumi charts/platform -n urumi -f values-prod.yaml --wait, then register the three upstreams in the dashboard (Upstreams).tests/e2e/seed-upstreams.shis written for the local cluster: it reads the Secret through the pinnedk3d-urumikubectl context.- Egress: the bootstrap Job downloads WooCommerce from wordpress.org; the gateway needs
gitlab.com:443andapi.anthropic.com:443. No egress NetworkPolicy exists today.
Honest status. The VPS deployment is optional and has not been run. What is tested: tests/infra/chart-checks.sh renders the chart with both
values-local.yaml and values-prod.yaml and checks every container has probes and
requests/limits and that no Secret is rendered. Not tested: TLS, cert-manager/ACME, a real
StorageClass, pulling from a remote registry, environmentType: production with
X-Forwarded-Proto, and CI does not build or push images.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
cluster-up.sh: port 80 is in use |
Another server holds 80/443. HTTP_PORT=8080 HTTPS_PORT=8443 scripts/cluster-up.sh, then use http://app.localtest.me:8080. Ports are fixed at creation: scripts/cluster-down.sh --yes first. The e2e scripts assume port 80. |
Pods Pending/OOMKilled, helm --wait times out |
Docker Desktop has too little memory. Give it ≥ 6 GB (Settings → Resources), restart, re-run helm upgrade --install. |
urumi-woo-mcp shows 0/0 after install |
Expected for a moment: the chart renders woo-mcp with 0 replicas until the bootstrap Job has written Secret urumi-woo-api, then the Job scales it to 1. If it stays at 0: kubectl -n urumi logs job/urumi-bootstrap --all-containers (the Job is kept for 1 h). |
Bootstrap fails the GET /wp-json/wc/v3/orders check (401) |
WordPress accepts Application Passwords only over HTTPS unless WP_ENVIRONMENT_TYPE=local. Locally keep bootstrap.environmentType: local; in prod serve HTTPS and keep wooMcp.forwardedProto: https. |
gitlab upstream down, lastError HTTP 403; seed fails |
GITLAB_MCP_TOKEN is a classic PAT or lacks "MCP tool: Execute", or has expired. Create a fine-grained PAT, put it in .env.local, scripts/secrets-apply.sh, then tests/e2e/seed-upstreams.sh (re-sends the credential). |
| Chat says "Chat is not configured" (HTTP 503) | ANTHROPIC_API_KEY is empty in Secret urumi-env. Fill it in, scripts/secrets-apply.sh, kubectl -n urumi rollout restart deploy/urumi-gateway. |
| Chat says "The chat model is unavailable, try again" | Anthropic returned an error (bad key, no credit, model id). The gateway logs only the error class and HTTP status: kubectl -n urumi logs deploy/urumi-gateway | grep anthropic. |
| A rebuilt image does not show up | Tags are :dev with pullPolicy: Always: kubectl -n urumi rollout restart deploy/urumi-<component>. |
| Start over | helm uninstall keeps PVCs, the bootstrap Job and Secret urumi-woo-api. Delete the Job before the PVCs (its pod holds the WordPress PVC), or simply scripts/cluster-down.sh --yes (--all also removes the registry). |
Repo layout
apps/woo-mcp/ WooCommerce MCP server (TypeScript, Streamable HTTP, internal-only)
apps/gateway/ MCP gateway + admin API + chat agent backend (Fastify, Postgres)
apps/dashboard/ React + Vite admin/member UI and chat panel (served by nginx)
apps/everything/ Dockerfile for @modelcontextprotocol/server-everything (upstream 2)
packages/contracts/ Shared zod schemas: API, DB schema (sql/), tool naming, error envelope
charts/platform/ The one Helm chart; values.yaml, values-local.yaml, values-prod.yaml
tests/e2e/ D1–D8 scripted flows (run.sh <step|all>), seed scripts
tests/infra/ Cluster checks: chart, persistence, bootstrap, NetworkPolicies, auth
scripts/ dev-setup, gen-env, cluster-up/down, secrets-apply, build-images
docs/ REQUIREMENTS, PLAN, DESIGN, THREAT_MODEL, adr/
Tests
npm test # unit + contract tests in every workspace (vitest)
npm run lint # eslint + prettier --check
npm run typecheck # tsc --noEmit in every workspace
- Gateway tests run against a real Postgres: a throwaway Docker container locally, or
TEST_DATABASE_URL(CI uses apostgres:16-alpineservice). Upstreams are fake MCP servers (apps/gateway/test/helpers/fake-upstream.ts); only external boundaries are faked. - Cluster checks (need the local cluster):
tests/infra/chart-checks.sh(offline),persistence.sh,store-bootstrap.sh,gateway-db.sh,gateway-auth.sh,upstreams.sh,woo-mcp-netpol.sh, plusscripts/smoke-ingress.sh. - End to end:
tests/e2e/run.sh all.
CI (.github/workflows/ci.yml, GitHub Actions): lint (eslint + prettier + typecheck) and
test (all workspaces against a postgres:16-alpine service). CI does not build images, run the
infra checks or run the e2e flows; those run against a local cluster.