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:
Garfield
2026-05-14 13:48:01 -04:00
parent 61dab40585
commit 02398258a5
13 changed files with 697 additions and 298 deletions

221
ARCHITECTURE.md Normal file
View 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
View File

@@ -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
View File

@@ -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
View 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
View 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

View File

@@ -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>
{

View File

@@ -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>

View File

@@ -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

View File

@@ -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();

View File

@@ -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;

View File

@@ -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),
};

View File

@@ -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, '&quot;')}">
<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;

View File

@@ -99,6 +99,7 @@ const mockCustomer = {
plan: 'growth' as const,
active: true,
email: 'test@example.com',
role: 'user',
getCredential: vi.fn().mockResolvedValue(null),
};