feat(saas): SquareMCP v2 — multi-tenant MCP platform complete

Steps 0–10 of the v2 plan, 194 tests passing.

Core infrastructure
- Shared Redis client (src/redis.ts); all four Redis consumers migrated
- Vitest test harness with vitest.config.ts and npm test/test:watch scripts

Billing & invoicing (Steps 1–2)
- Monthly invoice generation with idempotency (MySQL uq_customer_period unique key)
- Cron job with Redis distributed lock (Lua compare-delete, 1-hr TTL)
- Invoice emailer via nodemailer (FETCHERPAY SMTP)
- Billing middleware: checkLimit gate in handleToolCall; platform attribution fix

Email multi-tenancy (Step 3)
- EmailCtx = Account | EmailCredentials; imap.ts + smtp.ts accept both
- resolveEmailCtx helper in tools.ts; all email tools use customer credentials

Analytics + platform health (Steps 4–5)
- Chart.js bar charts for platform breakdown and daily activity
- Token expiry check in getCredential with dynamic import refresh
- platform-health.ts: per-platform health probe with 10-min Redis cache
- GET /api/health/platforms; "Token expired" amber badge in dashboard

Tool schema filtering (Step 6)
- stripAccountParam deep-clones tool schemas; multi-tenant sessions never
  see the internal account enum

OAuth hardening (Step 7)
- Atomic auth code consumption: UPDATE SET used=TRUE, check affectedRows
- customer_id threaded through oauth_auth_codes → oauth_tokens
- getTokenCustomer(); requireAuth resolves req.customer from Bearer token
- Consent page requires authenticated session; redirect_uri validated
  against registered URIs; http://localhost:* loopback wildcard

DCR browser flow (Step 8)
- ensureOAuthAppRegistered() upserts pre-registered SquareMCP OAuth app
  on startup with redirect URIs for mcp-callback, localhost:*, claude-desktop,
  opencode
- GET /oauth/connect-mcp → server-side redirect (client_id off frontend)
- GET /oauth/mcp-callback → exchanges code, renders config snippet page
  with copy buttons for Claude Desktop and Codex CLI

Webhooks (Step 9)
- webhook_url + webhook_secret columns on customers
- deliverWebhook(): HMAC-SHA256 signing, 3× exponential retry (1s/4s/16s),
  Redis DLQ with 7-day TTL on total failure
- isValidWebhookUrl(): SSRF protection (blocks RFC-1918, localhost, .local)
- POST /api/webhooks/config (secret returned once), GET, DELETE
- GET /api/admin/webhooks/dlq/:customerId
- WhatsApp POST route uses express.raw() for raw body preservation
- Dashboard Webhooks tab with secret-once display and copy button

Developer docs (Step 10)
- docs/ static HTML site (GitHub Pages, no build pipeline)
- index.html: landing page with client + platform overview
- getting-started.html: tabbed MCP config for Claude Desktop, Codex CLI, opencode
- platforms.html: LinkedIn, TikTok, WhatsApp, Instagram, Twitter, Telegram guides
- agent-tutorial.html: complete Node.js agent (Anthropic SDK + MCP SDK),
  LinkedIn posting loop, extensions for multi-platform + inbound webhook reaction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garfield
2026-05-13 23:43:35 -04:00
parent d4bc899b31
commit 61dab40585
38 changed files with 5042 additions and 238 deletions

View File

