Files
hermes-mcp/src/index.ts
Garfield ffb67560b9 feat: Facebook Page integration
Four tools: facebook_get_page, facebook_get_posts, facebook_create_post
(text + optional link), facebook_create_photo_post (image URL + caption).

Uses Graph API v19.0 with Page access token. Credentials stored per-customer
in Redis under creds:{id}:facebook with pageId alongside the access token.
Env-var fallback: FACEBOOK_{ACCOUNT}_ACCESS_TOKEN + FACEBOOK_{ACCOUNT}_PAGE_ID.

Wired into Platform type, validPlatforms, /api/connections, manifest OpenAPI
spec, and manifest tool registry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 12:47:20 -04:00

1045 lines
40 KiB
TypeScript

import 'dotenv/config';
import express from 'express';
import cors from 'cors';
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 } from './tools.js';
import { getManifest, getOpenApiSpec } from './manifest.js';
import { routeWhatsAppWebhook, registerWhatsAppNumber, type RoutedWebhookEvent } from './multitenancy/webhook-router.js';
import { storeCredential, type Platform } from './multitenancy/credential-store.js';
import { meterMiddleware, type Customer } from './billing/middleware.js';
import {
registerClient,
getClient,
createAuthCode,
exchangeCodeForToken,
validateAccessToken,
getAuthorizeHtml,
} from './oauth.js';
import { initDatabase } from './db.js';
const app = express();
app.use(cors({
origin: '*',
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 }));
// ── 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',
]);
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 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 handleToolCall('obsidian_append_to_note', {
path: 'SquareMCP/Pilot Requests.md',
header: 'Pilot Requests',
content,
create_if_missing: true,
});
await handleToolCall('obsidian_append_to_note', {
path: dailyNotePath,
header: 'SquareMCP Pilot Requests',
content,
create_if_missing: 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;
}
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)
const apiKeyProvided = (req.headers['x-api-key'] as string | undefined) || (req.query.key as string | undefined);
if (apiKeyProvided === API_KEY) return next();
// 2. Check OAuth Bearer token
const bearerToken = extractBearerToken(req);
if (bearerToken && await validateAccessToken(bearerToken)) return next();
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, or Bearer token' });
} 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() {
const server = new Server(
{ name: 'hermes', version: '1.0.0' },
{ capabilities: { tools: {}, resources: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
return handleToolCall(
request.params.name,
(request.params.arguments ?? {}) as Record<string, unknown>
);
});
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',
});
});
// 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 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;
}
res.setHeader('Content-Type', 'text/html');
res.send(getAuthorizeHtml({ client_id: clientId, redirect_uri: redirectUri, state, scope }));
});
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 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 (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;
}
const code = await createAuthCode(clientId, redirectUri, scope);
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 clientId = req.body.client_id as string | undefined;
const clientSecret = req.body.client_secret as string | undefined;
const code = req.body.code as string | undefined;
const redirectUri = req.body.redirect_uri as string | undefined;
if (!clientId || !clientSecret || !code || !redirectUri) {
res.status(400).json({ error: 'invalid_request' });
return;
}
const token = await exchangeCodeForToken(clientId, clientSecret, code, redirectUri);
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,
});
});
// ── Streamable HTTP transport (MCP 1.x standard) ────────────────────────────
const httpTransports = new Map<string, StreamableHTTPServerTransport>();
async function createSession(): Promise<StreamableHTTPServerTransport> {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (id) => {
console.log(`[mcp] Session initialized: ${id}`);
httpTransports.set(id, transport);
},
});
transport.onclose = () => {
if (transport.sessionId) {
console.log(`[mcp] Session closed: ${transport.sessionId}`);
httpTransports.delete(transport.sessionId);
}
};
const server = createMcpServer();
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)}`);
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();
} 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();
}
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 server = createMcpServer();
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 handleToolCall(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 handleToolCall(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));
});
// ── 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 handleToolCall('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 handleToolCall('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 handleToolCall('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 handleToolCall('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 handleToolCall('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 handleToolCall('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 handleToolCall('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 handleToolCall('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}`);
// Future: route to customer's agent or queue for processing
}
// 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) — multi-tenant routed
app.post('/webhook/whatsapp', express.json(), async (req, res) => {
// Always acknowledge immediately to prevent Meta retries (20s window)
res.status(200).send('EVENT_RECEIVED');
try {
const events = await routeWhatsAppWebhook(req.body as Record<string, unknown>);
for (const event of events) {
await handleInboundWhatsAppMessage(event);
}
} catch (err) {
console.error('[webhook/whatsapp] routing error:', err);
}
});
// ── 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 } = 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;
}
await storeCredential(customer.id, platform, {
accessToken,
refreshToken,
expiresAt: expiresAt ? parseInt(expiresAt, 10) : undefined,
scope,
});
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 status: Record<string, boolean> = {};
for (const platform of platforms) {
const cred = await customer.getCredential(platform);
status[platform] = cred !== null;
}
res.json({ customerId: customer.id, connections: status });
});
// ── LinkedIn REST endpoints ─────────────────────────────────────
app.get('/api/linkedin/profile', requireAuth, async (req, res) => {
const account = req.query.account as string | undefined;
try {
const result = await handleToolCall('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 handleToolCall('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/search-connections', requireAuth, async (req, res) => {
const { keywords, account } = req.body as Record<string, unknown>;
try {
const result = await handleToolCall('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 handleToolCall('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 handleToolCall('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 handleToolCall('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 handleToolCall('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 handleToolCall('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 handleToolCall('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 handleToolCall('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 handleToolCall('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 handleToolCall('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 handleToolCall('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 handleToolCall('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 handleToolCall('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 handleToolCall('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 handleToolCall('instagram_create_post', { image_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 handleToolCall('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 handleToolCall('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 handleToolCall('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 handleToolCall('twitter_create_tweet', { text, 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);
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'],
};
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();
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`);
}
});
}
main().catch((err) => {
console.error('Failed to start server:', err);
process.exit(1);
});