diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..05d491c --- /dev/null +++ b/ARCHITECTURE.md @@ -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 → decode JWT → resolve Customer + Authorization: Bearer → validate in oauth_tokens table +4. Cookie: session= → decode → resolve Customer +``` + +**JWT payload:** `{ sub: customerId, email, plan, role }` +**Cookie settings:** `httpOnly`, `secure`, `sameSite: lax`, `domain: .squaremcp.com`, 7-day TTL + +--- + +## OAuth 2.0 Flow (browser-based clients) + +``` +MCP Client (popup) hermes.squaremcp.com + │ │ + ├─ POST /oauth/register ────► Dynamic Client Registration + │◄─ { client_id, secret } ──┤ + │ + ├─ GET /oauth/authorize ────► + │ no session? │ + │◄─ 302 /login?return_to=.. ┤ + │ + ├─ GET /login ─────────────► + │◄─ HTML login form ───────── + │ + ├─ POST /login ────────────► verify credentials + │ set-cookie: session (first-party!) + │◄─ 302 /oauth/authorize ───┤ + │ + ├─ GET /oauth/authorize ────► session found ✓ + │◄─ consent HTML ───────────┤ + │ + ├─ POST /oauth/authorize ───► action=allow + │◄─ 302 redirect_uri?code=. ┤ + │ + ├─ POST /oauth/token ───────► PKCE code exchange + │◄─ { access_token } ───────┤ + │ + ├─ POST /mcp ───────────────► Bearer access_token + │◄─ MCP tools ──────────────┤ +``` + +**Cookie partitioning note:** Browsers (CHIPS) partition cookies per top-level site. The OAuth popup runs on `hermes.squaremcp.com`, so login must happen on hermes — not on `app.squaremcp.com` — for the session cookie to be visible in that context. `/login` on hermes exists specifically for this reason. + +--- + +## Multi-Tenancy: Tool Call Data Flow + +``` +AI assistant + │ + POST /mcp Authorization: Bearer + │ + └─► requireAuth + resolves token → Customer { id, plan, getCredential() } + │ + └─► createMcpServer(customer) + │ + └─► handleToolCall("linkedin_create_post", args, customer) + │ + ├─ checkLimit(customerId, plan) → OK or error + │ + ├─ customer.getCredential("linkedin") + │ Redis GET creds:{customerId}:linkedin + │ AES-256-GCM decrypt → { accessToken } + │ + ├─ linkedin.createPost(accessToken, content) + │ POST api.linkedin.com/v2/ugcPosts + │ + └─ recordUsage(customerId, "linkedin", "linkedin_create_post") + INSERT INTO usage_logs +``` + +**Credentials** are AES-256-GCM encrypted in Redis at `creds:{customerId}:{platform}`. Key is `CREDENTIAL_ENCRYPTION_KEY` env var — do not rotate without re-encrypting all stored values. + +--- + +## Platform Clients (51 tools, 11 platforms) + +| Platform | Key tools | +|----------|-----------| +| Email (IMAP/SMTP) | search_messages, read_message, send_email, create_draft, list_folders | +| Obsidian | search_notes, read_note, append_to_note, update_note, sync_status | +| WhatsApp Business | send_message, send_template, list_templates, get_message_status | +| LinkedIn | get_profile, create_post, search_connections, send_message, upload_video | +| TikTok | get_profile, get_creator_info, create_video, get_video_status | +| Facebook | get_page, get_posts, create_post, create_photo_post, create_video_post | +| Instagram | get_profile, get_media, create_post, create_reel | +| Twitter/X | get_user_profile, get_user_tweets, search_tweets, create_tweet, upload_video | +| Telegram | get_me, send_message, send_photo, get_updates, get_chat | +| Discord | get_me, get_guilds, get_channels, send_message, get_messages | +| Snapchat | get_me, get_ad_accounts, create_snap | + +--- + +## Source Layout + +``` +src/ +├── index.ts — Express app, all routes, MCP session management +├── tools.ts — handleToolCall(), tools[] registry +├── oauth.ts — OAuth 2.0 + PKCE + DCR +├── auth.ts — bcrypt, JWT, findCustomer* queries +├── db.ts — MySQL pool, ensureColumn() migration helper +├── redis.ts — shared Redis singleton +├── imap.ts / smtp.ts — multi-account email +├── manifest.ts — OpenAPI schema generation +├── billing/ +│ ├── plans.ts — plan definitions + limits +│ ├── middleware.ts — Customer interface, meterMiddleware, API key resolution +│ ├── usage.ts — recordUsage(), checkLimit() +│ ├── invoices.ts — invoice generation + email +│ └── cron.ts — monthly invoice cron (Redis distributed lock) +├── multitenancy/ +│ ├── credential-store.ts — AES-256-GCM per-customer credentials +│ ├── token-refresh.ts — OAuth token refresh per platform +│ ├── platform-health.ts — health checks + Redis cache +│ ├── webhook-router.ts — WhatsApp inbound phone → customerId routing +│ └── audit-log.ts — per-customer tool call audit trail +├── webhooks/ +│ └── delivery.ts — outbound HMAC webhook + 3× retry + Redis DLQ +└── clients/ + └── discord / facebook / instagram / linkedin / obsidian / + snapchat / telegram / tiktok / twitter / whatsapp .ts + +product/ +└── app/ — SaaS dashboard SPA (app.squaremcp.com) + +docs/ — Developer docs site (docs.squaremcp.com) +``` + +--- + +## Key Invariants + +1. **ESM `.js` extensions in imports** — `moduleResolution: bundler`; always `import from './foo.js'` even for `.ts` files +2. **Digest pinning in K8s manifests** — always use `image: registry/name@sha256:...`, never `:latest` in production +3. **`hermes-k8s.yaml` is gitignored** — never force-add; contains plaintext credentials +4. **`CREDENTIAL_ENCRYPTION_KEY` is immutable** — rotating it requires re-encrypting all Redis-stored credentials +5. **`account` param is internal** — `stripAccountParam()` removes it from args before presenting tools in multi-tenant MCP sessions +6. **WhatsApp inbound webhook needs raw body** — uses `express.raw({ type: '*/*' })` for HMAC verification; do not switch to `express.json()` +7. **Cookie first-party requirement** — always set session cookies in the domain that will read them; the `/login` route on hermes exists for this reason diff --git a/DEPLOY.md b/DEPLOY.md index de0d6da..543e07f 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,155 +1,68 @@ -# Hermes MCP — Setup & Deployment +# Hermes MCP — Deployment Runbook -Hermes is a multi-account email MCP server for Claude AI. -It supports **Yahoo Mail** (IMAP App Password) and any **custom IMAP/SMTP server**. +Production deployment runs on a single-node MicroK8s cluster in the `fetcherpay` namespace. Three services are deployed: `hermes-mcp` (API), `squaremcp-app` (SaaS UI), `squaremcp-docs` (docs). --- -## Local development +## Prerequisites -```bash -# 1. Install dependencies -npm install - -# 2. Copy and fill in credentials -cp .env.example .env -# edit .env with your email credentials - -# 3. Run in dev mode (hot-reload) -npm run dev - -# 4. Test health -curl http://localhost:3456/health -# → {"status":"ok","service":"hermes-mcp"} -``` - ---- - -## Production deployment (Kubernetes example) - -Example deployment using MicroK8s single-node cluster. -SSL is handled by `cert-manager` with a Let's Encrypt certificate. - -### Prerequisites on the server - MicroK8s with addons: `dns`, `ingress`, `registry`, `cert-manager` -- Local registry at `localhost:32000` -- A `ClusterIssuer` named `letsencrypt-prod` already configured +- Local image registry at `localhost:32000` +- `ClusterIssuer` named `letsencrypt-prod` configured +- MySQL 8 running as Docker container on host (`127.0.0.1:3306`) +- Redis 7 running as Docker container on host (`127.0.0.1:6379`) + +--- + +## Deploying hermes-mcp -### One-time: create K8s namespace and secret ```bash -microk8s kubectl create namespace hermes-mcp # if it doesn't exist +# 1. Build TypeScript +npm run build -microk8s kubectl create secret generic hermes-mcp-env -n hermes-mcp \ - --from-literal=YAHOO_EMAIL=your@yahoo.com \ - --from-literal=YAHOO_APP_PASSWORD=your-app-password \ - --from-literal=CUSTOM_EMAIL=your@domain.com \ - --from-literal=CUSTOM_PASSWORD=your-password \ - --from-literal=CUSTOM_IMAP_HOST=mail.yourdomain.com \ - --from-literal=CUSTOM_IMAP_PORT=993 \ - --from-literal=CUSTOM_SMTP_HOST=mail.yourdomain.com \ - --from-literal=CUSTOM_SMTP_PORT=587 \ - --from-literal=PORT=3456 -``` - -### Build & push image -```bash -# Option 1: Build locally and push to your registry +# 2. Build and push Docker image docker build -t localhost:32000/hermes-mcp:latest . docker push localhost:32000/hermes-mcp:latest -# Option 2: Copy to server and build there -scp -r src/ package*.json tsconfig.json Dockerfile user@your-server:~/hermes-mcp/ -ssh user@your-server "cd ~/hermes-mcp && docker build -t localhost:32000/hermes-mcp:latest . && docker push localhost:32000/hermes-mcp:latest" -``` +# 3. Get the sha256 digest from push output, or: +docker inspect localhost:32000/hermes-mcp:latest \ + --format='{{index .RepoDigests 0}}' -### Apply K8s manifests -```yaml -# hermes-k8s.yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: hermes-mcp - namespace: hermes-mcp -spec: - replicas: 1 - selector: - matchLabels: - app: hermes-mcp - template: - metadata: - labels: - app: hermes-mcp - spec: - containers: - - name: hermes-mcp - image: localhost:32000/hermes-mcp:latest - ports: - - containerPort: 3456 - envFrom: - - secretRef: - name: hermes-mcp-env - readinessProbe: - httpGet: - path: /health - port: 3456 - initialDelaySeconds: 5 - periodSeconds: 10 ---- -apiVersion: v1 -kind: Service -metadata: - name: hermes-mcp - namespace: hermes-mcp -spec: - selector: - app: hermes-mcp - ports: - - port: 3456 - targetPort: 3456 ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: hermes-ingress - namespace: hermes-mcp - annotations: - cert-manager.io/cluster-issuer: letsencrypt-prod - nginx.ingress.kubernetes.io/proxy-buffering: "off" - nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" - nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" -spec: - ingressClassName: nginx - rules: - - host: hermes.yourdomain.com - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: hermes-mcp - port: - number: 3456 - tls: - - hosts: - - hermes.yourdomain.com - secretName: hermes-tls -``` +# 4. Update hermes-k8s.yaml +# Change: image: localhost:32000/hermes-mcp@sha256: +# To: image: localhost:32000/hermes-mcp@sha256: -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 +``` diff --git a/README.md b/README.md index 5c44772..a55ad60 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,130 @@ -# Hermes MCP +# Hermes MCP — SquareMCP Gateway -Hermes MCP is a hosted MCP gateway for messaging, knowledge, and social connectors. +Hermes is the MCP server powering [SquareMCP](https://squaremcp.com). It exposes 51 tools across 11 platforms (email, Obsidian, WhatsApp, LinkedIn, TikTok, Facebook, Instagram, Twitter, Telegram, Discord, Snapchat) over Streamable HTTP, with per-user authentication, OAuth 2.0, and multi-tenant credential isolation. -The production endpoint is: - -```text +**Production endpoint:** +``` https://hermes.squaremcp.com/mcp ``` -Hermes currently supports MCP access patterns for: - -1. email -2. Obsidian vault notes -3. WhatsApp Business -4. LinkedIn -5. Telegram -6. additional social connectors that are in various states of credentialing and rollout - --- -## What Hermes is for +## Quick connect -Hermes is the integration and connector layer behind broader product work such as SquareMCP. - -Use Hermes when you want: - -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.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 { - "$schema": "https://opencode.ai/config.json", - "mcp": { - "hermes": { - "type": "remote", + "mcpServers": { + "squaremcp": { + "type": "http", "url": "https://hermes.squaremcp.com/mcp", - "headers": { - "x-api-key": "YOUR_MCP_API_KEY" - } + "headers": { "Authorization": "Bearer YOUR_TOKEN" } } } } ``` -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 ` — JWT or OAuth access token +3. `Cookie: session=` — 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 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 +``` diff --git a/docs/Dockerfile b/docs/Dockerfile new file mode 100644 index 0000000..df16030 --- /dev/null +++ b/docs/Dockerfile @@ -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 diff --git a/docs/docs-k8s.yaml b/docs/docs-k8s.yaml new file mode 100644 index 0000000..1c37fa5 --- /dev/null +++ b/docs/docs-k8s.yaml @@ -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 diff --git a/docs/getting-started.html b/docs/getting-started.html index 54d14c9..b7ffa31 100644 --- a/docs/getting-started.html +++ b/docs/getting-started.html @@ -53,12 +53,39 @@

