- Add GET/POST /login to hermes for first-party cookie during OAuth popup (fixes browser CHIPS cookie partitioning that broke claude.ai connection) - Add role column to all findCustomer* SQL queries in src/auth.ts - Add claude.ai tab to docs/getting-started.html with OAuth flow steps - Add ARCHITECTURE.md with system diagrams, data flow, and key invariants - Rewrite README.md and DEPLOY.md to reflect actual MicroK8s deployment - Deploy updated docs site (squaremcp-docs sha256 updated) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
222 lines
9.9 KiB
Markdown
222 lines
9.9 KiB
Markdown
# 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 <JWT> → decode JWT → resolve Customer
|
||
Authorization: Bearer <oauth-tok> → validate in oauth_tokens table
|
||
4. Cookie: session=<JWT> → 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 <token>
|
||
│
|
||
└─► 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
|