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.
|
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).
|
||||||
It supports **Yahoo Mail** (IMAP App Password) and any **custom IMAP/SMTP server**.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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`
|
- MicroK8s with addons: `dns`, `ingress`, `registry`, `cert-manager`
|
||||||
- Local registry at `localhost:32000`
|
- Local image registry at `localhost:32000`
|
||||||
- A `ClusterIssuer` named `letsencrypt-prod` already configured
|
- `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
|
```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 \
|
# 2. Build and push Docker image
|
||||||
--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
|
|
||||||
docker build -t localhost:32000/hermes-mcp:latest .
|
docker build -t localhost:32000/hermes-mcp:latest .
|
||||||
docker push localhost:32000/hermes-mcp:latest
|
docker push localhost:32000/hermes-mcp:latest
|
||||||
|
|
||||||
# Option 2: Copy to server and build there
|
# 3. Get the sha256 digest from push output, or:
|
||||||
scp -r src/ package*.json tsconfig.json Dockerfile user@your-server:~/hermes-mcp/
|
docker inspect localhost:32000/hermes-mcp:latest \
|
||||||
ssh user@your-server "cd ~/hermes-mcp && docker build -t localhost:32000/hermes-mcp:latest . && docker push localhost:32000/hermes-mcp:latest"
|
--format='{{index .RepoDigests 0}}'
|
||||||
```
|
|
||||||
|
|
||||||
### Apply K8s manifests
|
# 4. Update hermes-k8s.yaml
|
||||||
```yaml
|
# Change: image: localhost:32000/hermes-mcp@sha256:<old>
|
||||||
# hermes-k8s.yaml
|
# To: image: localhost:32000/hermes-mcp@sha256:<new>
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
Apply the manifests:
|
# 5. Apply
|
||||||
```bash
|
|
||||||
microk8s kubectl apply -f hermes-k8s.yaml
|
microk8s kubectl apply -f hermes-k8s.yaml
|
||||||
|
|
||||||
|
# 6. Wait for rollout
|
||||||
|
microk8s kubectl rollout status deployment/hermes-mcp -n fetcherpay
|
||||||
```
|
```
|
||||||
|
|
||||||
### Redeploy after code changes
|
**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.
|
||||||
```bash
|
|
||||||
# Rebuild and push the image
|
|
||||||
docker build -t localhost:32000/hermes-mcp:latest .
|
|
||||||
docker push localhost:32000/hermes-mcp:latest
|
|
||||||
|
|
||||||
# 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
|
## Useful commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Logs
|
# Check pod status
|
||||||
microk8s kubectl logs -n hermes-mcp -l app=hermes-mcp --tail=100 -f
|
microk8s kubectl get pods -n fetcherpay
|
||||||
|
|
||||||
# Pod status
|
# Live logs
|
||||||
microk8s kubectl get pods -n hermes-mcp -l app=hermes-mcp
|
microk8s kubectl logs -n fetcherpay -l app=hermes-mcp -f --tail=100
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Health check
|
# 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**
|
Key variables in `hermes-k8s.yaml`:
|
||||||
2. Enter URL: `https://hermes.yourdomain.com/mcp` (or your server's URL)
|
|
||||||
3. Click Connect
|
|
||||||
|
|
||||||
### 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 |
|
**Do not rotate `CREDENTIAL_ENCRYPTION_KEY`** without first re-encrypting all stored customer credentials in Redis.
|
||||||
|------|-------------|------------|
|
|
||||||
| `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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Known issues & fixes
|
## Schema migrations
|
||||||
|
|
||||||
| Issue | Cause | Fix |
|
`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.
|
||||||
|-------|-------|-----|
|
|
||||||
| `read_message` timeout | `source: true` downloads full raw RFC822 | Use `bodyParts: ['TEXT']` instead |
|
To run a migration manually:
|
||||||
| `messageFlagsAdd` deadlock | Called inside `for await` loop while FETCH active | Moved to after the loop |
|
```bash
|
||||||
| Stale session after pod restart | Session ID guard blocked re-initialize | Accept initialize regardless of session ID |
|
mysql -h 127.0.0.1 -u root -pfetcherpay hermes_oauth
|
||||||
| `EAI_AGAIN` DNS errors | K8s internal DNS resolution issues | Use direct IP instead of hostname |
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|||||||
230
README.md
230
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:
|
**Production endpoint:**
|
||||||
|
```
|
||||||
```text
|
|
||||||
https://hermes.squaremcp.com/mcp
|
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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What Hermes is for
|
## Quick connect
|
||||||
|
|
||||||
Hermes is the integration and connector layer behind broader product work such as SquareMCP.
|
### claude.ai
|
||||||
|
1. Settings → MCP Servers → Add → enter `https://hermes.squaremcp.com`
|
||||||
Use Hermes when you want:
|
2. Complete the OAuth popup (login with your SquareMCP credentials)
|
||||||
|
3. Click "Connect MCP client"
|
||||||
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
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Core transports
|
|
||||||
|
|
||||||
| Transport | URL |
|
|
||||||
|----------|-----|
|
|
||||||
| Streamable HTTP (preferred) | `https://hermes.squaremcp.com/mcp` |
|
|
||||||
| Legacy SSE | `https://hermes.squaremcp.com/sse` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
Hermes accepts:
|
|
||||||
|
|
||||||
1. `x-api-key` header
|
|
||||||
2. `?key=` query parameter
|
|
||||||
3. `Authorization: Bearer ...` for OAuth-based clients
|
|
||||||
|
|
||||||
For local/manual config examples in this repo, always substitute your own value for:
|
|
||||||
|
|
||||||
```text
|
|
||||||
YOUR_MCP_API_KEY
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not commit live API keys into repo config files.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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`:
|
|
||||||
|
|
||||||
|
### Claude Desktop
|
||||||
```json
|
```json
|
||||||
|
// ~/Library/Application Support/Claude/claude_desktop_config.json
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"mcpServers": {
|
||||||
"mcp": {
|
"squaremcp": {
|
||||||
"hermes": {
|
"type": "http",
|
||||||
"type": "remote",
|
|
||||||
"url": "https://hermes.squaremcp.com/mcp",
|
"url": "https://hermes.squaremcp.com/mcp",
|
||||||
"headers": {
|
"headers": { "Authorization": "Bearer YOUR_TOKEN" }
|
||||||
"x-api-key": "YOUR_MCP_API_KEY"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Full instructions: [OPENCODE.md](./OPENCODE.md)
|
### 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
See [ARCHITECTURE.md](./ARCHITECTURE.md) for the full architecture with diagrams.
|
||||||
|
|
||||||
|
```
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Platforms
|
||||||
|
|
||||||
|
| 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 (in priority order):
|
||||||
|
|
||||||
|
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`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transports
|
||||||
|
|
||||||
|
| Transport | URL |
|
||||||
|
|-----------|-----|
|
||||||
|
| Streamable HTTP (preferred) | `https://hermes.squaremcp.com/mcp` |
|
||||||
|
| Legacy SSE | `https://hermes.squaremcp.com/sse` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client setup guides
|
||||||
|
|
||||||
|
- [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
|
```bash
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env
|
cp .env.example .env # fill in credentials
|
||||||
npm run dev
|
npm run dev
|
||||||
curl http://localhost:3456/health
|
curl http://localhost:3456/health
|
||||||
```
|
```
|
||||||
|
|
||||||
The local server runs on port `3456` by default.
|
Server runs on port `3456` by default.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Production deployment notes are in:
|
See [DEPLOY.md](./DEPLOY.md) for the full deployment runbook.
|
||||||
|
|
||||||
1. [DEPLOY.md](./DEPLOY.md)
|
The short version:
|
||||||
2. `hermes-k8s.yaml`
|
```bash
|
||||||
|
npm run build
|
||||||
SquareMCP product-site docs live under:
|
docker build -t localhost:32000/hermes-mcp:latest .
|
||||||
|
docker push localhost:32000/hermes-mcp:latest
|
||||||
1. [`product/site`](./product/site)
|
# update sha256 digest in hermes-k8s.yaml
|
||||||
2. [`product/README.md`](./product/README.md)
|
microk8s kubectl apply -f hermes-k8s.yaml
|
||||||
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
|
|
||||||
|
|||||||
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>
|
<h2>Step 2 — Configure your MCP client</h2>
|
||||||
|
|
||||||
<div class="tabs">
|
<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,'codex')">Codex CLI</button>
|
||||||
<button class="tab" onclick="switchTab(this,'opencode')">opencode</button>
|
<button class="tab" onclick="switchTab(this,'opencode')">opencode</button>
|
||||||
</div>
|
</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>
|
<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>
|
<span class="cmt">// %APPDATA%\Claude\claude_desktop_config.json (Windows)</span>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<div>
|
<div>
|
||||||
<strong>Create a LinkedIn app</strong>
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: squaremcp-app
|
- name: squaremcp-app
|
||||||
image: localhost:32000/squaremcp-app@sha256:45d7adfe10efe727ec1f6c1f5a64ad12d9aa426af90145b5f8d7c1a9dbbe9536
|
image: localhost:32000/squaremcp-app@sha256:c9545e6ac1adcfc6dbfb162f4dbff5db39d9fbf4c5bd95899c74d70174dd3cfa
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
|
|||||||
@@ -207,7 +207,13 @@ loginForm.addEventListener('submit', async (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentUser = data;
|
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();
|
showDashboard();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -629,12 +635,20 @@ async function checkSession() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we were sent here from an OAuth flow, redirect back after confirming session
|
||||||
|
const returnTo = urlParams.get('return_to');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiGet('/api/auth/me');
|
const data = await apiGet('/api/auth/me');
|
||||||
if (data.id) {
|
if (data.id) {
|
||||||
currentUser = data;
|
currentUser = data;
|
||||||
isAdmin = data.plan === 'enterprise';
|
isAdmin = data.role === 'admin';
|
||||||
if (isAdmin) adminNav.classList.remove('hidden');
|
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();
|
showDashboard();
|
||||||
} else {
|
} else {
|
||||||
showLogin();
|
showLogin();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface JWTPayload {
|
|||||||
sub: string; // customer id
|
sub: string; // customer id
|
||||||
email: string;
|
email: string;
|
||||||
plan: string;
|
plan: string;
|
||||||
|
role?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hashPassword(password: string): Promise<string> {
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
@@ -37,11 +38,12 @@ interface CustomerRow extends RowDataPacket {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
api_key: string;
|
api_key: string;
|
||||||
password_hash: string | null;
|
password_hash: string | null;
|
||||||
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findCustomerByEmail(email: string): Promise<CustomerRow | null> {
|
export async function findCustomerByEmail(email: string): Promise<CustomerRow | null> {
|
||||||
const [rows] = await getPool().query<CustomerRow[]>(
|
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]
|
[email]
|
||||||
);
|
);
|
||||||
return rows[0] ?? null;
|
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> {
|
export async function findCustomerById(id: string): Promise<CustomerRow | null> {
|
||||||
const [rows] = await getPool().query<CustomerRow[]>(
|
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]
|
[id]
|
||||||
);
|
);
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
@@ -77,7 +79,7 @@ export async function setResetToken(email: string, token: string): Promise<boole
|
|||||||
|
|
||||||
export async function findCustomerByResetToken(token: string) {
|
export async function findCustomerByResetToken(token: string) {
|
||||||
const [rows] = await getPool().query<CustomerRow[]>(
|
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]
|
[token]
|
||||||
);
|
);
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface Customer {
|
|||||||
plan: PlanKey;
|
plan: PlanKey;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
email: string;
|
email: string;
|
||||||
|
role: string;
|
||||||
// Credential loader — tool handlers call this to get their platform credentials
|
// Credential loader — tool handlers call this to get their platform credentials
|
||||||
getCredential: <T extends PlatformCredentials>(platform: Platform) => Promise<T | null>;
|
getCredential: <T extends PlatformCredentials>(platform: Platform) => Promise<T | null>;
|
||||||
}
|
}
|
||||||
@@ -20,6 +21,7 @@ interface CustomerRow extends RowDataPacket {
|
|||||||
plan: PlanKey;
|
plan: PlanKey;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
email: string;
|
email: string;
|
||||||
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCustomer(row: CustomerRow): Customer {
|
function buildCustomer(row: CustomerRow): Customer {
|
||||||
@@ -28,6 +30,7 @@ function buildCustomer(row: CustomerRow): Customer {
|
|||||||
plan: row.plan,
|
plan: row.plan,
|
||||||
active: Boolean(row.active),
|
active: Boolean(row.active),
|
||||||
email: row.email,
|
email: row.email,
|
||||||
|
role: row.role || 'user',
|
||||||
getCredential: <T extends PlatformCredentials>(platform: Platform) =>
|
getCredential: <T extends PlatformCredentials>(platform: Platform) =>
|
||||||
getCredential<T>(row.id, 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();
|
const app = express();
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(cors({
|
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'],
|
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
||||||
allowedHeaders: ['Content-Type', 'mcp-session-id', 'Accept', 'x-api-key', 'Authorization'],
|
allowedHeaders: ['Content-Type', 'mcp-session-id', 'Accept', 'x-api-key', 'Authorization'],
|
||||||
credentials: true
|
credentials: true,
|
||||||
}));
|
}));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
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([
|
const SQUAREMCP_ALLOWED_ORIGINS = new Set([
|
||||||
'https://squaremcp.com',
|
'https://squaremcp.com',
|
||||||
'https://www.squaremcp.com',
|
'https://www.squaremcp.com',
|
||||||
|
'https://app.squaremcp.com',
|
||||||
'https://tiktok.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 payload = verifyJWT(jwtCookie);
|
||||||
const customer = await resolveCustomerById(payload.sub);
|
const customer = await resolveCustomerById(payload.sub);
|
||||||
if (customer && customer.active) {
|
if (customer && customer.active) {
|
||||||
(req as express.Request & { customer?: Customer; jwtUser?: { id: string; email: string; plan: string } }).customer = customer;
|
(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 } }).jwtUser = {
|
(req as express.Request & { jwtUser?: { id: string; email: string; plan: string; role: string } }).jwtUser = {
|
||||||
id: payload.sub,
|
id: payload.sub,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
plan: payload.plan,
|
plan: payload.plan,
|
||||||
|
role: customer.role,
|
||||||
};
|
};
|
||||||
return next();
|
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
|
// Authorization endpoint: GET shows consent form, POST handles approval
|
||||||
app.get('/oauth/authorize', async (req, res) => {
|
app.get('/oauth/authorize', async (req, res) => {
|
||||||
const clientId = req.query.client_id as string | undefined;
|
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
|
// Require authenticated SquareMCP session to show the consent page
|
||||||
const jwtCookie = req.cookies?.session;
|
const jwtCookie = req.cookies?.session;
|
||||||
if (!jwtCookie) {
|
if (!jwtCookie) {
|
||||||
const returnTo = encodeURIComponent(req.originalUrl);
|
res.redirect(`/login?return_to=${encodeURIComponent(`https://hermes.squaremcp.com${req.originalUrl}`)}`);
|
||||||
res.redirect(`/login?return_to=${returnTo}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
verifyJWT(jwtCookie);
|
verifyJWT(jwtCookie);
|
||||||
} catch {
|
} catch {
|
||||||
const returnTo = encodeURIComponent(req.originalUrl);
|
res.redirect(`/login?return_to=${encodeURIComponent(`https://hermes.squaremcp.com${req.originalUrl}`)}`);
|
||||||
res.redirect(`/login?return_to=${returnTo}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1164,14 +1263,17 @@ app.post('/api/auth/signup', express.json(), async (req, res) => {
|
|||||||
if (isFirstUser) {
|
if (isFirstUser) {
|
||||||
await getPool().query("UPDATE customers SET role = 'admin', plan = 'enterprise' WHERE id = ?", [id]);
|
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, {
|
res.cookie('session', token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: true,
|
secure: true,
|
||||||
sameSite: 'strict',
|
sameSite: 'lax',
|
||||||
|
domain: '.squaremcp.com',
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
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) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: 'Failed to create account' });
|
res.status(500).json({ error: 'Failed to create account' });
|
||||||
}
|
}
|
||||||
@@ -1196,23 +1298,23 @@ app.post('/api/auth/login', express.json(), async (req, res) => {
|
|||||||
return;
|
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, {
|
res.cookie('session', token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: true,
|
secure: true,
|
||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
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) => {
|
app.post('/api/auth/logout', (_req, res) => {
|
||||||
res.clearCookie('session');
|
res.clearCookie('session', { domain: '.squaremcp.com' });
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/auth/me', requireAuth, async (req, res) => {
|
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) {
|
if (jwtUser) {
|
||||||
res.json(jwtUser);
|
res.json(jwtUser);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ const mockCustomer = {
|
|||||||
plan: 'growth' as const,
|
plan: 'growth' as const,
|
||||||
active: true,
|
active: true,
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
|
role: 'user',
|
||||||
getCredential: vi.fn().mockResolvedValue(null),
|
getCredential: vi.fn().mockResolvedValue(null),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user