Files
hermes-mcp/src/index.ts
Garfield 1f8d97b6bd fix: serve chat-widget.js correctly in production
- Copy product/ into Docker image (was missing from final stage)
- Fix static file path: ../../product → ../product (wrong depth from dist/index.js)
- Add ANTHROPIC_API_KEY to K8s manifest (chat endpoint was returning 500)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 18:45:43 -04:00

2184 lines
82 KiB
TypeScript

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 } 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';
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());
app.use(express.urlencoded({ extended: true }));
// ── 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function getPilotRequestBody(body: Record<string, unknown>): 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<keyof PilotRequestBody> = [
'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) => `<li>${escapeHtml(line)}</li>`)
.join('');
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(args.title)}</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at top, rgba(14,99,246,0.18), transparent 30%), #09090f;
color: #fff;
font-family: Inter, system-ui, sans-serif;
padding: 24px;
}
.card {
width: min(720px, 100%);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 28px;
background: rgba(18,18,31,0.94);
box-shadow: 0 30px 80px rgba(0,0,0,0.35);
padding: 32px;
}
.eyebrow {
color: ${accent};
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 12px;
}
h1 {
margin: 0 0 12px;
font-size: 34px;
line-height: 1.05;
}
p {
margin: 0;
color: #cbd5e1;
font-size: 17px;
line-height: 1.55;
}
ul {
margin: 24px 0 0;
padding-left: 20px;
color: #e2e8f0;
line-height: 1.6;
}
code {
color: #93c5fd;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
</style>
</head>
<body>
<main class="card">
<div class="eyebrow">TikTok Login Kit</div>
<h1>${escapeHtml(args.title)}</h1>
<p>${escapeHtml(args.message)}</p>
${details ? `<ul>${details}</ul>` : ''}
</main>
</body>
</html>`;
}
// ── 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<string, unknown>,
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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in — SquareMCP</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f8fafc;display:flex;align-items:center;justify-content:center;min-height:100vh}
.card{background:#fff;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.08);padding:40px;width:100%;max-width:400px}
.logo{text-align:center;margin-bottom:28px;font-size:22px;font-weight:700;color:#1a1a2e}
.logo span{color:#6366f1}
h1{font-size:20px;font-weight:600;color:#1a1a2e;margin-bottom:6px;text-align:center}
.sub{font-size:14px;color:#64748b;text-align:center;margin-bottom:28px}
label{font-size:14px;font-weight:500;color:#374151;display:block;margin-bottom:6px}
input{width:100%;padding:10px 14px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:15px;outline:none;transition:border-color .2s}
input:focus{border-color:#6366f1}
.field{margin-bottom:18px}
button{width:100%;padding:12px;background:#6366f1;color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;transition:background .2s}
button:hover{background:#4f46e5}
.error{color:#dc2626;font-size:13px;text-align:center;margin-bottom:16px}
</style>
</head>
<body>
<div class="card">
<div class="logo">Square<span>MCP</span></div>
<h1>Sign in to continue</h1>
<p class="sub">Connect your SquareMCP account to authorize access.</p>
${errMsg ? `<p class="error">${errMsg}</p>` : ''}
<form method="POST" action="/login">
<input type="hidden" name="return_to" value="${safeReturnTo.replace(/"/g, '&quot;')}">
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" name="email" required autocomplete="email" placeholder="you@example.com">
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password" placeholder="••••••••">
</div>
<button type="submit">Sign in</button>
</form>
</div>
</body>
</html>`);
});
app.post('/login', express.urlencoded({ extended: false }), async (req, res) => {
const { email, password, return_to } = req.body as Record<string, string>;
const safeReturnTo = return_to && return_to.startsWith('https://hermes.squaremcp.com/')
? return_to
: '/';
if (!email || !password) {
res.redirect(`/login?return_to=${encodeURIComponent(safeReturnTo)}&error=missing`);
return;
}
const customer = await findCustomerByEmail(email);
if (!customer || !customer.password_hash) {
res.redirect(`/login?return_to=${encodeURIComponent(safeReturnTo)}&error=invalid`);
return;
}
const valid = await verifyPassword(password, customer.password_hash);
if (!valid) {
res.redirect(`/login?return_to=${encodeURIComponent(safeReturnTo)}&error=invalid`);
return;
}
const token = signJWT({ sub: customer.id, email: customer.email, plan: customer.plan, role: customer.role });
res.cookie('session', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
domain: '.squaremcp.com',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.redirect(safeReturnTo);
});
// Authorization endpoint: GET shows consent form, POST handles approval
app.get('/oauth/authorize', async (req, res) => {
const clientId = req.query.client_id as string | undefined;
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 `<!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) {
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<string, unknown>;
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<string, StreamableHTTPServerTransport>();
const sessionCustomers = new Map<string, Customer>();
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(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<string, SSEServerTransport>();
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<string, unknown>;
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<string, unknown>;
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));
});
// ── 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<void> {
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<string, unknown>).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
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 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);
}
} catch (err) {
console.error('[webhook/whatsapp] routing error:', err);
}
});
// ── Auth endpoints ──────────────────────────────────────────────
app.post('/api/auth/signup', express.json(), async (req, res) => {
const { email, password } = req.body as Record<string, string>;
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<any[]>('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) => {
const { email, password } = req.body as Record<string, string>;
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<string, string>;
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<string, string>;
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<string, string>;
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<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) => {
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<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) => {
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) => {
const { email } = req.body as Record<string, string>;
if (!email) {
res.status(400).json({ error: 'Email required' });
return;
}
const token = crypto.randomUUID().replace(/-/g, '');
const success = await setResetToken(email, token);
if (!success) {
// Don't reveal if email exists
res.json({ message: 'If an account exists, a reset link has been sent.' });
return;
}
// In production, send email here. For now, return the token in dev mode.
const resetUrl = `https://app.squaremcp.com/reset-password?token=${token}`;
res.json({
message: 'Password reset link generated.',
resetUrl,
token,
});
});
app.post('/api/auth/reset-password', express.json(), async (req, res) => {
const { token, password } = req.body as Record<string, string>;
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<string, unknown>) {
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<any[]>('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<any[]>(
'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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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 ────────────────────────────────────────
app.post('/api/chat', async (req, res) => {
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 });
} 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<string, unknown>;
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<string, unknown>;
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<string, unknown>);
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',
]);
}
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);
});
}