# SquareMCP — Architecture ## System Overview SquareMCP is a multi-tenant MCP (Model Context Protocol) gateway. It lets AI assistants (claude.ai, Codex CLI, opencode, Claude Desktop) control social media, email, and messaging platforms on behalf of authenticated users. **Three public services:** | Subdomain | Purpose | K8s pod | Port | |-----------|---------|---------|------| | `hermes.squaremcp.com` | MCP API + OAuth server | `hermes-mcp` | 3456 | | `app.squaremcp.com` | Customer dashboard SPA | `squaremcp-app` | 8080 | | `docs.squaremcp.com` | Developer documentation | `squaremcp-docs` | 80 | --- ## Infrastructure ``` Internet │ ┌─────────▼─────────┐ │ nginx ingress │ │ (MicroK8s addon) │ │ TLS via cert-mgr │ └─────────┬─────────┘ │ ┌───────────┼───────────┐ │ │ │ ┌─────────▼──┐ ┌─────▼──────┐ ┌▼────────────┐ │ hermes-mcp │ │squaremcp- │ │squaremcp- │ │ pod │ │ app pod │ │ docs pod │ │ :3456 │ │ :8080 │ │ :80 │ │hostNetwork │ └────────────┘ └─────────────┘ └──────┬──────┘ │ (same host network) ┌──────────┼──────────┐ │ │ │ ┌────▼───┐ ┌───▼────┐ ┌───▼──────────────────┐ │ MySQL 8│ │Redis 7 │ │ obsidian vaults │ │ :3306 │ │ :6379 │ │ (hostPath volume) │ │(Docker)│ │(Docker)│ └──────────────────────┘ └────────┘ └────────┘ Namespace: fetcherpay Registry: localhost:32000 (MicroK8s built-in) TLS: Let's Encrypt via cert-manager ``` **`hermes-mcp` uses `hostNetwork: true`** — the pod binds port 3456 directly on the host interface, sharing MySQL and Redis at `127.0.0.1`. --- ## Deployment Pattern ```bash # Every code change: npm run build docker build -t localhost:32000/hermes-mcp:latest . docker push localhost:32000/hermes-mcp:latest # Get the digest from the push output (or docker inspect) # Update image sha256 in hermes-k8s.yaml — never use :latest tag in k8s microk8s kubectl apply -f hermes-k8s.yaml microk8s kubectl rollout status deployment/hermes-mcp -n fetcherpay ``` `hermes-k8s.yaml` is gitignored (contains plaintext secrets). Each deploy updates the sha256 image digest. --- ## Authentication Three methods accepted in priority order: ``` 1. x-api-key = global MCP_API_KEY → superadmin 2. x-api-key = customer API key → resolve Customer from MySQL 3. Authorization: Bearer → decode JWT → resolve Customer Authorization: Bearer → validate in oauth_tokens table 4. Cookie: session= → decode → resolve Customer ``` **JWT payload:** `{ sub: customerId, email, plan, role }` **Cookie settings:** `httpOnly`, `secure`, `sameSite: lax`, `domain: .squaremcp.com`, 7-day TTL --- ## OAuth 2.0 Flow (browser-based clients) ``` MCP Client (popup) hermes.squaremcp.com │ │ ├─ POST /oauth/register ────► Dynamic Client Registration │◄─ { client_id, secret } ──┤ │ ├─ GET /oauth/authorize ────► │ no session? │ │◄─ 302 /login?return_to=.. ┤ │ ├─ GET /login ─────────────► │◄─ HTML login form ───────── │ ├─ POST /login ────────────► verify credentials │ set-cookie: session (first-party!) │◄─ 302 /oauth/authorize ───┤ │ ├─ GET /oauth/authorize ────► session found ✓ │◄─ consent HTML ───────────┤ │ ├─ POST /oauth/authorize ───► action=allow │◄─ 302 redirect_uri?code=. ┤ │ ├─ POST /oauth/token ───────► PKCE code exchange │◄─ { access_token } ───────┤ │ ├─ POST /mcp ───────────────► Bearer access_token │◄─ MCP tools ──────────────┤ ``` **Cookie partitioning note:** Browsers (CHIPS) partition cookies per top-level site. The OAuth popup runs on `hermes.squaremcp.com`, so login must happen on hermes — not on `app.squaremcp.com` — for the session cookie to be visible in that context. `/login` on hermes exists specifically for this reason. --- ## Multi-Tenancy: Tool Call Data Flow ``` AI assistant │ POST /mcp Authorization: Bearer │ └─► requireAuth resolves token → Customer { id, plan, getCredential() } │ └─► createMcpServer(customer) │ └─► handleToolCall("linkedin_create_post", args, customer) │ ├─ checkLimit(customerId, plan) → OK or error │ ├─ customer.getCredential("linkedin") │ Redis GET creds:{customerId}:linkedin │ AES-256-GCM decrypt → { accessToken } │ ├─ linkedin.createPost(accessToken, content) │ POST api.linkedin.com/v2/ugcPosts │ └─ recordUsage(customerId, "linkedin", "linkedin_create_post") INSERT INTO usage_logs ``` **Credentials** are AES-256-GCM encrypted in Redis at `creds:{customerId}:{platform}`. Key is `CREDENTIAL_ENCRYPTION_KEY` env var — do not rotate without re-encrypting all stored values. --- ## Platform Clients (51 tools, 11 platforms) | Platform | Key tools | |----------|-----------| | Email (IMAP/SMTP) | search_messages, read_message, send_email, create_draft, list_folders | | Obsidian | search_notes, read_note, append_to_note, update_note, sync_status | | WhatsApp Business | send_message, send_template, list_templates, get_message_status | | LinkedIn | get_profile, create_post, search_connections, send_message, upload_video | | TikTok | get_profile, get_creator_info, create_video, get_video_status | | Facebook | get_page, get_posts, create_post, create_photo_post, create_video_post | | Instagram | get_profile, get_media, create_post, create_reel | | Twitter/X | get_user_profile, get_user_tweets, search_tweets, create_tweet, upload_video | | Telegram | get_me, send_message, send_photo, get_updates, get_chat | | Discord | get_me, get_guilds, get_channels, send_message, get_messages | | Snapchat | get_me, get_ad_accounts, create_snap | --- ## Source Layout ``` src/ ├── index.ts — Express app, all routes, MCP session management ├── tools.ts — handleToolCall(), tools[] registry ├── oauth.ts — OAuth 2.0 + PKCE + DCR ├── auth.ts — bcrypt, JWT, findCustomer* queries ├── db.ts — MySQL pool, ensureColumn() migration helper ├── redis.ts — shared Redis singleton ├── imap.ts / smtp.ts — multi-account email ├── manifest.ts — OpenAPI schema generation ├── billing/ │ ├── plans.ts — plan definitions + limits │ ├── middleware.ts — Customer interface, meterMiddleware, API key resolution │ ├── usage.ts — recordUsage(), checkLimit() │ ├── invoices.ts — invoice generation + email │ └── cron.ts — monthly invoice cron (Redis distributed lock) ├── multitenancy/ │ ├── credential-store.ts — AES-256-GCM per-customer credentials │ ├── token-refresh.ts — OAuth token refresh per platform │ ├── platform-health.ts — health checks + Redis cache │ ├── webhook-router.ts — WhatsApp inbound phone → customerId routing │ └── audit-log.ts — per-customer tool call audit trail ├── webhooks/ │ └── delivery.ts — outbound HMAC webhook + 3× retry + Redis DLQ └── clients/ └── discord / facebook / instagram / linkedin / obsidian / snapchat / telegram / tiktok / twitter / whatsapp .ts product/ └── app/ — SaaS dashboard SPA (app.squaremcp.com) docs/ — Developer docs site (docs.squaremcp.com) ``` --- ## Key Invariants 1. **ESM `.js` extensions in imports** — `moduleResolution: bundler`; always `import from './foo.js'` even for `.ts` files 2. **Digest pinning in K8s manifests** — always use `image: registry/name@sha256:...`, never `:latest` in production 3. **`hermes-k8s.yaml` is gitignored** — never force-add; contains plaintext credentials 4. **`CREDENTIAL_ENCRYPTION_KEY` is immutable** — rotating it requires re-encrypting all Redis-stored credentials 5. **`account` param is internal** — `stripAccountParam()` removes it from args before presenting tools in multi-tenant MCP sessions 6. **WhatsApp inbound webhook needs raw body** — uses `express.raw({ type: '*/*' })` for HMAC verification; do not switch to `express.json()` 7. **Cookie first-party requirement** — always set session cookies in the domain that will read them; the `/login` route on hermes exists for this reason