Step 2 — Configure your MCP client

- + +
-
+
+

Connect SquareMCP directly inside the claude.ai web interface — no config files needed.

+
    +
  1. Open MCP Servers — go to claude.ai → Settings → MCP Servers and click Add.
  2. +
  3. Enter the server URL
  4. +
+
https://hermes.squaremcp.com
+
    +
  1. +
    + Complete the OAuth flow — a popup will open at hermes.squaremcp.com/login. + Sign in with your SquareMCP account credentials. After signing in you will be shown a consent page — click Connect MCP client. + The popup closes and the connector shows as Connected. +
    +
  2. +
+
+ Why a separate login page? + Browsers partition cookies per top-level site. The OAuth popup runs on hermes.squaremcp.com, so your session must be established there — not on app.squaremcp.com — for the cookie to be visible. +
+

Troubleshooting claude.ai connections

+

Popup doesn't open — make sure your browser isn't blocking pop-ups from claude.ai. Allow pop-ups for claude.ai and retry.

+

Stuck on login after connecting — the popup may have been closed before the OAuth flow finished. Remove the server entry, click Add again, and complete the full popup flow.

+

Shows connected but tools don't appear — start a new conversation. Tools from newly connected MCP servers appear in the next conversation's tool picker.

+
+ +
// ~/Library/Application Support/Claude/claude_desktop_config.json  (macOS)
 // %APPDATA%\Claude\claude_desktop_config.json                       (Windows)
 {
diff --git a/docs/platforms.html b/docs/platforms.html
index 2624cd9..8e13069 100644
--- a/docs/platforms.html
+++ b/docs/platforms.html
@@ -51,7 +51,7 @@
     
  • Create a LinkedIn app - Go to developer.linkedin.com/apps and create a new app. Add your company page and request the w_member_social and r_liteprofile products. + Go to linkedin.com/developers/apps and create a new app. Add your company page and request the w_member_social and r_liteprofile products.
  • diff --git a/product/app/app-k8s.yaml b/product/app/app-k8s.yaml index c47c641..cd0ecd7 100644 --- a/product/app/app-k8s.yaml +++ b/product/app/app-k8s.yaml @@ -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 diff --git a/product/app/app.js b/product/app/app.js index 9870da2..f74744a 100644 --- a/product/app/app.js +++ b/product/app/app.js @@ -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(); diff --git a/src/auth.ts b/src/auth.ts index 8e91a1c..8ed5539 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -12,6 +12,7 @@ export interface JWTPayload { sub: string; // customer id email: string; plan: string; + role?: string; } export async function hashPassword(password: string): Promise { @@ -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 { const [rows] = await getPool().query( - '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 { const [rows] = await getPool().query( - '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( - '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; diff --git a/src/billing/middleware.ts b/src/billing/middleware.ts index 1aeb89c..bac49e6 100644 --- a/src/billing/middleware.ts +++ b/src/billing/middleware.ts @@ -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: (platform: Platform) => Promise; } @@ -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: (platform: Platform) => getCredential(row.id, platform), }; diff --git a/src/index.ts b/src/index.ts index 24b7886..2232050 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,10 +39,17 @@ import redis from './redis.js'; const app = express(); app.use(cookieParser()); app.use(cors({ - origin: '*', + origin: (origin, callback) => { + // Allow requests with no origin (curl, server-to-server, MCP clients) + if (!origin) return callback(null, true); + if (SQUAREMCP_ALLOWED_ORIGINS.has(origin)) return callback(null, origin); + // Allow localhost for dev/testing + if (/^https?:\/\/localhost(:\d+)?$/.test(origin)) return callback(null, origin); + callback(null, false); + }, methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'mcp-session-id', 'Accept', 'x-api-key', 'Authorization'], - credentials: true + credentials: true, })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); @@ -58,6 +65,7 @@ const PROTECTED_RESOURCE_METADATA_URL = `${SERVER_URL}/.well-known/oauth-protect const SQUAREMCP_ALLOWED_ORIGINS = new Set([ 'https://squaremcp.com', 'https://www.squaremcp.com', + 'https://app.squaremcp.com', 'https://tiktok.squaremcp.com', ]); @@ -349,11 +357,12 @@ async function requireAuth(req: express.Request, res: express.Response, next: ex const payload = verifyJWT(jwtCookie); const customer = await resolveCustomerById(payload.sub); if (customer && customer.active) { - (req as express.Request & { customer?: Customer; jwtUser?: { id: string; email: string; plan: string } }).customer = customer; - (req as express.Request & { jwtUser?: { id: string; email: string; plan: string } }).jwtUser = { + (req as express.Request & { customer?: Customer; jwtUser?: { id: string; email: string; plan: string; role: string } }).customer = customer; + (req as express.Request & { jwtUser?: { id: string; email: string; plan: string; role: string } }).jwtUser = { id: payload.sub, email: payload.email, plan: payload.plan, + role: customer.role, }; return next(); } @@ -416,6 +425,98 @@ app.post('/oauth/register', async (req, res) => { }); }); +// ── Native login page (first-party cookie for OAuth flow) ────────────────── +app.get('/login', (req, res) => { + const returnTo = req.query.return_to as string | undefined; + const error = req.query.error as string | undefined; + const safeReturnTo = returnTo && returnTo.startsWith('https://hermes.squaremcp.com/') + ? returnTo + : '/'; + const errMsg = error === 'invalid' ? 'Incorrect email or password.' + : error === 'missing' ? 'Email and password are required.' + : ''; + + res.setHeader('Content-Type', 'text/html'); + res.send(` + + + + + Sign in — SquareMCP + + + +
    + +

    Sign in to continue

    +

    Connect your SquareMCP account to authorize access.

    + ${errMsg ? `

    ${errMsg}

    ` : ''} +
    + +
    + + +
    +
    + + +
    + +
    +
    + +`); +}); + +app.post('/login', express.urlencoded({ extended: false }), async (req, res) => { + const { email, password, return_to } = req.body as Record; + 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; diff --git a/src/tools.test.ts b/src/tools.test.ts index f7adca6..9611dd2 100644 --- a/src/tools.test.ts +++ b/src/tools.test.ts @@ -99,6 +99,7 @@ const mockCustomer = { plan: 'growth' as const, active: true, email: 'test@example.com', + role: 'user', getCredential: vi.fn().mockResolvedValue(null), };