feat: native OAuth login page, architecture docs, docs site update
- 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>
This commit is contained in:
221
ARCHITECTURE.md
Normal file
221
ARCHITECTURE.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# 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
|
||||
275
DEPLOY.md
275
DEPLOY.md
@@ -1,155 +1,68 @@
|
||||
# Hermes MCP — Setup & Deployment
|
||||
# Hermes MCP — Deployment Runbook
|
||||
|
||||
Hermes is a multi-account email MCP server for Claude AI.
|
||||
It supports **Yahoo Mail** (IMAP App Password) and any **custom IMAP/SMTP server**.
|
||||
Production deployment runs on a single-node MicroK8s cluster in the `fetcherpay` namespace. Three services are deployed: `hermes-mcp` (API), `squaremcp-app` (SaaS UI), `squaremcp-docs` (docs).
|
||||
|
||||
---
|
||||
|
||||
## Local development
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
npm install
|
||||
|
||||
# 2. Copy and fill in credentials
|
||||
cp .env.example .env
|
||||
# edit .env with your email credentials
|
||||
|
||||
# 3. Run in dev mode (hot-reload)
|
||||
npm run dev
|
||||
|
||||
# 4. Test health
|
||||
curl http://localhost:3456/health
|
||||
# → {"status":"ok","service":"hermes-mcp"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production deployment (Kubernetes example)
|
||||
|
||||
Example deployment using MicroK8s single-node cluster.
|
||||
SSL is handled by `cert-manager` with a Let's Encrypt certificate.
|
||||
|
||||
### Prerequisites on the server
|
||||
- MicroK8s with addons: `dns`, `ingress`, `registry`, `cert-manager`
|
||||
- Local registry at `localhost:32000`
|
||||
- A `ClusterIssuer` named `letsencrypt-prod` already configured
|
||||
- Local image registry at `localhost:32000`
|
||||
- `ClusterIssuer` named `letsencrypt-prod` configured
|
||||
- MySQL 8 running as Docker container on host (`127.0.0.1:3306`)
|
||||
- Redis 7 running as Docker container on host (`127.0.0.1:6379`)
|
||||
|
||||
---
|
||||
|
||||
## Deploying hermes-mcp
|
||||
|
||||
### One-time: create K8s namespace and secret
|
||||
```bash
|
||||
microk8s kubectl create namespace hermes-mcp # if it doesn't exist
|
||||
# 1. Build TypeScript
|
||||
npm run build
|
||||
|
||||
microk8s kubectl create secret generic hermes-mcp-env -n hermes-mcp \
|
||||
--from-literal=YAHOO_EMAIL=your@yahoo.com \
|
||||
--from-literal=YAHOO_APP_PASSWORD=your-app-password \
|
||||
--from-literal=CUSTOM_EMAIL=your@domain.com \
|
||||
--from-literal=CUSTOM_PASSWORD=your-password \
|
||||
--from-literal=CUSTOM_IMAP_HOST=mail.yourdomain.com \
|
||||
--from-literal=CUSTOM_IMAP_PORT=993 \
|
||||
--from-literal=CUSTOM_SMTP_HOST=mail.yourdomain.com \
|
||||
--from-literal=CUSTOM_SMTP_PORT=587 \
|
||||
--from-literal=PORT=3456
|
||||
```
|
||||
|
||||
### Build & push image
|
||||
```bash
|
||||
# Option 1: Build locally and push to your registry
|
||||
# 2. Build and push Docker image
|
||||
docker build -t localhost:32000/hermes-mcp:latest .
|
||||
docker push localhost:32000/hermes-mcp:latest
|
||||
|
||||
# Option 2: Copy to server and build there
|
||||
scp -r src/ package*.json tsconfig.json Dockerfile user@your-server:~/hermes-mcp/
|
||||
ssh user@your-server "cd ~/hermes-mcp && docker build -t localhost:32000/hermes-mcp:latest . && docker push localhost:32000/hermes-mcp:latest"
|
||||
```
|
||||
# 3. Get the sha256 digest from push output, or:
|
||||
docker inspect localhost:32000/hermes-mcp:latest \
|
||||
--format='{{index .RepoDigests 0}}'
|
||||
|
||||
### Apply K8s manifests
|
||||
```yaml
|
||||
# hermes-k8s.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: hermes-mcp
|
||||
namespace: hermes-mcp
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: hermes-mcp
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: hermes-mcp
|
||||
spec:
|
||||
containers:
|
||||
- name: hermes-mcp
|
||||
image: localhost:32000/hermes-mcp:latest
|
||||
ports:
|
||||
- containerPort: 3456
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: hermes-mcp-env
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3456
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: hermes-mcp
|
||||
namespace: hermes-mcp
|
||||
spec:
|
||||
selector:
|
||||
app: hermes-mcp
|
||||
ports:
|
||||
- port: 3456
|
||||
targetPort: 3456
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: hermes-ingress
|
||||
namespace: hermes-mcp
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
nginx.ingress.kubernetes.io/proxy-buffering: "off"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: hermes.yourdomain.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: hermes-mcp
|
||||
port:
|
||||
number: 3456
|
||||
tls:
|
||||
- hosts:
|
||||
- hermes.yourdomain.com
|
||||
secretName: hermes-tls
|
||||
```
|
||||
# 4. Update hermes-k8s.yaml
|
||||
# Change: image: localhost:32000/hermes-mcp@sha256:<old>
|
||||
# To: image: localhost:32000/hermes-mcp@sha256:<new>
|
||||
|
||||
Apply the manifests:
|
||||
```bash
|
||||
# 5. Apply
|
||||
microk8s kubectl apply -f hermes-k8s.yaml
|
||||
|
||||
# 6. Wait for rollout
|
||||
microk8s kubectl rollout status deployment/hermes-mcp -n fetcherpay
|
||||
```
|
||||
|
||||
### Redeploy after code changes
|
||||
```bash
|
||||
# Rebuild and push the image
|
||||
docker build -t localhost:32000/hermes-mcp:latest .
|
||||
docker push localhost:32000/hermes-mcp:latest
|
||||
**Important:** `hermes-k8s.yaml` is gitignored — it contains plaintext secrets. Never force-add it to git. Always use sha256 digest (never `:latest`) in the manifest.
|
||||
|
||||
# Restart the deployment
|
||||
microk8s kubectl rollout restart deployment/hermes-mcp -n hermes-mcp
|
||||
microk8s kubectl rollout status deployment/hermes-mcp -n hermes-mcp
|
||||
---
|
||||
|
||||
## Deploying squaremcp-app (dashboard UI)
|
||||
|
||||
```bash
|
||||
docker build -f product/app/Dockerfile . -t localhost:32000/squaremcp-app:latest
|
||||
docker push localhost:32000/squaremcp-app:latest
|
||||
|
||||
# Update sha256 in product/app/app-k8s.yaml, then:
|
||||
microk8s kubectl apply -f product/app/app-k8s.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deploying squaremcp-docs
|
||||
|
||||
```bash
|
||||
docker build -f docs/Dockerfile . -t localhost:32000/squaremcp-docs:latest
|
||||
docker push localhost:32000/squaremcp-docs:latest
|
||||
|
||||
# Update sha256 in docs/docs-k8s.yaml, then:
|
||||
microk8s kubectl apply -f docs/docs-k8s.yaml
|
||||
```
|
||||
|
||||
---
|
||||
@@ -157,48 +70,80 @@ microk8s kubectl rollout status deployment/hermes-mcp -n hermes-mcp
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
# Logs
|
||||
microk8s kubectl logs -n hermes-mcp -l app=hermes-mcp --tail=100 -f
|
||||
# Check pod status
|
||||
microk8s kubectl get pods -n fetcherpay
|
||||
|
||||
# Pod status
|
||||
microk8s kubectl get pods -n hermes-mcp -l app=hermes-mcp
|
||||
|
||||
# Update a single env var without rebuild (takes effect on next rollout)
|
||||
microk8s kubectl set env deployment/hermes-mcp -n hermes-mcp KEY=value
|
||||
microk8s kubectl rollout restart deployment/hermes-mcp -n hermes-mcp
|
||||
# Live logs
|
||||
microk8s kubectl logs -n fetcherpay -l app=hermes-mcp -f --tail=100
|
||||
|
||||
# Health check
|
||||
curl https://hermes.yourdomain.com/health
|
||||
curl https://hermes.squaremcp.com/health
|
||||
|
||||
# Inject env var without rebuild (takes effect after rollout)
|
||||
microk8s kubectl set env deployment/hermes-mcp -n fetcherpay KEY=value
|
||||
microk8s kubectl rollout restart deployment/hermes-mcp -n fetcherpay
|
||||
|
||||
# Restart all pods
|
||||
microk8s kubectl rollout restart deployment -n fetcherpay
|
||||
|
||||
# Check TLS certificates
|
||||
microk8s kubectl get certificate -n fetcherpay
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Add to Claude.ai
|
||||
## Environment variables (hermes-mcp)
|
||||
|
||||
1. Go to **Claude.ai → Settings → Connectors → Add custom connector**
|
||||
2. Enter URL: `https://hermes.yourdomain.com/mcp` (or your server's URL)
|
||||
3. Click Connect
|
||||
Key variables in `hermes-k8s.yaml`:
|
||||
|
||||
### Available tools
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `PORT` | Server port (3456) |
|
||||
| `SERVER_URL` | Public base URL (`https://hermes.squaremcp.com`) |
|
||||
| `MCP_API_KEY` | Global superadmin API key |
|
||||
| `MYSQL_HOST/PORT/USER/PASSWORD` | MySQL connection |
|
||||
| `REDIS_URL` | Redis connection (`redis://127.0.0.1:6379`) |
|
||||
| `CREDENTIAL_ENCRYPTION_KEY` | AES-256-GCM key for stored platform credentials |
|
||||
| `OAUTH_CLIENT_ID/SECRET` | Pre-registered OAuth app credentials |
|
||||
| `OBSIDIAN_VAULT_PATH` | Mount path for Obsidian vault (`/vaults`) |
|
||||
| `YAHOO_EMAIL / YAHOO_APP_PASSWORD` | Default Yahoo IMAP account |
|
||||
| `GMAIL_EMAIL / GMAIL_APP_PASSWORD` | Default Gmail account |
|
||||
| `FETCHERPAY_*` | Fetcherpay email IMAP/SMTP |
|
||||
|
||||
| Tool | Description | Key params |
|
||||
|------|-------------|------------|
|
||||
| `get_profile` | Get email address for an account | `account` |
|
||||
| `search_messages` | Search INBOX by keyword/sender/subject | `q`, `maxResults`, `account` |
|
||||
| `read_message` | Read full message body by UID | `uid`, `account` |
|
||||
| `list_folders` | List all mailbox folders | `account` |
|
||||
| `create_draft` | Save a draft to the Drafts folder | `to`, `subject`, `body`, `account` |
|
||||
| `send_email` | Send an email | `to`, `subject`, `body`, `account` |
|
||||
|
||||
`account` is always optional and defaults to `"yahoo"`. Configure your second account in the code if needed.
|
||||
**Do not rotate `CREDENTIAL_ENCRYPTION_KEY`** without first re-encrypting all stored customer credentials in Redis.
|
||||
|
||||
---
|
||||
|
||||
## Known issues & fixes
|
||||
## Schema migrations
|
||||
|
||||
| Issue | Cause | Fix |
|
||||
|-------|-------|-----|
|
||||
| `read_message` timeout | `source: true` downloads full raw RFC822 | Use `bodyParts: ['TEXT']` instead |
|
||||
| `messageFlagsAdd` deadlock | Called inside `for await` loop while FETCH active | Moved to after the loop |
|
||||
| Stale session after pod restart | Session ID guard blocked re-initialize | Accept initialize regardless of session ID |
|
||||
| `EAI_AGAIN` DNS errors | K8s internal DNS resolution issues | Use direct IP instead of hostname |
|
||||
`src/db.ts` uses `ensureColumn()` — an idempotent helper that `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`. Run migrations by deploying a new image; the startup hook runs automatically.
|
||||
|
||||
To run a migration manually:
|
||||
```bash
|
||||
mysql -h 127.0.0.1 -u root -pfetcherpay hermes_oauth
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Domains and TLS
|
||||
|
||||
| Domain | K8s Ingress | TLS secret |
|
||||
|--------|-------------|------------|
|
||||
| `hermes.squaremcp.com` | `hermes-ingress` | `hermes-squaremcp-tls` |
|
||||
| `app.squaremcp.com` | `squaremcp-app-ingress` | `squaremcp-app-tls` |
|
||||
| `docs.squaremcp.com` | `squaremcp-docs-ingress` | `squaremcp-docs-tls` |
|
||||
|
||||
TLS certificates are auto-provisioned by cert-manager from Let's Encrypt. Check certificate status:
|
||||
```bash
|
||||
microk8s kubectl describe certificate -n fetcherpay
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
To roll back to the previous image, update the sha256 digest in the manifest to the previous value and re-apply:
|
||||
```bash
|
||||
microk8s kubectl apply -f hermes-k8s.yaml
|
||||
microk8s kubectl rollout status deployment/hermes-mcp -n fetcherpay
|
||||
```
|
||||
|
||||
204
README.md
204
README.md
@@ -1,108 +1,130 @@
|
||||
# Hermes MCP
|
||||
# Hermes MCP — SquareMCP Gateway
|
||||
|
||||
Hermes MCP is a hosted MCP gateway for messaging, knowledge, and social connectors.
|
||||
Hermes is the MCP server powering [SquareMCP](https://squaremcp.com). It exposes 51 tools across 11 platforms (email, Obsidian, WhatsApp, LinkedIn, TikTok, Facebook, Instagram, Twitter, Telegram, Discord, Snapchat) over Streamable HTTP, with per-user authentication, OAuth 2.0, and multi-tenant credential isolation.
|
||||
|
||||
The production endpoint is:
|
||||
|
||||
```text
|
||||
**Production endpoint:**
|
||||
```
|
||||
https://hermes.squaremcp.com/mcp
|
||||
```
|
||||
|
||||
Hermes currently supports MCP access patterns for:
|
||||
---
|
||||
|
||||
1. email
|
||||
2. Obsidian vault notes
|
||||
3. WhatsApp Business
|
||||
4. LinkedIn
|
||||
5. Telegram
|
||||
6. additional social connectors that are in various states of credentialing and rollout
|
||||
## Quick connect
|
||||
|
||||
### claude.ai
|
||||
1. Settings → MCP Servers → Add → enter `https://hermes.squaremcp.com`
|
||||
2. Complete the OAuth popup (login with your SquareMCP credentials)
|
||||
3. Click "Connect MCP client"
|
||||
|
||||
### Claude Desktop
|
||||
```json
|
||||
// ~/Library/Application Support/Claude/claude_desktop_config.json
|
||||
{
|
||||
"mcpServers": {
|
||||
"squaremcp": {
|
||||
"type": "http",
|
||||
"url": "https://hermes.squaremcp.com/mcp",
|
||||
"headers": { "Authorization": "Bearer YOUR_TOKEN" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Codex CLI
|
||||
```toml
|
||||
# ~/.codex/config.toml
|
||||
[mcp_servers.squaremcp]
|
||||
url = "https://hermes.squaremcp.com/mcp"
|
||||
headers = { Authorization = "Bearer YOUR_TOKEN" }
|
||||
```
|
||||
|
||||
### opencode
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"squaremcp": {
|
||||
"type": "remote",
|
||||
"url": "https://hermes.squaremcp.com/mcp",
|
||||
"headers": { "x-api-key": "YOUR_API_KEY" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Get your token from the [SquareMCP dashboard](https://app.squaremcp.com) → Connect MCP Client.
|
||||
|
||||
---
|
||||
|
||||
## What Hermes is for
|
||||
## Architecture
|
||||
|
||||
Hermes is the integration and connector layer behind broader product work such as SquareMCP.
|
||||
See [ARCHITECTURE.md](./ARCHITECTURE.md) for the full architecture with diagrams.
|
||||
|
||||
Use Hermes when you want:
|
||||
```
|
||||
nginx ingress (TLS)
|
||||
│
|
||||
┌────────────┼────────────┐
|
||||
│ │ │
|
||||
hermes-mcp squaremcp-app squaremcp-docs
|
||||
:3456 :8080 :80
|
||||
(MCP API) (SaaS UI) (Docs)
|
||||
│
|
||||
┌─────────┼─────────┐
|
||||
│ │ │
|
||||
MySQL 8 Redis 7 /vaults
|
||||
(creds + (cache + (Obsidian)
|
||||
billing) DLQ)
|
||||
```
|
||||
|
||||
1. an MCP endpoint that exposes real tools over Streamable HTTP
|
||||
2. API-key or OAuth-protected access
|
||||
3. connector access from agent clients such as Codex CLI, Claude Code, opencode, or ChatGPT
|
||||
**Stack:** TypeScript / Node.js, Express, MySQL 8, Redis 7, MicroK8s, Docker
|
||||
**Auth:** JWT session cookies + OAuth 2.0 PKCE + API key
|
||||
**Platform clients:** 11 platforms, 51 MCP tools
|
||||
|
||||
---
|
||||
|
||||
## Core transports
|
||||
## Platforms
|
||||
|
||||
| Transport | URL |
|
||||
|----------|-----|
|
||||
| Streamable HTTP (preferred) | `https://hermes.squaremcp.com/mcp` |
|
||||
| Legacy SSE | `https://hermes.squaremcp.com/sse` |
|
||||
| Platform | Tools |
|
||||
|----------|-------|
|
||||
| Email (IMAP/SMTP) | search, read, send, draft, folders |
|
||||
| Obsidian | search, read, append, update, sync |
|
||||
| WhatsApp Business | send, template, list templates |
|
||||
| LinkedIn | profile, post, search connections, message, video |
|
||||
| TikTok | profile, creator info, upload video, status |
|
||||
| Facebook | page, posts, post, photo, video |
|
||||
| Instagram | profile, media, post, reel |
|
||||
| Twitter/X | profile, tweets, search, tweet, video |
|
||||
| Telegram | me, send, photo, updates, chat |
|
||||
| Discord | me, guilds, channels, send, messages |
|
||||
| Snapchat | me, ad accounts, create snap |
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
Hermes accepts:
|
||||
Hermes accepts (in priority order):
|
||||
|
||||
1. `x-api-key` header
|
||||
2. `?key=` query parameter
|
||||
3. `Authorization: Bearer ...` for OAuth-based clients
|
||||
1. `x-api-key` header — global superadmin or per-customer key
|
||||
2. `Authorization: Bearer <token>` — JWT or OAuth access token
|
||||
3. `Cookie: session=<JWT>` — web session (set by `/api/auth/login` or `/login`)
|
||||
|
||||
For local/manual config examples in this repo, always substitute your own value for:
|
||||
---
|
||||
|
||||
```text
|
||||
YOUR_MCP_API_KEY
|
||||
```
|
||||
## Transports
|
||||
|
||||
Do not commit live API keys into repo config files.
|
||||
| Transport | URL |
|
||||
|-----------|-----|
|
||||
| Streamable HTTP (preferred) | `https://hermes.squaremcp.com/mcp` |
|
||||
| Legacy SSE | `https://hermes.squaremcp.com/sse` |
|
||||
|
||||
---
|
||||
|
||||
## Client setup guides
|
||||
|
||||
Use the setup guide that matches your client:
|
||||
|
||||
1. [Codex CLI setup](./CODEX_SETUP.md)
|
||||
2. [CLI agent setup (Claude Code, generic MCP CLIs, Claude Desktop)](./AGENTS_CLI_SETUP.md)
|
||||
3. [opencode setup](./OPENCODE.md)
|
||||
4. [ChatGPT Custom GPT setup](./CHATGPT_SETUP.md)
|
||||
5. [Social publishing setup (TikTok / Facebook)](./SOCIAL_PUBLISHING_SETUP.md)
|
||||
|
||||
---
|
||||
|
||||
## Codex quick setup
|
||||
|
||||
Add this block to `~/.codex/config.toml`:
|
||||
|
||||
```toml
|
||||
[mcp_servers.hermes]
|
||||
url = "https://hermes.squaremcp.com/mcp"
|
||||
```
|
||||
|
||||
See [CODEX_SETUP.md](./CODEX_SETUP.md) for the full notes and caveats.
|
||||
|
||||
---
|
||||
|
||||
## opencode quick setup
|
||||
|
||||
Project-level `opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"hermes": {
|
||||
"type": "remote",
|
||||
"url": "https://hermes.squaremcp.com/mcp",
|
||||
"headers": {
|
||||
"x-api-key": "YOUR_MCP_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Full instructions: [OPENCODE.md](./OPENCODE.md)
|
||||
- [Codex CLI](./CODEX_SETUP.md)
|
||||
- [Claude Code / CLI agents](./AGENTS_CLI_SETUP.md)
|
||||
- [opencode](./OPENCODE.md)
|
||||
- [ChatGPT Custom GPT](./CHATGPT_SETUP.md)
|
||||
- [Social publishing (TikTok / Facebook)](./SOCIAL_PUBLISHING_SETUP.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -110,34 +132,24 @@ Full instructions: [OPENCODE.md](./OPENCODE.md)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
cp .env.example .env # fill in credentials
|
||||
npm run dev
|
||||
curl http://localhost:3456/health
|
||||
```
|
||||
|
||||
The local server runs on port `3456` by default.
|
||||
Server runs on port `3456` by default.
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
Production deployment notes are in:
|
||||
See [DEPLOY.md](./DEPLOY.md) for the full deployment runbook.
|
||||
|
||||
1. [DEPLOY.md](./DEPLOY.md)
|
||||
2. `hermes-k8s.yaml`
|
||||
|
||||
SquareMCP product-site docs live under:
|
||||
|
||||
1. [`product/site`](./product/site)
|
||||
2. [`product/README.md`](./product/README.md)
|
||||
3. [`videos/remotion-demo`](./videos/remotion-demo/README.md) for SquareMCP video production assets and render workflows
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
The historical docs in this repo started from an email-only Claude-focused setup. Current deployment and setup guidance should follow:
|
||||
|
||||
1. the `hermes.squaremcp.com` domain
|
||||
2. Streamable HTTP `/mcp` as the default transport
|
||||
3. the dedicated client setup docs linked above
|
||||
The short version:
|
||||
```bash
|
||||
npm run build
|
||||
docker build -t localhost:32000/hermes-mcp:latest .
|
||||
docker push localhost:32000/hermes-mcp:latest
|
||||
# update sha256 digest in hermes-k8s.yaml
|
||||
microk8s kubectl apply -f hermes-k8s.yaml
|
||||
```
|
||||
|
||||
7
docs/Dockerfile
Normal file
7
docs/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM nginx:1.27-alpine
|
||||
COPY docs/index.html /usr/share/nginx/html/index.html
|
||||
COPY docs/getting-started.html /usr/share/nginx/html/getting-started.html
|
||||
COPY docs/platforms.html /usr/share/nginx/html/platforms.html
|
||||
COPY docs/agent-tutorial.html /usr/share/nginx/html/agent-tutorial.html
|
||||
COPY docs/styles.css /usr/share/nginx/html/styles.css
|
||||
EXPOSE 80
|
||||
65
docs/docs-k8s.yaml
Normal file
65
docs/docs-k8s.yaml
Normal file
@@ -0,0 +1,65 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: squaremcp-docs
|
||||
namespace: fetcherpay
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: squaremcp-docs
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: squaremcp-docs
|
||||
spec:
|
||||
containers:
|
||||
- name: squaremcp-docs
|
||||
image: localhost:32000/squaremcp-docs@sha256:adbc221aca3cae4ce42a48d30a69e1745601baa6e425a113f4ae78eed06a5b3a
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 80
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: squaremcp-docs
|
||||
namespace: fetcherpay
|
||||
spec:
|
||||
selector:
|
||||
app: squaremcp-docs
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: squaremcp-docs-ingress
|
||||
namespace: fetcherpay
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: docs.squaremcp.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: squaremcp-docs
|
||||
port:
|
||||
number: 80
|
||||
tls:
|
||||
- hosts:
|
||||
- docs.squaremcp.com
|
||||
secretName: squaremcp-docs-tls
|
||||
@@ -53,12 +53,39 @@
|
||||
<h2>Step 2 — Configure your MCP client</h2>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab(this,'claude')">Claude Desktop</button>
|
||||
<button class="tab active" onclick="switchTab(this,'claudeai')">claude.ai</button>
|
||||
<button class="tab" onclick="switchTab(this,'claude')">Claude Desktop</button>
|
||||
<button class="tab" onclick="switchTab(this,'codex')">Codex CLI</button>
|
||||
<button class="tab" onclick="switchTab(this,'opencode')">opencode</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-claude" class="tab-content tab-panel active">
|
||||
<div id="tab-claudeai" class="tab-content tab-panel active">
|
||||
<p>Connect SquareMCP directly inside the claude.ai web interface — no config files needed.</p>
|
||||
<ol class="steps">
|
||||
<li><div><strong>Open MCP Servers</strong> — go to <strong>claude.ai → Settings → MCP Servers</strong> and click <strong>Add</strong>.</div></li>
|
||||
<li><div><strong>Enter the server URL</strong></div></li>
|
||||
</ol>
|
||||
<pre><code>https://hermes.squaremcp.com</code></pre>
|
||||
<ol class="steps" start="3">
|
||||
<li>
|
||||
<div>
|
||||
<strong>Complete the OAuth flow</strong> — a popup will open at <code>hermes.squaremcp.com/login</code>.
|
||||
Sign in with your SquareMCP account credentials. After signing in you will be shown a consent page — click <strong>Connect MCP client</strong>.
|
||||
The popup closes and the connector shows as <strong>Connected</strong>.
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="callout">
|
||||
<strong>Why a separate login page?</strong>
|
||||
Browsers partition cookies per top-level site. The OAuth popup runs on <code>hermes.squaremcp.com</code>, so your session must be established there — not on <code>app.squaremcp.com</code> — for the cookie to be visible.
|
||||
</div>
|
||||
<h3>Troubleshooting claude.ai connections</h3>
|
||||
<p><strong>Popup doesn't open</strong> — make sure your browser isn't blocking pop-ups from claude.ai. Allow pop-ups for claude.ai and retry.</p>
|
||||
<p><strong>Stuck on login after connecting</strong> — the popup may have been closed before the OAuth flow finished. Remove the server entry, click Add again, and complete the full popup flow.</p>
|
||||
<p><strong>Shows connected but tools don't appear</strong> — start a new conversation. Tools from newly connected MCP servers appear in the next conversation's tool picker.</p>
|
||||
</div>
|
||||
|
||||
<div id="tab-claude" class="tab-content tab-panel">
|
||||
<pre><code><span class="cmt">// ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)</span>
|
||||
<span class="cmt">// %APPDATA%\Claude\claude_desktop_config.json (Windows)</span>
|
||||
{
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<li>
|
||||
<div>
|
||||
<strong>Create a LinkedIn app</strong>
|
||||
Go to <a href="https://developer.linkedin.com/apps" target="_blank">developer.linkedin.com/apps</a> and create a new app. Add your company page and request the <code>w_member_social</code> and <code>r_liteprofile</code> products.
|
||||
Go to <a href="https://www.linkedin.com/developers/apps" target="_blank">linkedin.com/developers/apps</a> and create a new app. Add your company page and request the <code>w_member_social</code> and <code>r_liteprofile</code> products.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -15,7 +15,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: squaremcp-app
|
||||
image: localhost:32000/squaremcp-app@sha256:45d7adfe10efe727ec1f6c1f5a64ad12d9aa426af90145b5f8d7c1a9dbbe9536
|
||||
image: localhost:32000/squaremcp-app@sha256:c9545e6ac1adcfc6dbfb162f4dbff5db39d9fbf4c5bd95899c74d70174dd3cfa
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
|
||||
@@ -207,7 +207,13 @@ loginForm.addEventListener('submit', async (e) => {
|
||||
return;
|
||||
}
|
||||
currentUser = data;
|
||||
isAdmin = data.plan === 'enterprise'; // simplistic admin check
|
||||
isAdmin = data.role === 'admin';
|
||||
// If we were sent here from an OAuth flow, redirect back
|
||||
const returnTo = new URLSearchParams(window.location.search).get('return_to');
|
||||
if (returnTo && returnTo.startsWith('https://hermes.squaremcp.com/')) {
|
||||
window.location.href = returnTo;
|
||||
return;
|
||||
}
|
||||
showDashboard();
|
||||
});
|
||||
|
||||
@@ -629,12 +635,20 @@ async function checkSession() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we were sent here from an OAuth flow, redirect back after confirming session
|
||||
const returnTo = urlParams.get('return_to');
|
||||
|
||||
try {
|
||||
const data = await apiGet('/api/auth/me');
|
||||
if (data.id) {
|
||||
currentUser = data;
|
||||
isAdmin = data.plan === 'enterprise';
|
||||
isAdmin = data.role === 'admin';
|
||||
if (isAdmin) adminNav.classList.remove('hidden');
|
||||
// Already logged in — bounce back to the OAuth authorize URL if present
|
||||
if (returnTo && returnTo.startsWith('https://hermes.squaremcp.com/')) {
|
||||
window.location.href = returnTo;
|
||||
return;
|
||||
}
|
||||
showDashboard();
|
||||
} else {
|
||||
showLogin();
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface JWTPayload {
|
||||
sub: string; // customer id
|
||||
email: string;
|
||||
plan: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
@@ -37,11 +38,12 @@ interface CustomerRow extends RowDataPacket {
|
||||
active: boolean;
|
||||
api_key: string;
|
||||
password_hash: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export async function findCustomerByEmail(email: string): Promise<CustomerRow | null> {
|
||||
const [rows] = await getPool().query<CustomerRow[]>(
|
||||
'SELECT id, email, plan, active, api_key, password_hash FROM customers WHERE email = ?',
|
||||
'SELECT id, email, plan, active, api_key, password_hash, role FROM customers WHERE email = ?',
|
||||
[email]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
@@ -49,7 +51,7 @@ export async function findCustomerByEmail(email: string): Promise<CustomerRow |
|
||||
|
||||
export async function findCustomerById(id: string): Promise<CustomerRow | null> {
|
||||
const [rows] = await getPool().query<CustomerRow[]>(
|
||||
'SELECT id, email, plan, active, api_key, password_hash FROM customers WHERE id = ?',
|
||||
'SELECT id, email, plan, active, api_key, password_hash, role FROM customers WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
@@ -77,7 +79,7 @@ export async function setResetToken(email: string, token: string): Promise<boole
|
||||
|
||||
export async function findCustomerByResetToken(token: string) {
|
||||
const [rows] = await getPool().query<CustomerRow[]>(
|
||||
'SELECT id, email, plan, active, api_key, password_hash FROM customers WHERE reset_token = ? AND reset_expires_at > NOW()',
|
||||
'SELECT id, email, plan, active, api_key, password_hash, role FROM customers WHERE reset_token = ? AND reset_expires_at > NOW()',
|
||||
[token]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Customer {
|
||||
plan: PlanKey;
|
||||
active: boolean;
|
||||
email: string;
|
||||
role: string;
|
||||
// Credential loader — tool handlers call this to get their platform credentials
|
||||
getCredential: <T extends PlatformCredentials>(platform: Platform) => Promise<T | null>;
|
||||
}
|
||||
@@ -20,6 +21,7 @@ interface CustomerRow extends RowDataPacket {
|
||||
plan: PlanKey;
|
||||
active: boolean;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
function buildCustomer(row: CustomerRow): Customer {
|
||||
@@ -28,6 +30,7 @@ function buildCustomer(row: CustomerRow): Customer {
|
||||
plan: row.plan,
|
||||
active: Boolean(row.active),
|
||||
email: row.email,
|
||||
role: row.role || 'user',
|
||||
getCredential: <T extends PlatformCredentials>(platform: Platform) =>
|
||||
getCredential<T>(row.id, platform),
|
||||
};
|
||||
|
||||
132
src/index.ts
132
src/index.ts
@@ -39,10 +39,17 @@ import redis from './redis.js';
|
||||
const app = express();
|
||||
app.use(cookieParser());
|
||||
app.use(cors({
|
||||
origin: '*',
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (curl, server-to-server, MCP clients)
|
||||
if (!origin) return callback(null, true);
|
||||
if (SQUAREMCP_ALLOWED_ORIGINS.has(origin)) return callback(null, origin);
|
||||
// Allow localhost for dev/testing
|
||||
if (/^https?:\/\/localhost(:\d+)?$/.test(origin)) return callback(null, origin);
|
||||
callback(null, false);
|
||||
},
|
||||
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'mcp-session-id', 'Accept', 'x-api-key', 'Authorization'],
|
||||
credentials: true
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
@@ -58,6 +65,7 @@ const PROTECTED_RESOURCE_METADATA_URL = `${SERVER_URL}/.well-known/oauth-protect
|
||||
const SQUAREMCP_ALLOWED_ORIGINS = new Set([
|
||||
'https://squaremcp.com',
|
||||
'https://www.squaremcp.com',
|
||||
'https://app.squaremcp.com',
|
||||
'https://tiktok.squaremcp.com',
|
||||
]);
|
||||
|
||||
@@ -349,11 +357,12 @@ async function requireAuth(req: express.Request, res: express.Response, next: ex
|
||||
const payload = verifyJWT(jwtCookie);
|
||||
const customer = await resolveCustomerById(payload.sub);
|
||||
if (customer && customer.active) {
|
||||
(req as express.Request & { customer?: Customer; jwtUser?: { id: string; email: string; plan: string } }).customer = customer;
|
||||
(req as express.Request & { jwtUser?: { id: string; email: string; plan: string } }).jwtUser = {
|
||||
(req as express.Request & { customer?: Customer; jwtUser?: { id: string; email: string; plan: string; role: string } }).customer = customer;
|
||||
(req as express.Request & { jwtUser?: { id: string; email: string; plan: string; role: string } }).jwtUser = {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
plan: payload.plan,
|
||||
role: customer.role,
|
||||
};
|
||||
return next();
|
||||
}
|
||||
@@ -416,6 +425,98 @@ app.post('/oauth/register', async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Native login page (first-party cookie for OAuth flow) ──────────────────
|
||||
app.get('/login', (req, res) => {
|
||||
const returnTo = req.query.return_to as string | undefined;
|
||||
const error = req.query.error as string | undefined;
|
||||
const safeReturnTo = returnTo && returnTo.startsWith('https://hermes.squaremcp.com/')
|
||||
? returnTo
|
||||
: '/';
|
||||
const errMsg = error === 'invalid' ? 'Incorrect email or password.'
|
||||
: error === 'missing' ? 'Email and password are required.'
|
||||
: '';
|
||||
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.send(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign in — SquareMCP</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f8fafc;display:flex;align-items:center;justify-content:center;min-height:100vh}
|
||||
.card{background:#fff;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.08);padding:40px;width:100%;max-width:400px}
|
||||
.logo{text-align:center;margin-bottom:28px;font-size:22px;font-weight:700;color:#1a1a2e}
|
||||
.logo span{color:#6366f1}
|
||||
h1{font-size:20px;font-weight:600;color:#1a1a2e;margin-bottom:6px;text-align:center}
|
||||
.sub{font-size:14px;color:#64748b;text-align:center;margin-bottom:28px}
|
||||
label{font-size:14px;font-weight:500;color:#374151;display:block;margin-bottom:6px}
|
||||
input{width:100%;padding:10px 14px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:15px;outline:none;transition:border-color .2s}
|
||||
input:focus{border-color:#6366f1}
|
||||
.field{margin-bottom:18px}
|
||||
button{width:100%;padding:12px;background:#6366f1;color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;transition:background .2s}
|
||||
button:hover{background:#4f46e5}
|
||||
.error{color:#dc2626;font-size:13px;text-align:center;margin-bottom:16px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">Square<span>MCP</span></div>
|
||||
<h1>Sign in to continue</h1>
|
||||
<p class="sub">Connect your SquareMCP account to authorize access.</p>
|
||||
${errMsg ? `<p class="error">${errMsg}</p>` : ''}
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="return_to" value="${safeReturnTo.replace(/"/g, '"')}">
|
||||
<div class="field">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required autocomplete="email" placeholder="you@example.com">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password" placeholder="••••••••">
|
||||
</div>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>`);
|
||||
});
|
||||
|
||||
app.post('/login', express.urlencoded({ extended: false }), async (req, res) => {
|
||||
const { email, password, return_to } = req.body as Record<string, string>;
|
||||
const safeReturnTo = return_to && return_to.startsWith('https://hermes.squaremcp.com/')
|
||||
? return_to
|
||||
: '/';
|
||||
|
||||
if (!email || !password) {
|
||||
res.redirect(`/login?return_to=${encodeURIComponent(safeReturnTo)}&error=missing`);
|
||||
return;
|
||||
}
|
||||
|
||||
const customer = await findCustomerByEmail(email);
|
||||
if (!customer || !customer.password_hash) {
|
||||
res.redirect(`/login?return_to=${encodeURIComponent(safeReturnTo)}&error=invalid`);
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, customer.password_hash);
|
||||
if (!valid) {
|
||||
res.redirect(`/login?return_to=${encodeURIComponent(safeReturnTo)}&error=invalid`);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = signJWT({ sub: customer.id, email: customer.email, plan: customer.plan, role: customer.role });
|
||||
res.cookie('session', token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
domain: '.squaremcp.com',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
res.redirect(safeReturnTo);
|
||||
});
|
||||
|
||||
// Authorization endpoint: GET shows consent form, POST handles approval
|
||||
app.get('/oauth/authorize', async (req, res) => {
|
||||
const clientId = req.query.client_id as string | undefined;
|
||||
@@ -449,15 +550,13 @@ app.get('/oauth/authorize', async (req, res) => {
|
||||
// Require authenticated SquareMCP session to show the consent page
|
||||
const jwtCookie = req.cookies?.session;
|
||||
if (!jwtCookie) {
|
||||
const returnTo = encodeURIComponent(req.originalUrl);
|
||||
res.redirect(`/login?return_to=${returnTo}`);
|
||||
res.redirect(`/login?return_to=${encodeURIComponent(`https://hermes.squaremcp.com${req.originalUrl}`)}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
verifyJWT(jwtCookie);
|
||||
} catch {
|
||||
const returnTo = encodeURIComponent(req.originalUrl);
|
||||
res.redirect(`/login?return_to=${returnTo}`);
|
||||
res.redirect(`/login?return_to=${encodeURIComponent(`https://hermes.squaremcp.com${req.originalUrl}`)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1164,14 +1263,17 @@ app.post('/api/auth/signup', express.json(), async (req, res) => {
|
||||
if (isFirstUser) {
|
||||
await getPool().query("UPDATE customers SET role = 'admin', plan = 'enterprise' WHERE id = ?", [id]);
|
||||
}
|
||||
const token = signJWT({ sub: id, email, plan: isFirstUser ? 'enterprise' : 'free' });
|
||||
const role = isFirstUser ? 'admin' : 'user';
|
||||
const plan = isFirstUser ? 'enterprise' : 'free';
|
||||
const token = signJWT({ sub: id, email, plan, role });
|
||||
res.cookie('session', token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
sameSite: 'lax',
|
||||
domain: '.squaremcp.com',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
res.status(201).json({ id, email, plan: 'free', api_key: apiKey });
|
||||
res.status(201).json({ id, email, plan, role, api_key: apiKey });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to create account' });
|
||||
}
|
||||
@@ -1196,23 +1298,23 @@ app.post('/api/auth/login', express.json(), async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = signJWT({ sub: customer.id, email: customer.email, plan: customer.plan });
|
||||
const token = signJWT({ sub: customer.id, email: customer.email, plan: customer.plan, role: customer.role });
|
||||
res.cookie('session', token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
res.json({ id: customer.id, email: customer.email, plan: customer.plan, api_key: customer.api_key });
|
||||
res.json({ id: customer.id, email: customer.email, plan: customer.plan, role: customer.role, api_key: customer.api_key });
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (_req, res) => {
|
||||
res.clearCookie('session');
|
||||
res.clearCookie('session', { domain: '.squaremcp.com' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', requireAuth, async (req, res) => {
|
||||
const jwtUser = (req as express.Request & { jwtUser?: { id: string; email: string; plan: string } }).jwtUser;
|
||||
const jwtUser = (req as express.Request & { jwtUser?: { id: string; email: string; plan: string; role?: string } }).jwtUser;
|
||||
if (jwtUser) {
|
||||
res.json(jwtUser);
|
||||
return;
|
||||
|
||||
@@ -99,6 +99,7 @@ const mockCustomer = {
|
||||
plan: 'growth' as const,
|
||||
active: true,
|
||||
email: 'test@example.com',
|
||||
role: 'user',
|
||||
getCredential: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user