@@ -1,4 +1,5 @@
import 'dotenv/config';
import crypto from 'crypto';
import express from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
@@ -11,7 +12,7 @@ import {
ListResourcesRequestSchema,
isInitializeRequest,
} from '@modelcontextprotocol/sdk/types.js';
import { tools, handleToolCall } from './tools.js';
import { tools, handleToolCall, stripAccountParam } from './tools.js';
import { getManifest, getOpenApiSpec, getOpenApiSpecMail, getOpenApiSpecSocial } from './manifest.js';
import { routeWhatsAppWebhook, registerWhatsAppNumber, type RoutedWebhookEvent } from './multitenancy/webhook-router.js';
import { storeCredential, type Platform } from './multitenancy/credential-store.js';
@@ -22,12 +23,18 @@ import {
createAuthCode,
exchangeCodeForToken,
validateAccessToken,
getTokenCustomer,
getAuthorizeHtml,
isValidRedirectUri,
ensureOAuthAppRegistered,
} from './oauth.js';
import { initDatabase, getPool } from './db.js';
import { hashPassword, verifyPassword, signJWT, verifyJWT, findCustomerByEmail, createCustomer, setResetToken, findCustomerByResetToken, clearResetToken, updatePassword } from './auth.js';
import { recordUsage, getMonthlyUsage, getUsageBreakdown, checkLimit } from './billing/usage.js';
import { getCustomerInvoices, getInvoiceByNumber, markInvoiceSent, markInvoicePaid, generateMonthlyInvoice } from './billing/invoices.js';
import { getAllPlatformHealth } from './multitenancy/platform-health.js';
import { deliverWebhook, isValidWebhookUrl } from './webhooks/delivery.js';
import redis from './redis.js';
const app = express();
app.use(cookieParser());
@@ -320,9 +327,20 @@ async function requireAuth(req: express.Request, res: express.Response, next: ex
}
}
// 3. Check OAuth Bearer token
// 3. Check OAuth Bearer token — resolve to customer when token has customer_id binding
const bearerToken = extractBearerToken(req);
if (bearerToken && await validateAccessToken(bearerToken)) return next();
if (bearerToken) {
const tokenInfo = await getTokenCustomer(bearerToken);
if (tokenInfo) {
const customer = await resolveCustomerById(tokenInfo.customerId);
if (customer && customer.active) {
(req as express.Request & { customer?: Customer }).customer = customer;
return next();
}
}
// Fall back to legacy tokens (no customer_id) — validate presence only
if (await validateAccessToken(bearerToken)) return next();
}
// 4. Check JWT session cookie (web app auth)
const jwtCookie = req.cookies?.session;
@@ -364,17 +382,19 @@ app.use((req, res, next) => {
next();
});
function createMcpServer() {
function createMcpServer(customer?: Customer) {
const server = new Server(
{ name: 'hermes', version: '1.0.0' },
{ capabilities: { tools: {}, resources: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
const visibleTools = customer ? tools.map(stripAccountParam) : tools;
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: visibleTools }));
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
return handleToolCall(
request.params.name,
(request.params.arguments ?? {}) as Record<string, unknown>
(request.params.arguments ?? {}) as Record<string, unknown>,
customer
);
});
return server;
@@ -421,6 +441,26 @@ app.get('/oauth/authorize', async (req, res) => {
return;
}
if (!isValidRedirectUri(redirectUri, client.redirect_uris)) {
res.status(400).send('redirect_uri not registered for this client');
return;
}
// 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}`);
return;
}
try {
verifyJWT(jwtCookie);
} catch {
const returnTo = encodeURIComponent(req.originalUrl);
res.redirect(`/login?return_to=${returnTo}`);
return;
}
res.setHeader('Content-Type', 'text/html');
res.send(getAuthorizeHtml({
client_id: clientId,
@@ -452,6 +492,11 @@ app.post('/oauth/authorize', async (req, res) => {
return;
}
if (!isValidRedirectUri(redirectUri, client.redirect_uris)) {
res.status(400).send('redirect_uri not registered for this client');
return;
}
if (action !== 'allow') {
const url = new URL(redirectUri);
url.searchParams.set('error', 'access_denied');
@@ -461,7 +506,19 @@ app.post('/oauth/authorize', async (req, res) => {
return;
}
const code = await createAuthCode(clientId, redirectUri, scope, codeChallenge, codeChallengeMethod);
// Bind the auth code to the authenticated customer if present
let customerId: string | undefined;
const jwtCookie = req.cookies?.session;
if (jwtCookie) {
try {
const payload = verifyJWT(jwtCookie);
customerId = payload.sub;
} catch {
// no binding — legacy flow
}
}
const code = await createAuthCode(clientId, redirectUri, scope, codeChallenge, codeChallengeMethod, customerId);
const url = new URL(redirectUri);
url.searchParams.set('code', code.code);
if (state) url.searchParams.set('state', state);
@@ -502,6 +559,121 @@ app.post('/oauth/token', async (req, res) => {
});
});
// ── DCR browser flow ────────────────────────────────────────────────────────
// Kick off the "Connect MCP Client" browser flow — server redirects to consent page
// so the client_id never needs to be exposed in the frontend JS.
app.get('/oauth/connect-mcp', (req, res) => {
const clientId = process.env.OAUTH_CLIENT_ID;
if (!clientId) {
res.status(503).send('MCP OAuth app not configured (OAUTH_CLIENT_ID missing)');
return;
}
const callbackUrl = `${SERVER_URL}/oauth/mcp-callback`;
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: callbackUrl,
response_type: 'code',
scope: 'mcp',
});
res.redirect(`/oauth/authorize?${params}`);
});
// Callback — exchange code for token and render the config snippet page
app.get('/oauth/mcp-callback', async (req, res) => {
const code = req.query.code as string | undefined;
const error = req.query.error as string | undefined;
if (error || !code) {
res.status(400).send(renderMcpCallbackHtml({ error: error || 'Missing authorization code' }));
return;
}
const clientId = process.env.OAUTH_CLIENT_ID;
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
res.status(503).send(renderMcpCallbackHtml({ error: 'Server misconfiguration — OAUTH_CLIENT_ID/SECRET missing' }));
return;
}
const callbackUrl = `${SERVER_URL}/oauth/mcp-callback`;
const token = await exchangeCodeForToken(clientId, clientSecret, code, callbackUrl);
if (!token) {
res.status(400).send(renderMcpCallbackHtml({ error: 'Token exchange failed — code may be expired or already used' }));
return;
}
res.setHeader('Content-Type', 'text/html');
res.send(renderMcpCallbackHtml({ token: token.access_token, serverUrl: SERVER_URL }));
});
function renderMcpCallbackHtml(opts: { token?: string; serverUrl?: string; error?: string }): string {
if (opts.error) {
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Connection failed</title>
<style>body{font-family:system-ui,sans-serif;background:#0f0f10;color:#e5e5e5;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0}
.card{background:#1a1a1b;border:1px solid #2a2a2b;border-radius:12px;padding:32px;max-width:520px;width:100%}
h1{color:#dc2626;margin:0 0 12px}p{color:#888;margin:0}</style></head>
<body><div class="card"><h1>Connection failed</h1><p>${opts.error}</p></div></body></html>`;
}
const { token, serverUrl } = opts;
const claudeConfig = JSON.stringify({
mcpServers: { 'hermes-mcp': { type: 'http', url: `${serverUrl}/mcp`, headers: { Authorization: `Bearer ${token}` } } }
}, null, 2);
const codexConfig = JSON.stringify({
mcpServers: { 'hermes-mcp': { type: 'http', url: `${serverUrl}/mcp`, headers: { Authorization: `Bearer ${token}` } } }
}, null, 2);
const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>MCP Client Connected — SquareMCP</title>
<style>
body{font-family:system-ui,sans-serif;background:#0f0f10;color:#e5e5e5;margin:0;padding:24px}
.card{background:#1a1a1b;border:1px solid #2a2a2b;border-radius:12px;padding:32px;max-width:680px;margin:0 auto}
h1{font-size:22px;margin:0 0 8px;color:#10a37f}
.subtitle{color:#888;margin:0 0 28px;font-size:14px}
h2{font-size:14px;font-weight:600;color:#888;text-transform:uppercase;letter-spacing:.05em;margin:20px 0 8px}
pre{background:#0f0f10;border:1px solid #2a2a2b;border-radius:8px;padding:16px;font-size:12px;overflow-x:auto;position:relative}
.copy-btn{position:absolute;top:8px;right:8px;background:#2a2a2b;border:none;color:#888;padding:4px 10px;border-radius:6px;cursor:pointer;font-size:11px}
.copy-btn:hover{color:#e5e5e5}
.token-box{background:#0f0f10;border:1px solid #2a2a2b;border-radius:8px;padding:12px 16px;font-family:monospace;font-size:13px;word-break:break-all;margin-bottom:8px}
.warn{color:#888;font-size:12px;margin:4px 0 20px}
</style>
</head>
<body>
<div class="card">
<h1>MCP Client Connected!</h1>
<p class="subtitle">Copy your access token and the config for your MCP client below.</p>
<h2>Your Access Token</h2>
<div class="token-box">${esc(token!)}</div>
<p class="warn">Store this securely — it won't be shown again.</p>
<h2>Claude Desktop <code>claude_desktop_config.json</code></h2>
<pre id="claude-cfg">${esc(claudeConfig)}<button class="copy-btn" onclick="copy('claude-cfg')">Copy</button></pre>
<h2>Codex CLI / opencode config</h2>
<pre id="codex-cfg">${esc(codexConfig)}<button class="copy-btn" onclick="copy('codex-cfg')">Copy</button></pre>
</div>
<script>
function copy(id) {
const pre = document.getElementById(id);
const text = pre.innerText.replace(/Copy$/, '').trim();
navigator.clipboard.writeText(text).then(() => {
const btn = pre.querySelector('.copy-btn');
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
});
}
</script>
</body>
</html>`;
}
// ── TikTok Login Kit + Content Posting auth flow ───────────────────────────
app.get('/auth/tiktok/start', async (req, res) => {
if (!TIKTOK_CLIENT_KEY) {
@@ -650,22 +822,25 @@ app.get('/auth/tiktok/callback', async (req, res) => {
// ── Streamable HTTP transport (MCP 1.x standard) ────────────────────────────
const httpTransports = new Map<string, StreamableHTTPServerTransport>();
const sessionCustomers = new Map<string, Customer>();
async function createSession(): Promise<StreamableHTTPServerTransport> {
async function createSession(customer?: Customer): Promise<StreamableHTTPServerTransport> {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (id) => {
console.log(`[mcp] Session initialized: ${id}`);
httpTransports.set(id, transport);
if (customer) sessionCustomers.set(id, customer);
},
});
transport.onclose = () => {
if (transport.sessionId) {
console.log(`[mcp] Session closed: ${transport.sessionId}`);
httpTransports.delete(transport.sessionId);
sessionCustomers.delete(transport.sessionId);
}
};
const server = createMcpServer();
const server = createMcpServer(customer);
await server.connect(transport);
return transport;
}
@@ -674,6 +849,7 @@ app.post('/mcp', requireAuth, async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
console.log(`[mcp] POST sessionId=${sessionId ?? 'none'}, isInit=${isInitializeRequest(req.body)}`);
const reqCustomer = (req as express.Request & { customer?: Customer }).customer;
let transport: StreamableHTTPServerTransport;
if (sessionId && httpTransports.has(sessionId)) {
@@ -684,12 +860,12 @@ app.post('/mcp', requireAuth, async (req, res) => {
console.warn(`[mcp] Stale session ${sessionId} re-initializing — pod may have restarted`);
}
console.log(`[mcp] Creating new session`);
transport = await createSession();
transport = await createSession(reqCustomer);
} else {
// Stale session ID from a pod restart — transparently create a new session
// and handle the request. Our tools are stateless so no context is lost.
console.warn(`[mcp] Unknown session ${sessionId ?? '(none)'} — auto-recovering with new session`);
transport = await createSession();
transport = await createSession(reqCustomer);
}
try {
@@ -725,7 +901,8 @@ app.get('/sse', requireAuth, async (req, res) => {
const transport = new SSEServerTransport('/messages', res);
sseTransports.set(transport.sessionId, transport);
res.on('close', () => sseTransports.delete(transport.sessionId));
const server = createMcpServer();
const sseCustomer = (req as express.Request & { customer?: Customer }).customer;
const server = createMcpServer(sseCustomer);
await server.connect(transport);
});
@@ -919,7 +1096,12 @@ app.get('/api/whatsapp/templates', requireAuth, async (req, res) => {
// ── WhatsApp webhook (multi-tenant) ─────────────────────────────
async function handleInboundWhatsAppMessage(event: RoutedWebhookEvent): Promise<void> {
console.log(`[webhook/whatsapp] inbound message from=${event.message.from} customer=${event.customerId} type=${event.message.type}`);
// Future: route to customer's agent or queue for processing
// Fire-and-forget — don't block the webhook acknowledgement
deliverWebhook(event.customerId, 'whatsapp', 'inbound_message', {
from: event.message.from,
text: (event.message as unknown as Record<string, unknown>).text ?? null,
timestamp: event.message.timestamp,
}).catch((err) => console.error('[webhook/whatsapp] delivery error:', err));
}
// WhatsApp webhook verification (GET)
@@ -935,13 +1117,14 @@ app.get('/webhook/whatsapp', (req, res) => {
}
});
// WhatsApp webhook delivery (POST) — multi-tenant routed
app.post('/webhook/whatsapp', express.json(), async (req, res) => {
// WhatsApp webhook delivery (POST) — raw body preserved for HMAC verification
app.post('/webhook/whatsapp', express.raw({ type: '*/*' }), async (req, res) => {
// Always acknowledge immediately to prevent Meta retries (20s window)
res.status(200).send('EVENT_RECEIVED');
try {
const events = await routeWhatsAppWebhook(req.body as Record<string, unknown>);
const body = JSON.parse((req.body as Buffer).toString('utf8')) as Record<string, unknown>;
const events = await routeWhatsAppWebhook(body);
for (const event of events) {
await handleInboundWhatsAppMessage(event);
}
@@ -1125,15 +1308,62 @@ app.get('/api/connections', meterMiddleware, async (req, res) => {
const customer = (req as unknown as { customer: Customer }).customer;
const platforms: Platform[] = ['email', 'whatsapp', 'linkedin', 'telegram', 'discord', 'instagram', 'twitter', 'tiktok', 'snapchat', 'facebook', 'obsidian'];
const status: Record<string, boolean> = {};
for (const platform of platforms) {
const cred = await customer.getCredential(platform);
status[platform] = cred !== null;
}
const results = await Promise.all(platforms.map((p) => customer.getCredential(p)));
const status: Record<string, boolean> = Object.fromEntries(
platforms.map((p, i) => [p, results[i] !== null])
);
res.json({ customerId: customer.id, connections: status });
});
app.get('/api/health/platforms', meterMiddleware, async (req, res) => {
const customer = (req as unknown as { customer: Customer }).customer;
const health = await getAllPlatformHealth(customer.id);
res.json({ health });
});
// ── Webhooks ────────────────────────────────────────────────────
app.get('/api/webhooks/config', meterMiddleware, async (req, res) => {
const customer = (req as unknown as { customer: Customer }).customer;
const [rows] = await getPool().query<any[]>(
'SELECT webhook_url FROM customers WHERE id = ?',
[customer.id]
);
res.json({ webhookUrl: rows[0]?.webhook_url ?? null });
});
app.post('/api/webhooks/config', meterMiddleware, async (req, res) => {
const customer = (req as unknown as { customer: Customer }).customer;
const webhookUrl = (req.body as Record<string, unknown>).webhook_url as string | undefined;
if (!webhookUrl) {
res.status(400).json({ error: 'webhook_url required' });
return;
}
if (!isValidWebhookUrl(webhookUrl)) {
res.status(400).json({ error: 'Invalid webhook URL — must be https:// with a public hostname' });
return;
}
const secret = crypto.randomBytes(32).toString('hex');
await getPool().query(
'UPDATE customers SET webhook_url = ?, webhook_secret = ? WHERE id = ?',
[webhookUrl, secret, customer.id]
);
// Secret returned only at creation/rotation; not retrievable afterward
res.json({ webhookUrl, webhookSecret: secret });
});
app.delete('/api/webhooks/config', meterMiddleware, async (req, res) => {
const customer = (req as unknown as { customer: Customer }).customer;
await getPool().query(
'UPDATE customers SET webhook_url = NULL, webhook_secret = NULL WHERE id = ?',
[customer.id]
);
res.json({ deleted: true });
});
// ── Usage & Limits ──────────────────────────────────────────────
app.get('/api/usage', meterMiddleware, async (req, res) => {
@@ -1150,6 +1380,20 @@ app.get('/api/usage', meterMiddleware, async (req, res) => {
});
});
app.get('/api/usage/daily', meterMiddleware, async (req, res) => {
const customer = (req as unknown as { customer: Customer }).customer;
const [rows] = await getPool().query<any[]>(
`SELECT DATE(created_at) as date, COUNT(*) as count
FROM usage_logs
WHERE customer_id = ?
AND created_at >= DATE_FORMAT(NOW(), '%Y-%m-01')
GROUP BY DATE(created_at)
ORDER BY date ASC`,
[customer.id]
);
res.json({ daily: rows });
});
// ── Invoices ────────────────────────────────────────────────────
app.get('/api/invoices', meterMiddleware, async (req, res) => {
@@ -1159,8 +1403,9 @@ app.get('/api/invoices', meterMiddleware, async (req, res) => {
});
app.get('/api/invoices/:number', meterMiddleware, async (req, res) => {
const customer = (req as express.Request & { customer?: Customer }).customer;
const invoice = await getInvoiceByNumber(req.params.number);
if (!invoice) {
if (!invoice || invoice.customer_id !== customer?.id) {
res.status(404).json({ error: 'Invoice not found' });
return;
}
@@ -1297,6 +1542,13 @@ app.post('/api/admin/invoices/:number/pay', requireAdmin, async (req, res) => {
res.json({ paid: true });
});
app.get('/api/admin/webhooks/dlq/:customerId', requireAdmin, async (req, res) => {
const { customerId } = req.params;
const entries = await redis.lRange(`webhook:dlq:${customerId}`, 0, -1);
const events = entries.map((e) => JSON.parse(e) as unknown);
res.json({ customerId, events, count: events.length });
});
// ── LinkedIn REST endpoints ─────────────────────────────────────
app.get('/api/linkedin/profile', requireAuth, async (req, res) => {
const account = req.query.account as string | undefined;
@@ -1748,6 +2000,18 @@ app.get('/health', (_req, res) => {
async function main() {
await initDatabase();
// Ensure the pre-registered SquareMCP OAuth app exists for the browser DCR flow
const oauthClientId = process.env.OAUTH_CLIENT_ID;
const oauthClientSecret = process.env.OAUTH_CLIENT_SECRET;
if (oauthClientId && oauthClientSecret) {
await ensureOAuthAppRegistered(oauthClientId, oauthClientSecret, [
`${SERVER_URL}/oauth/mcp-callback`,
'http://localhost:*',
'claude-desktop://callback',
'opencode://callback',
]);
}
app.listen(PORT, () => {
console.log(`Hermes MCP server running on port ${PORT}`);
console.log(` Streamable HTTP: ${SERVER_URL}/mcp`);