import 'dotenv/config'; import crypto from 'crypto'; import express from 'express'; import cors from 'cors'; import cookieParser from 'cookie-parser'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, isInitializeRequest, } from '@modelcontextprotocol/sdk/types.js'; import { tools, handleToolCall, stripAccountParam } from './tools.js'; import { getManifest, getOpenApiSpec, getOpenApiSpecMail, getOpenApiSpecSocial, getOpenApiSpecChatGPT } from './manifest.js'; import { routeWhatsAppWebhook, registerWhatsAppNumber, type RoutedWebhookEvent } from './multitenancy/webhook-router.js'; import { storeCredential, type Platform } from './multitenancy/credential-store.js'; import { meterMiddleware, resolveCustomerByApiKey, resolveCustomerById, type Customer } from './billing/middleware.js'; import { registerClient, getClient, 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 { notifyNewPilotRequest } from './notifications/index.js'; import redis from './redis.js'; import { handleChat, type ChatMessage } from './chat.js'; import { startEmailPoller } from './email-poller.js'; import { sendChatEscalationAlert } from './notifications/slack.js'; import { sendEmail } from './smtp.js'; process.on('uncaughtException', (err) => { console.error('FATAL uncaughtException:', err); if (err && typeof err === 'object') { console.error('Stack:', (err as Error).stack); } process.exit(1); }); process.on('unhandledRejection', (reason, promise) => { console.error('FATAL unhandledRejection at:', promise, 'reason:', reason); if (reason && typeof reason === 'object') { console.error('Stack:', (reason as Error).stack); } process.exit(1); }); const app = express(); app.use(cookieParser()); app.use(cors({ 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, })); app.use(express.json({ limit: '100kb' })); app.use(express.urlencoded({ extended: true, limit: '100kb' })); // ── Static files (videos, assets) ────────────────────────────────────────── app.use('/public', express.static('/vaults/public')); app.use(express.static(new URL('../product', import.meta.url).pathname)); // ── Config ───────────────────────────────────────────────────────────────── const PORT = process.env.PORT ?? 3456; const SERVER_URL = process.env.SERVER_URL ?? `http://localhost:${PORT}`; const MCP_RESOURCE_URL = `${SERVER_URL}/mcp`; const PROTECTED_RESOURCE_METADATA_URL = `${SERVER_URL}/.well-known/oauth-protected-resource`; const SQUAREMCP_ALLOWED_ORIGINS = new Set([ 'https://squaremcp.com', 'https://www.squaremcp.com', 'https://app.squaremcp.com', 'https://tiktok.squaremcp.com', ]); type PilotRequestBody = { name: string; email: string; company: string; role: string; use_case: string; timeline: string; systems: string; requirements: string; submission_tag: string; }; function getEasternDateString() { return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', }).format(new Date()); } function sanitizeField(value: unknown) { return String(value ?? '').trim(); } function escapeHtml(value: string) { return value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function getPilotRequestBody(body: Record): PilotRequestBody { return { name: sanitizeField(body.name), email: sanitizeField(body.email), company: sanitizeField(body.company), role: sanitizeField(body.role), use_case: sanitizeField(body.use_case), timeline: sanitizeField(body.timeline), systems: sanitizeField(body.systems), requirements: sanitizeField(body.requirements), submission_tag: sanitizeField(body.submission_tag), }; } function validatePilotRequest(body: PilotRequestBody) { const requiredFields: Array = [ 'name', 'email', 'company', 'role', 'use_case', 'timeline', 'systems', 'requirements', ]; for (const field of requiredFields) { if (!body[field]) { return `Missing required field: ${field}`; } } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) { return 'Invalid email address'; } return null; } function formatPilotRequestMarkdown(requestId: string, body: PilotRequestBody, req: express.Request) { const submittedAt = new Date().toISOString(); const source = req.get('origin') || req.get('host') || 'unknown'; const ipAddress = req.ip || req.socket.remoteAddress || 'unknown'; return [ `### ${body.company} — ${body.name}`, `- Request ID: \`${requestId}\``, `- Submitted: ${submittedAt}`, `- Source: ${source}`, `- IP: ${ipAddress}`, `- Email: ${body.email}`, `- Role: ${body.role}`, `- Use case: ${body.use_case}`, `- Timeline: ${body.timeline}`, ...(body.submission_tag ? [`- Tags: ${body.submission_tag}`] : []), '', '**Internal systems to connect**', body.systems, '', '**Security or compliance requirements**', body.requirements, '', ].join('\n'); } async function appendPilotRequestToVault(requestId: string, body: PilotRequestBody, req: express.Request) { const content = formatPilotRequestMarkdown(requestId, body, req); const dailyNotePath = `Daily Notes/${getEasternDateString()}.md`; await callTool(req, 'obsidian_append_to_note', { path: 'SquareMCP/Pilot Requests.md', header: 'Pilot Requests', content, create_if_missing: true, }); await callTool(req, 'obsidian_append_to_note', { path: dailyNotePath, header: 'SquareMCP Pilot Requests', content, create_if_missing: true, }); } const TIKTOK_CLIENT_KEY = process.env.TIKTOK_CLIENT_KEY ?? ''; const TIKTOK_CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET ?? ''; const TIKTOK_REDIRECT_URI = process.env.TIKTOK_REDIRECT_URI ?? 'https://tiktok.squaremcp.com/auth/tiktok/callback'; const TIKTOK_DEFAULT_SCOPE = 'user.info.basic,user.info.profile,user.info.stats,video.list,video.publish'; function getTikTokAuthorizeUrl(state?: string, scope = TIKTOK_DEFAULT_SCOPE) { const url = new URL('https://www.tiktok.com/v2/auth/authorize/'); url.searchParams.set('client_key', TIKTOK_CLIENT_KEY); url.searchParams.set('response_type', 'code'); url.searchParams.set('scope', scope); url.searchParams.set('redirect_uri', TIKTOK_REDIRECT_URI); if (state) { url.searchParams.set('state', state); } return url.toString(); } function renderTikTokCallbackHtml(args: { title: string; status: 'success' | 'error'; message: string; details?: string[]; }) { const accent = args.status === 'success' ? '#22c55e' : '#ef4444'; const details = (args.details ?? []) .map((line) => `
  • ${escapeHtml(line)}
  • `) .join(''); return ` ${escapeHtml(args.title)}
    TikTok Login Kit

    ${escapeHtml(args.title)}

    ${escapeHtml(args.message)}

    ${details ? `
      ${details}
    ` : ''}
    `; } // ── Rate limiting ─────────────────────────────────────────────────────────── async function checkRateLimit(req: express.Request, res: express.Response, max: number, windowSec: number): Promise { const ip = req.ip ?? req.socket.remoteAddress ?? 'unknown'; const key = `ratelimit:${req.path}:${ip}`; try { const count = await redis.incr(key); if (count === 1) await redis.expire(key, windowSec); if (count > max) { res.status(429).json({ error: 'Too many requests. Please try again later.' }); return false; } } catch { // Redis unavailable — fail open rather than block legitimate users } return true; } // ── Auth middleware ───────────────────────────────────────────────────────── const API_KEY = process.env.MCP_API_KEY; function extractBearerToken(req: express.Request): string | undefined { const authHeader = req.headers.authorization as string | undefined; if (authHeader && authHeader.startsWith('Bearer ')) { return authHeader.slice(7); } return undefined; } function extractBasicClientCredentials(req: express.Request): { clientId?: string; clientSecret?: string } { const authHeader = req.headers.authorization as string | undefined; if (!authHeader || !authHeader.startsWith('Basic ')) { return {}; } try { const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8'); const separatorIndex = decoded.indexOf(':'); if (separatorIndex === -1) { return {}; } return { clientId: decoded.slice(0, separatorIndex), clientSecret: decoded.slice(separatorIndex + 1), }; } catch { return {}; } } async function requireAuth(req: express.Request, res: express.Response, next: express.NextFunction) { try { // No API key configured = open access if (!API_KEY) return next(); // 1. Check x-api-key header or query param (backward compatibility — global key) const apiKeyProvided = (req.headers['x-api-key'] as string | undefined) || (req.query.key as string | undefined); if (apiKeyProvided === API_KEY) return next(); // 2. Check customer API key (per-user SaaS auth) if (apiKeyProvided) { const customer = await resolveCustomerByApiKey(apiKeyProvided); if (customer && customer.active) { (req as express.Request & { customer?: Customer }).customer = customer; return next(); } } // 3. Check OAuth Bearer token — resolve to customer when token has customer_id binding const bearerToken = extractBearerToken(req); 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; if (jwtCookie) { try { 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; 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(); } } catch { // invalid JWT, fall through to 401 } } res.setHeader( 'WWW-Authenticate', `Bearer realm="hermes", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"` ); res.status(401).json({ error: 'Unauthorized — provide x-api-key header, ?key= query param, Bearer token, or session cookie' }); } catch (err) { next(err); } } // Request logging middleware app.use((req, res, next) => { const timestamp = new Date().toISOString(); console.log(`[${timestamp}] ${req.method} ${req.path} - ${req.headers['user-agent'] || 'no-ua'}`); if (req.body && Object.keys(req.body).length > 0 && !req.path.startsWith('/oauth')) { console.log(` Body: ${JSON.stringify(req.body).substring(0, 500)}`); } next(); }); function createMcpServer(customer?: Customer) { const server = new Server( { name: 'hermes', version: '1.0.0' }, { capabilities: { tools: {}, resources: {} } } ); 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, customer ); }); return server; } // ── OAuth 2.0 + Dynamic Client Registration ───────────────────────────────── // DCR: ChatGPT registers itself app.post('/oauth/register', async (req, res) => { const body = req.body || {}; const client = await registerClient(body); res.status(201).json({ client_id: client.client_id, client_secret: client.client_secret, client_name: client.client_name, redirect_uris: client.redirect_uris, grant_types: ['authorization_code'], token_endpoint_auth_method: 'client_secret_post', }); }); // ── 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; const redirectUri = req.query.redirect_uri as string | undefined; const state = req.query.state as string | undefined; const scope = req.query.scope as string | undefined; const codeChallenge = req.query.code_challenge as string | undefined; const codeChallengeMethod = req.query.code_challenge_method as string | undefined; const responseType = req.query.response_type as string | undefined; if (!clientId || !redirectUri) { res.status(400).send('Missing client_id or redirect_uri'); return; } if (responseType && responseType !== 'code') { res.status(400).send('Unsupported response_type'); return; } const client = await getClient(clientId); if (!client) { res.status(400).send('Invalid client_id'); 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) { res.redirect(`/login?return_to=${encodeURIComponent(`https://hermes.squaremcp.com${req.originalUrl}`)}`); return; } try { verifyJWT(jwtCookie); } catch { res.redirect(`/login?return_to=${encodeURIComponent(`https://hermes.squaremcp.com${req.originalUrl}`)}`); return; } res.setHeader('Content-Type', 'text/html'); res.send(getAuthorizeHtml({ client_id: clientId, redirect_uri: redirectUri, state, scope, code_challenge: codeChallenge, code_challenge_method: codeChallengeMethod, })); }); app.post('/oauth/authorize', async (req, res) => { const clientId = req.body.client_id as string | undefined; const redirectUri = req.body.redirect_uri as string | undefined; const state = req.body.state as string | undefined; const scope = req.body.scope as string | undefined; const codeChallenge = req.body.code_challenge as string | undefined; const codeChallengeMethod = req.body.code_challenge_method as string | undefined; const action = req.body.action as string | undefined; if (!clientId || !redirectUri) { res.status(400).send('Missing client_id or redirect_uri'); return; } const client = await getClient(clientId); if (!client) { res.status(400).send('Invalid client_id'); 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'); url.searchParams.set('error_description', 'User denied authorization'); if (state) url.searchParams.set('state', state); res.redirect(url.toString()); return; } // 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); res.redirect(url.toString()); }); // Token endpoint: exchange code for access token app.post('/oauth/token', async (req, res) => { const grantType = req.body.grant_type as string | undefined; if (grantType !== 'authorization_code') { res.status(400).json({ error: 'unsupported_grant_type' }); return; } const basicAuth = extractBasicClientCredentials(req); const clientId = (req.body.client_id as string | undefined) || basicAuth.clientId; const clientSecret = (req.body.client_secret as string | undefined) || basicAuth.clientSecret; const code = req.body.code as string | undefined; const redirectUri = req.body.redirect_uri as string | undefined; const codeVerifier = req.body.code_verifier as string | undefined; if (!clientId || !code || !redirectUri || (!clientSecret && !codeVerifier)) { res.status(400).json({ error: 'invalid_request' }); return; } const token = await exchangeCodeForToken(clientId, clientSecret, code, redirectUri, codeVerifier); if (!token) { res.status(400).json({ error: 'invalid_grant' }); return; } res.json({ access_token: token.access_token, token_type: token.token_type, expires_in: token.expires_in, scope: token.scope, }); }); // ── 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 `Connection failed

    Connection failed

    ${opts.error}

    `; } 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, '&').replace(//g, '>'); return ` MCP Client Connected — SquareMCP

    MCP Client Connected!

    Copy your access token and the config for your MCP client below.

    Your Access Token

    ${esc(token!)}

    Store this securely — it won't be shown again.

    Claude Desktop claude_desktop_config.json

    ${esc(claudeConfig)}

    Codex CLI / opencode config

    ${esc(codexConfig)}
    `; } // ── TikTok Login Kit + Content Posting auth flow ─────────────────────────── app.get('/auth/tiktok/start', async (req, res) => { if (!TIKTOK_CLIENT_KEY) { res.status(500).send(renderTikTokCallbackHtml({ title: 'TikTok login is not configured', status: 'error', message: 'SquareMCP is missing the TikTok client configuration required to start Login Kit.', details: ['Set TIKTOK_CLIENT_KEY before using this route.'], })); return; } const state = req.query.state as string | undefined; const scope = req.query.scope as string | undefined; res.redirect(getTikTokAuthorizeUrl(state, scope || TIKTOK_DEFAULT_SCOPE)); }); app.get('/auth/tiktok/callback', async (req, res) => { const code = req.query.code as string | undefined; const state = req.query.state as string | undefined; const error = req.query.error as string | undefined; const errorDescription = req.query.error_description as string | undefined; if (error) { res .status(400) .send( renderTikTokCallbackHtml({ title: 'TikTok authorization failed', status: 'error', message: errorDescription || error, details: state ? [`State: ${state}`] : undefined, }) ); return; } if (!code) { res .status(400) .send( renderTikTokCallbackHtml({ title: 'TikTok authorization failed', status: 'error', message: 'TikTok did not provide an authorization code.', }) ); return; } if (!TIKTOK_CLIENT_KEY || !TIKTOK_CLIENT_SECRET) { res .status(500) .send( renderTikTokCallbackHtml({ title: 'TikTok login is not configured', status: 'error', message: 'SquareMCP received the callback, but the TikTok client credentials are missing on the server.', details: [ 'Set TIKTOK_CLIENT_KEY and TIKTOK_CLIENT_SECRET on the Hermes deployment.', `Redirect URI configured: ${TIKTOK_REDIRECT_URI}`, ], }) ); return; } try { const body = new URLSearchParams({ client_key: TIKTOK_CLIENT_KEY, client_secret: TIKTOK_CLIENT_SECRET, code, grant_type: 'authorization_code', redirect_uri: TIKTOK_REDIRECT_URI, }); const tokenRes = await fetch('https://open.tiktokapis.com/v2/oauth/token/', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: body.toString(), signal: AbortSignal.timeout(15000), }); const tokenJson = await tokenRes.json() as Record; const tokenError = tokenJson.error as string | undefined; const tokenErrorDescription = (tokenJson.error_description as string | undefined) || (tokenJson.description as string | undefined) || (tokenJson.message as string | undefined); if (!tokenRes.ok || tokenError) { res .status(400) .send( renderTikTokCallbackHtml({ title: 'TikTok token exchange failed', status: 'error', message: tokenErrorDescription || tokenError || `Token endpoint returned HTTP ${tokenRes.status}.`, details: [ `Redirect URI used: ${TIKTOK_REDIRECT_URI}`, ...(state ? [`State: ${state}`] : []), ], }) ); return; } const accessToken = String(tokenJson.access_token ?? ''); const expiresIn = String(tokenJson.expires_in ?? ''); const openId = String(tokenJson.open_id ?? ''); const scope = String(tokenJson.scope ?? ''); res .status(200) .send( renderTikTokCallbackHtml({ title: 'TikTok account connected', status: 'success', message: 'SquareMCP successfully completed the TikTok Login Kit callback and exchanged the authorization code for a user access token.', details: [ `Open ID: ${openId || 'not returned'}`, `Scopes: ${scope || 'not returned'}`, `Expires in: ${expiresIn || 'not returned'} seconds`, `Access token: ${accessToken}`, 'Copy this token and paste it to your AI assistant to store it for future TikTok API calls.', ...(state ? [`State: ${state}`] : []), ], }) ); } catch (err) { res .status(500) .send( renderTikTokCallbackHtml({ title: 'TikTok token exchange failed', status: 'error', message: err instanceof Error ? err.message : 'Unknown error exchanging the TikTok authorization code.', details: [`Redirect URI used: ${TIKTOK_REDIRECT_URI}`], }) ); } }); // ── Streamable HTTP transport (MCP 1.x standard) ──────────────────────────── const httpTransports = new Map(); const sessionCustomers = new Map(); async function createSession(customer?: Customer): Promise { 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(customer); await server.connect(transport); return transport; } 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)) { console.log(`[mcp] Reusing existing session ${sessionId}`); transport = httpTransports.get(sessionId)!; } else if (isInitializeRequest(req.body)) { if (sessionId) { console.warn(`[mcp] Stale session ${sessionId} re-initializing — pod may have restarted`); } console.log(`[mcp] Creating new session`); 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(reqCustomer); } try { await transport.handleRequest(req, res, req.body); console.log(`[mcp] Request handled successfully`); } catch (err) { console.error(`[mcp] Error handling request:`, err); throw err; } }); app.get('/mcp', requireAuth, async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId || !httpTransports.has(sessionId)) { res.status(400).json({ error: 'No active session' }); return; } await httpTransports.get(sessionId)!.handleRequest(req, res); }); app.delete('/mcp', requireAuth, async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (sessionId && httpTransports.has(sessionId)) { httpTransports.delete(sessionId); } res.status(200).end(); }); // ── LEGACY: SSE transport ────────────────────────────────────────────────── const sseTransports = new Map(); 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 sseCustomer = (req as express.Request & { customer?: Customer }).customer; const server = createMcpServer(sseCustomer); await server.connect(transport); }); app.post('/messages', requireAuth, async (req, res) => { const sessionId = req.query.sessionId as string; const transport = sseTransports.get(sessionId); if (!transport) { res.status(400).json({ error: 'No active SSE session' }); return; } await transport.handlePostMessage(req, res); }); // ── Tool manifest endpoint ────────────────────────────────────────────────── app.get('/tools', requireAuth, (_req, res) => { res.json(getManifest(SERVER_URL, !!API_KEY)); }); // ── ChatGPT MCP connector proxy ───────────────────────────────────────────── // ChatGPT routes MCP tool calls as: POST /hermes-mcp/link_{id}/{toolName} app.post('/hermes-mcp/:linkSegment/:toolName', requireAuth, async (req, res) => { const { toolName } = req.params; const args = (req.body ?? {}) as Record; console.log(`[chatgpt-mcp] ${toolName}`, JSON.stringify(args).substring(0, 200)); try { const result = await callTool(req, toolName, args); const text = result.content[0].text; if (text.startsWith('Error:')) { res.status(400).json({ error: text.slice(7).trim() }); return; } try { res.json(JSON.parse(text)); } catch { res.json({ result: text }); } } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.get('/hermes-mcp/:linkSegment/:toolName', requireAuth, async (req, res) => { const { toolName } = req.params; const args = req.query as Record; console.log(`[chatgpt-mcp] GET ${toolName}`, JSON.stringify(args).substring(0, 200)); try { const result = await callTool(req, toolName, args); const text = result.content[0].text; if (text.startsWith('Error:')) { res.status(400).json({ error: text.slice(7).trim() }); return; } try { res.json(JSON.parse(text)); } catch { res.json({ result: text }); } } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); // ── ChatGPT plugin discovery ──────────────────────────────────────────────── app.get('/.well-known/ai-plugin.json', (_req, res) => { res.json({ schema_version: 'v1', name_for_human: 'Hermes', name_for_model: 'hermes', description_for_human: 'Access your Obsidian vault notes and email accounts.', description_for_model: 'Hermes provides read/write access to an Obsidian markdown vault (search, read, append, overwrite notes) and email operations across multiple accounts. Always use exact relative vault paths returned by search when reading or writing notes.', auth: { type: 'oauth', client_url: `${SERVER_URL}/oauth/authorize`, scope: 'obsidian email', authorization_url: `${SERVER_URL}/oauth/token`, authorization_content_type: 'application/x-www-form-urlencoded', verification_tokens: {}, }, api: { type: 'openapi', url: `${SERVER_URL}/openapi.json`, }, contact_email: 'garfield@fetcherpay.com', legal_info_url: 'https://squaremcp.com/privacy', }); }); app.get('/openapi.json', (_req, res) => { res.json(getOpenApiSpec(SERVER_URL)); }); app.get('/openapi-mail.json', (_req, res) => { res.json(getOpenApiSpecMail(SERVER_URL)); }); app.get('/openapi-social.json', (_req, res) => { res.json(getOpenApiSpecSocial(SERVER_URL)); }); app.get('/openapi-chatgpt.json', (_req, res) => { res.json(getOpenApiSpecChatGPT(SERVER_URL)); }); // ── Obsidian REST API (ChatGPT Actions) ──────────────────────────────────── function parseToolResult(result: { content: Array<{ type: string; text: string }> }): unknown { const text = result.content[0].text; if (text.startsWith('Error:')) throw new Error(text.slice(7).trim()); try { return JSON.parse(text); } catch { return text; } } app.get('/api/obsidian/search', requireAuth, async (req, res) => { const query = req.query.query as string | undefined; if (!query) { res.status(400).json({ error: 'query is required' }); return; } const limit = req.query.limit ? Number(req.query.limit) : 10; const path_filter = req.query.path_filter as string | undefined; const tagsRaw = req.query.tags as string | undefined; const tags = tagsRaw ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean) : undefined; try { const result = await callTool(req, 'obsidian_search_notes', { query, limit, path_filter, tags }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.get('/api/obsidian/note', requireAuth, async (req, res) => { const path = req.query.path as string | undefined; if (!path) { res.status(400).json({ error: 'path is required' }); return; } try { const result = await callTool(req, 'obsidian_read_note', { path }); res.json(parseToolResult(result)); } catch (err) { const msg = (err as Error).message; res.status(msg.toLowerCase().includes('not found') ? 404 : 500).json({ error: msg }); } }); app.post('/api/obsidian/note/append', requireAuth, async (req, res) => { const { path, content, header, create_if_missing } = req.body as Record; if (!path || !content) { res.status(400).json({ error: 'path and content are required' }); return; } try { const result = await callTool(req, 'obsidian_append_to_note', { path, content, header, create_if_missing }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.put('/api/obsidian/note', requireAuth, async (req, res) => { const { path, content } = req.body as Record; if (!path || !content) { res.status(400).json({ error: 'path and content are required' }); return; } try { const result = await callTool(req, 'obsidian_update_note', { path, content }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.get('/api/obsidian/sync', requireAuth, async (req, res) => { try { const result = await callTool(req, 'obsidian_sync_status', {}); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); // ── WhatsApp Business API REST endpoints ────────────────────────── app.post('/api/whatsapp/send', requireAuth, async (req, res) => { const { to, message, account } = req.body as Record; if (!to || !message) { res.status(400).json({ error: 'to and message are required' }); return; } try { const result = await callTool(req, 'whatsapp_send_message', { to, message, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.post('/api/whatsapp/template', requireAuth, async (req, res) => { const { to, template_name, language, components, account } = req.body as Record; if (!to || !template_name) { res.status(400).json({ error: 'to and template_name are required' }); return; } try { const result = await callTool(req, 'whatsapp_send_template', { to, template_name, language, components, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.get('/api/whatsapp/templates', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; try { const result = await callTool(req, 'whatsapp_list_templates', { account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); // ── WhatsApp webhook (multi-tenant) ───────────────────────────── async function handleInboundWhatsAppMessage(event: RoutedWebhookEvent): Promise { console.log(`[webhook/whatsapp] inbound message from=${event.message.from} customer=${event.customerId} type=${event.message.type}`); // 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).text ?? null, timestamp: event.message.timestamp, }).catch((err) => console.error('[webhook/whatsapp] delivery error:', err)); } // WhatsApp webhook verification (GET) app.get('/webhook/whatsapp', (req, res) => { const mode = req.query['hub.mode']; const token = req.query['hub.verify_token']; const challenge = req.query['hub.challenge']; if (mode === 'subscribe' && token === process.env.WA_VERIFY_TOKEN) { res.status(200).send(challenge); } else { res.status(403).send('Forbidden'); } }); // WhatsApp webhook delivery (POST) — raw body preserved for HMAC verification const WHATSAPP_APP_SECRET = process.env.WHATSAPP_APP_SECRET ?? ''; app.post('/webhook/whatsapp', express.raw({ type: '*/*' }), async (req, res) => { // Verify Meta HMAC signature when app secret is configured if (WHATSAPP_APP_SECRET) { const sig = req.headers['x-hub-signature-256'] as string | undefined; if (!sig) { res.status(403).send('Missing signature'); return; } const expected = 'sha256=' + crypto.createHmac('sha256', WHATSAPP_APP_SECRET).update(req.body as Buffer).digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { res.status(403).send('Invalid signature'); return; } } // Always acknowledge immediately to prevent Meta retries (20s window) res.status(200).send('EVENT_RECEIVED'); try { const body = JSON.parse((req.body as Buffer).toString('utf8')) as Record; const events = await routeWhatsAppWebhook(body); for (const event of events) { await handleInboundWhatsAppMessage(event); } } catch (err) { console.error('[webhook/whatsapp] routing error:', err); } }); // ── Twilio voice webhook — reject calls on the WhatsApp number ── app.post('/webhook/twilio/voice', (_req, res) => { res.type('text/xml').send( '' + 'This number does not accept voice calls. Please visit squaremcp.com for support.' ); }); // ── Auth endpoints ────────────────────────────────────────────── app.post('/api/auth/signup', express.json(), async (req, res) => { const { email, password } = req.body as Record; if (!email || !password) { res.status(400).json({ error: 'Email and password required' }); return; } if (password.length < 8) { res.status(400).json({ error: 'Password must be at least 8 characters' }); return; } const existing = await findCustomerByEmail(email); if (existing) { res.status(409).json({ error: 'Email already registered' }); return; } const id = crypto.randomUUID(); const apiKey = crypto.randomUUID().replace(/-/g, ''); const passwordHash = await hashPassword(password); try { // Check if this is the first user — make them admin const [countRows] = await getPool().query('SELECT COUNT(*) as c FROM customers'); const isFirstUser = countRows[0]?.c === 0; await createCustomer(id, email, passwordHash, apiKey); if (isFirstUser) { await getPool().query("UPDATE customers SET role = 'admin', plan = 'enterprise' WHERE id = ?", [id]); } 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: 'lax', domain: '.squaremcp.com', maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days }); res.status(201).json({ id, email, plan, role, api_key: apiKey }); } catch (err) { res.status(500).json({ error: 'Failed to create account' }); } }); app.post('/api/auth/login', express.json(), async (req, res) => { if (!await checkRateLimit(req, res, 10, 900)) return; // 10 attempts per 15 min per IP const { email, password } = req.body as Record; if (!email || !password) { res.status(400).json({ error: 'Email and password required' }); return; } const customer = await findCustomerByEmail(email); if (!customer || !customer.password_hash) { res.status(401).json({ error: 'Invalid credentials' }); return; } const valid = await verifyPassword(password, customer.password_hash); if (!valid) { res.status(401).json({ error: 'Invalid credentials' }); 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: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000, }); 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', { 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; role?: string } }).jwtUser; if (jwtUser) { res.json(jwtUser); return; } res.status(401).json({ error: 'Not authenticated' }); }); // ── Customer onboarding endpoints ─────────────────────────────── // Connect WhatsApp — called after customer enters their Meta credentials app.post('/api/connect/whatsapp', meterMiddleware, async (req, res) => { const customer = (req as unknown as { customer: Customer }).customer; const { phoneNumberId, accessToken, businessAccountId } = req.body as Record; if (!phoneNumberId || !accessToken || !businessAccountId) { res.status(400).json({ error: 'missing_fields' }); return; } await storeCredential(customer.id, 'whatsapp', { phoneNumberId, accessToken, businessAccountId }); await registerWhatsAppNumber(customer.id, phoneNumberId); res.json({ connected: true, platform: 'whatsapp' }); }); // Connect email (IMAP/SMTP) app.post('/api/connect/email', meterMiddleware, async (req, res) => { const customer = (req as unknown as { customer: Customer }).customer; const { host, port, user, password, smtpHost, smtpPort } = req.body as Record; if (!host || !port || !user || !password) { res.status(400).json({ error: 'missing_fields' }); return; } await storeCredential(customer.id, 'email', { host, port: parseInt(port, 10), user, password, smtpHost, smtpPort: smtpPort ? parseInt(smtpPort, 10) : undefined, }); res.json({ connected: true, platform: 'email' }); }); // Connect OAuth platforms (LinkedIn, Telegram, Discord, Instagram, Twitter) app.post('/api/connect/:platform', meterMiddleware, async (req, res) => { const customer = (req as unknown as { customer: Customer }).customer; const platform = req.params.platform as Platform; const { accessToken, refreshToken, expiresAt, scope, pageId } = req.body as Record; const validPlatforms: Platform[] = ['linkedin', 'telegram', 'discord', 'instagram', 'twitter', 'tiktok', 'snapchat', 'facebook']; if (!validPlatforms.includes(platform)) { res.status(400).json({ error: 'unknown_platform' }); return; } if (!accessToken) { res.status(400).json({ error: 'missing_fields' }); return; } const credentials: { accessToken: string; refreshToken?: string; expiresAt?: number; scope?: string; pageId?: string; } = { accessToken, refreshToken, expiresAt: expiresAt ? parseInt(expiresAt, 10) : undefined, scope, }; if (platform === 'facebook') { if (!pageId) { res.status(400).json({ error: 'page_id_required' }); return; } credentials.pageId = pageId; } await storeCredential(customer.id, platform, credentials); res.json({ connected: true, platform }); }); // Get connection status for a customer 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 results = await Promise.all(platforms.map((p) => customer.getCredential(p))); const status: Record = 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( '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).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) => { const customer = (req as unknown as { customer: Customer }).customer; const used = await getMonthlyUsage(customer.id); const breakdown = await getUsageBreakdown(customer.id); const limitCheck = await checkLimit(customer.id, customer.plan); res.json({ plan: customer.plan, monthlyLimit: limitCheck.limit, used, remaining: limitCheck.limit === -1 ? -1 : Math.max(0, limitCheck.limit - used), breakdown, }); }); app.get('/api/usage/daily', meterMiddleware, async (req, res) => { const customer = (req as unknown as { customer: Customer }).customer; const [rows] = await getPool().query( `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) => { const customer = (req as unknown as { customer: Customer }).customer; const invoices = await getCustomerInvoices(customer.id); res.json({ invoices }); }); 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 || invoice.customer_id !== customer?.id) { res.status(404).json({ error: 'Invoice not found' }); return; } res.json(invoice); }); // ── Password Reset ────────────────────────────────────────────── app.post('/api/auth/forgot-password', express.json(), async (req, res) => { if (!await checkRateLimit(req, res, 5, 3600)) return; // 5 per hour per IP const { email } = req.body as Record; if (!email) { res.status(400).json({ error: 'Email required' }); return; } const token = crypto.randomUUID().replace(/-/g, ''); const success = await setResetToken(email, token); // Always return the same message to avoid email enumeration res.json({ message: 'If an account exists, a reset link has been sent.' }); if (success) { const resetUrl = `https://app.squaremcp.com/reset-password?token=${token}`; sendEmail( email, 'Reset your SquareMCP password', `Click the link below to reset your password. This link expires in 1 hour.\n\n${resetUrl}\n\nIf you didn't request this, you can ignore this email.`, 'sqcp_info', ).catch(err => console.error('[auth] reset email send error:', err)); } }); app.post('/api/auth/reset-password', express.json(), async (req, res) => { const { token, password } = req.body as Record; if (!token || !password) { res.status(400).json({ error: 'Token and password required' }); return; } if (password.length < 8) { res.status(400).json({ error: 'Password must be at least 8 characters' }); return; } const customer = await findCustomerByResetToken(token); if (!customer) { res.status(400).json({ error: 'Invalid or expired reset token' }); return; } const passwordHash = await hashPassword(password); await updatePassword(customer.id, passwordHash); await clearResetToken(customer.id); res.json({ message: 'Password updated successfully.' }); }); // ── Admin Endpoints ───────────────────────────────────────────── function callTool(req: express.Request, name: string, args: Record) { const customer = (req as express.Request & { customer?: Customer }).customer; return handleToolCall(name, args, customer); } async function requireAdmin(req: express.Request, res: express.Response, next: express.NextFunction) { // Global API key = superadmin access const apiKeyProvided = (req.headers['x-api-key'] as string | undefined) || (req.query.key as string | undefined); if (apiKeyProvided === API_KEY) return next(); // Check JWT first const jwtCookie = req.cookies?.session; let customerId: string | null = null; if (jwtCookie) { try { const payload = verifyJWT(jwtCookie); customerId = payload.sub; } catch { // fall through } } // Check customer API key if (!customerId && apiKeyProvided) { const customer = await resolveCustomerByApiKey(apiKeyProvided); if (customer) customerId = customer.id; } if (!customerId) { res.status(401).json({ error: 'Unauthorized' }); return; } const [rows] = await getPool().query('SELECT role FROM customers WHERE id = ?', [customerId]); if (!rows.length || rows[0].role !== 'admin') { res.status(403).json({ error: 'Admin access required' }); return; } next(); } app.get('/api/admin/customers', requireAdmin, async (req, res) => { const [rows] = await getPool().query( 'SELECT id, email, plan, active, role, created_at FROM customers ORDER BY created_at DESC' ); res.json({ customers: rows }); }); app.get('/api/admin/customers/:id/usage', requireAdmin, async (req, res) => { const customerId = req.params.id; const used = await getMonthlyUsage(customerId); const breakdown = await getUsageBreakdown(customerId); res.json({ customerId, used, breakdown }); }); app.post('/api/admin/customers/:id/invoice', requireAdmin, async (req, res) => { const customerId = req.params.id; const invoice = await generateMonthlyInvoice(customerId); if (!invoice) { res.status(400).json({ error: 'No usage to invoice' }); return; } res.json(invoice); }); app.post('/api/admin/invoices/:number/send', requireAdmin, async (req, res) => { await markInvoiceSent(req.params.number); res.json({ sent: true }); }); app.post('/api/admin/invoices/:number/pay', requireAdmin, async (req, res) => { await markInvoicePaid(req.params.number); 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; try { const result = await callTool(req, 'linkedin_get_profile', { account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.post('/api/linkedin/post', requireAuth, async (req, res) => { const { text, visibility, account } = req.body as Record; if (!text) { res.status(400).json({ error: 'text is required' }); return; } try { const result = await callTool(req, 'linkedin_create_post', { text, visibility, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.post('/api/linkedin/video', requireAuth, async (req, res) => { try { const { video_url, text, visibility, account } = req.body as Record; const result = await callTool(req, 'linkedin_upload_video', { video_url, text, visibility, account }); res.json(result); } catch (err) { res.status(500).json({ error: String(err) }); } }); app.post('/api/linkedin/search-connections', requireAuth, async (req, res) => { const { keywords, account } = req.body as Record; try { const result = await callTool(req, 'linkedin_search_connections', { keywords, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.post('/api/linkedin/message', requireAuth, async (req, res) => { const { recipient_id, message, account } = req.body as Record; if (!recipient_id || !message) { res.status(400).json({ error: 'recipient_id and message are required' }); return; } try { const result = await callTool(req, 'linkedin_send_message', { recipient_id, message, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); // ── Telegram REST endpoints ───────────────────────────────────── app.get('/api/telegram/me', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; try { const result = await callTool(req, 'telegram_get_me', { account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.post('/api/telegram/message', requireAuth, async (req, res) => { const { chat_id, text, parse_mode, account } = req.body as Record; if (!chat_id || !text) { res.status(400).json({ error: 'chat_id and text are required' }); return; } try { const result = await callTool(req, 'telegram_send_message', { chat_id, text, parse_mode, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.post('/api/telegram/photo', requireAuth, async (req, res) => { const { chat_id, photo, caption, account } = req.body as Record; if (!chat_id || !photo) { res.status(400).json({ error: 'chat_id and photo are required' }); return; } try { const result = await callTool(req, 'telegram_send_photo', { chat_id, photo, caption, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.get('/api/telegram/updates', requireAuth, async (req, res) => { const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined; const account = req.query.account as string | undefined; try { const result = await callTool(req, 'telegram_get_updates', { limit, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.get('/api/telegram/chat', requireAuth, async (req, res) => { const chat_id = req.query.chat_id as string | undefined; const account = req.query.account as string | undefined; if (!chat_id) { res.status(400).json({ error: 'chat_id is required' }); return; } try { const result = await callTool(req, 'telegram_get_chat', { chat_id, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); // ── Discord REST endpoints ────────────────────────────────────── app.get('/api/discord/me', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; try { const result = await callTool(req, 'discord_get_me', { account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.get('/api/discord/guilds', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; try { const result = await callTool(req, 'discord_get_guilds', { account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.get('/api/discord/channels', requireAuth, async (req, res) => { const guild_id = req.query.guild_id as string | undefined; const account = req.query.account as string | undefined; if (!guild_id) { res.status(400).json({ error: 'guild_id is required' }); return; } try { const result = await callTool(req, 'discord_get_channels', { guild_id, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.post('/api/discord/message', requireAuth, async (req, res) => { const { channel_id, content, account } = req.body as Record; if (!channel_id || !content) { res.status(400).json({ error: 'channel_id and content are required' }); return; } try { const result = await callTool(req, 'discord_send_message', { channel_id, content, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.get('/api/discord/messages', requireAuth, async (req, res) => { const channel_id = req.query.channel_id as string | undefined; const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined; const account = req.query.account as string | undefined; if (!channel_id) { res.status(400).json({ error: 'channel_id is required' }); return; } try { const result = await callTool(req, 'discord_get_messages', { channel_id, limit, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); // ── Instagram REST endpoints ──────────────────────────────────── app.get('/api/instagram/profile', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; try { const result = await callTool(req, 'instagram_get_profile', { account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.get('/api/instagram/media', requireAuth, async (req, res) => { const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined; const account = req.query.account as string | undefined; try { const result = await callTool(req, 'instagram_get_media', { limit, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.post('/api/instagram/post', requireAuth, async (req, res) => { const { image_url, caption, account } = req.body as Record; if (!image_url) { res.status(400).json({ error: 'image_url is required' }); return; } try { const result = await callTool(req, 'instagram_create_post', { image_url, caption, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.post('/api/instagram/reel', requireAuth, async (req, res) => { const { video_url, caption, account } = req.body as Record; if (!video_url) { res.status(400).json({ error: 'video_url is required' }); return; } try { const result = await callTool(req, 'instagram_create_reel', { video_url, caption, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); // ── Twitter/X REST endpoints ──────────────────────────────────── app.get('/api/twitter/search', requireAuth, async (req, res) => { const query = req.query.query as string | undefined; const max_results = req.query.max_results ? parseInt(req.query.max_results as string, 10) : undefined; const account = req.query.account as string | undefined; if (!query) { res.status(400).json({ error: 'query is required' }); return; } try { const result = await callTool(req, 'twitter_search_tweets', { query, max_results, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.get('/api/twitter/user', requireAuth, async (req, res) => { const username = req.query.username as string | undefined; const account = req.query.account as string | undefined; if (!username) { res.status(400).json({ error: 'username is required' }); return; } try { const result = await callTool(req, 'twitter_get_user_profile', { username, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.get('/api/twitter/tweets', requireAuth, async (req, res) => { const username = req.query.username as string | undefined; const max_results = req.query.max_results ? parseInt(req.query.max_results as string, 10) : undefined; const account = req.query.account as string | undefined; if (!username) { res.status(400).json({ error: 'username is required' }); return; } try { const result = await callTool(req, 'twitter_get_user_tweets', { username, max_results, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.post('/api/twitter/tweet', requireAuth, async (req, res) => { const { text, account } = req.body as Record; if (!text) { res.status(400).json({ error: 'text is required' }); return; } try { const result = await callTool(req, 'twitter_create_tweet', { text, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.post('/api/twitter/video', requireAuth, async (req, res) => { const { video_url, text, account } = req.body as Record; if (!video_url || !text) { res.status(400).json({ error: 'video_url and text are required' }); return; } try { const result = await callTool(req, 'twitter_upload_video', { video_url, text, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); // ── Facebook REST endpoints ───────────────────────────────────── app.get('/api/facebook/page', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; try { const result = await callTool(req, 'facebook_get_page', { account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.get('/api/facebook/posts', requireAuth, async (req, res) => { const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined; const account = req.query.account as string | undefined; try { const result = await callTool(req, 'facebook_get_posts', { limit, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.post('/api/facebook/post', requireAuth, async (req, res) => { const { message, link, account } = req.body as Record; if (!message) { res.status(400).json({ error: 'message is required' }); return; } try { const result = await callTool(req, 'facebook_create_post', { message, link, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.post('/api/facebook/photo', requireAuth, async (req, res) => { const { image_url, caption, account } = req.body as Record; if (!image_url) { res.status(400).json({ error: 'image_url is required' }); return; } try { const result = await callTool(req, 'facebook_create_photo_post', { image_url, caption, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.post('/api/facebook/video', requireAuth, async (req, res) => { const { video_url, description, account } = req.body as Record; if (!video_url) { res.status(400).json({ error: 'video_url is required' }); return; } try { const result = await callTool(req, 'facebook_create_video_post', { video_url, description, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); // ── Chat widget endpoint ──────────────────────────────────────── const ESCALATION_PATTERNS = [ /\b(human|person|agent|someone|anybody|real person)\b/i, /\bspeak\s+to\b|\btalk\s+to\b|\bcontact\s+(you|someone|support|sales)\b/i, /\b(pricing|price|cost|how much|plans?|subscribe|sign\s*up|get\s+started|trial|demo)\b/i, /\b(help me|need help|can you help|support)\b/i, /\bemail\s+(you|garfield|support|sales)\b/i, ]; function detectEscalation(messages: ChatMessage[]): string | null { const lastUser = [...messages].reverse().find(m => m.role === 'user'); if (!lastUser) return null; for (const pattern of ESCALATION_PATTERNS) { const match = lastUser.content.match(pattern); if (match) return match[0]; } return null; } app.post('/api/chat', async (req, res) => { if (!await checkRateLimit(req, res, 30, 3600)) return; // 30 per hour per IP const { messages } = req.body as { messages?: ChatMessage[] }; if (!Array.isArray(messages) || messages.length === 0) { res.status(400).json({ error: 'messages array required' }); return; } try { const { reply, toolsUsed } = await handleChat(messages); res.json({ reply, toolsUsed }); const trigger = detectEscalation(messages); if (trigger) { sendChatEscalationAlert({ trigger, conversation: messages, userAgent: (req as { get: (h: string) => string | undefined }).get('user-agent'), }).catch(err => console.error('[chat] escalation alert error:', err)); } } catch (err) { console.error('[chat] error:', (err as Error).message); res.status(500).json({ error: 'Chat unavailable' }); } }); // ── TikTok REST endpoints ─────────────────────────────────────── app.get('/api/tiktok/profile', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; try { const result = await callTool(req, 'tiktok_get_profile', { account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.get('/api/tiktok/creator-info', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; try { const result = await callTool(req, 'tiktok_get_creator_info', { account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.post('/api/tiktok/video', requireAuth, async (req, res) => { const { video_url, title, description, account } = req.body as Record; if (!video_url) { res.status(400).json({ error: 'video_url is required' }); return; } try { const result = await callTool(req, 'tiktok_create_video', { video_url, title, description, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.post('/api/tiktok/video/status', requireAuth, async (req, res) => { const { publish_id, account } = req.body as Record; if (!publish_id) { res.status(400).json({ error: 'publish_id is required' }); return; } try { const result = await callTool(req, 'tiktok_get_video_status', { publish_id, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); app.post('/api/pilot-request', async (req, res) => { const origin = req.get('origin'); if (origin && !SQUAREMCP_ALLOWED_ORIGINS.has(origin)) { res.status(403).json({ error: 'Origin not allowed' }); return; } const body = getPilotRequestBody((req.body ?? {}) as Record); const validationError = validatePilotRequest(body); if (validationError) { res.status(400).json({ error: validationError }); return; } const requestId = crypto.randomUUID(); console.log( `[squaremcp] pilot_request requestId=${requestId} company=${body.company} email=${body.email} use_case=${body.use_case}` ); try { await appendPilotRequestToVault(requestId, body, req); // Fire-and-forget notification — don't block response notifyNewPilotRequest(body, requestId, req).catch((err) => console.error(`[notification] requestId=${requestId} error:`, err) ); res.status(201).json({ ok: true, request_id: requestId }); } catch (error) { console.error(`[squaremcp] pilot_request ERROR requestId=${requestId}:`, error); res.status(500).json({ error: 'Failed to store pilot request' }); } }); // ── OAuth Discovery (RFC 8414) ───────────────────────────────────────────── const oauthDiscovery = { issuer: SERVER_URL, authorization_endpoint: `${SERVER_URL}/oauth/authorize`, token_endpoint: `${SERVER_URL}/oauth/token`, registration_endpoint: `${SERVER_URL}/oauth/register`, response_types_supported: ['code'], grant_types_supported: ['authorization_code'], token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic', 'none'], code_challenge_methods_supported: ['S256', 'plain'], }; const protectedResourceMetadata = { resource: MCP_RESOURCE_URL, authorization_servers: [SERVER_URL], scopes_supported: ['email', 'obsidian'], bearer_methods_supported: ['header'], resource_documentation: `${SERVER_URL}/tools`, }; app.get('/.well-known/oauth-authorization-server', (_req, res) => { res.json(oauthDiscovery); }); app.get('/.well-known/oauth-protected-resource', (_req, res) => { res.json(protectedResourceMetadata); }); app.get('/.well-known/oauth-protected-resource/mcp', (_req, res) => { res.json(protectedResourceMetadata); }); app.get('/.well-known/openid-configuration', (_req, res) => { res.json({ ...oauthDiscovery, scopes_supported: ['openid', 'email', 'profile'], claims_supported: ['sub', 'iss'], }); }); // ── Health ────────────────────────────────────────────────────────────────── app.get('/health', (_req, res) => { res.json({ status: 'ok', service: 'hermes-mcp', toolCount: tools.length, transports: ['streamable-http', 'sse'], endpoints: ['/mcp', '/sse', '/tools', '/openapi.json', '/health', '/oauth/authorize', '/oauth/token', '/oauth/register', '/.well-known/oauth-authorization-server', '/.well-known/oauth-protected-resource', '/.well-known/ai-plugin.json', '/api/obsidian/search', '/api/obsidian/note', '/api/obsidian/note/append', '/api/obsidian/sync'], }); }); 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', ]); } startEmailPoller(); app.listen(PORT, () => { console.log(`Hermes MCP server running on port ${PORT}`); console.log(` Streamable HTTP: ${SERVER_URL}/mcp`); console.log(` SSE (legacy): ${SERVER_URL}/sse`); console.log(` Tools manifest: ${SERVER_URL}/tools`); console.log(` Health: ${SERVER_URL}/health`); console.log(` OAuth authorize: ${SERVER_URL}/oauth/authorize`); console.log(` OAuth token: ${SERVER_URL}/oauth/token`); console.log(` OAuth register: ${SERVER_URL}/oauth/register`); console.log(` OAuth discovery: ${SERVER_URL}/.well-known/oauth-authorization-server`); console.log(` Resource meta: ${SERVER_URL}/.well-known/oauth-protected-resource`); console.log(` OIDC discovery: ${SERVER_URL}/.well-known/openid-configuration`); if (API_KEY) { console.log(` Auth: API key + OAuth Bearer tokens accepted`); } else { console.warn(` Auth: NO API KEY SET — consider setting MCP_API_KEY`); } }); } export { app }; if (process.env.NODE_ENV !== 'test') { main().catch((err) => { console.error('Failed to start server:', err); process.exit(1); }); }