Add multi-account support and CORS/logging middleware

- Add garfield, sales, leads, founder accounts to IMAP and SMTP configs
- Refactor fetcherpay config into shared helper functions
- Add CORS middleware with wildcard origin
- Add request logging middleware and MCP session lifecycle logs
- Include package-lock.json and add @types/cors dependency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
garfieldheron
2026-04-14 08:45:45 -04:00
parent 9ecb02785c
commit 166f5d55a6
6 changed files with 2670 additions and 66 deletions

2510
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -11,15 +11,17 @@
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0", "@modelcontextprotocol/sdk": "^1.0.0",
"@types/cors": "^2.8.19",
"cors": "^2.8.6",
"dotenv": "^16.0.0",
"express": "^4.18.0", "express": "^4.18.0",
"imapflow": "^1.0.0", "imapflow": "^1.0.0",
"nodemailer": "^6.9.0", "nodemailer": "^6.9.0"
"dotenv": "^16.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.0", "@types/express": "^4.17.0",
"@types/nodemailer": "^6.4.0",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@types/nodemailer": "^6.4.0",
"tsx": "^4.0.0", "tsx": "^4.0.0",
"typescript": "^5.0.0" "typescript": "^5.0.0"
} }

View File

@@ -1,20 +1,48 @@
import { ImapFlow } from 'imapflow'; import { ImapFlow } from 'imapflow';
export type Account = 'yahoo' | 'fetcherpay'; export type Account = 'yahoo' | 'fetcherpay' | 'garfield' | 'sales' | 'leads' | 'founder';
const FETCHERPAY_IMAP_HOST = process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com';
const FETCHERPAY_IMAP_PORT = parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993');
function fetcherpayImapConfig(user: string, pass: string) {
return {
host: FETCHERPAY_IMAP_HOST,
port: FETCHERPAY_IMAP_PORT,
secure: true,
auth: { user, pass },
tls: { rejectUnauthorized: false },
};
}
function getConfig(account: Account = 'yahoo') { function getConfig(account: Account = 'yahoo') {
if (account === 'fetcherpay') { switch (account) {
return { case 'fetcherpay':
host: process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com', return fetcherpayImapConfig(
port: parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993'), process.env['FETCHERPAY_EMAIL'] as string,
secure: true, process.env['FETCHERPAY_PASSWORD'] as string,
auth: { );
user: process.env['FETCHERPAY_EMAIL'] as string, case 'garfield':
pass: process.env['FETCHERPAY_PASSWORD'] as string, return fetcherpayImapConfig(
}, process.env['GARFIELD_EMAIL'] as string,
tls: { rejectUnauthorized: false }, // self-signed cert on self-hosted server process.env['GARFIELD_PASSWORD'] as string,
}; );
} case 'sales':
return fetcherpayImapConfig(
process.env['SALES_EMAIL'] as string,
process.env['SALES_PASSWORD'] as string,
);
case 'leads':
return fetcherpayImapConfig(
process.env['LEADS_EMAIL'] as string,
process.env['LEADS_PASSWORD'] as string,
);
case 'founder':
return fetcherpayImapConfig(
process.env['FOUNDER_EMAIL'] as string,
process.env['FOUNDER_PASSWORD'] as string,
);
default:
return { return {
host: 'imap.mail.yahoo.com', host: 'imap.mail.yahoo.com',
port: 993, port: 993,
@@ -24,6 +52,7 @@ function getConfig(account: Account = 'yahoo') {
pass: process.env['YAHOO_APP_PASSWORD'] as string, pass: process.env['YAHOO_APP_PASSWORD'] as string,
}, },
}; };
}
} }
async function withClient<T>(account: Account, fn: (client: ImapFlow) => Promise<T>): Promise<T> { async function withClient<T>(account: Account, fn: (client: ImapFlow) => Promise<T>): Promise<T> {
@@ -159,9 +188,15 @@ export async function readMessage(uid: number, account: Account = 'yahoo'): Prom
} }
export async function getProfile(account: Account = 'yahoo'): Promise<{ email: string; name: string; account: string }> { export async function getProfile(account: Account = 'yahoo'): Promise<{ email: string; name: string; account: string }> {
const email = account === 'fetcherpay' const emailMap: Record<Account, string> = {
? (process.env['FETCHERPAY_EMAIL'] ?? '') yahoo: process.env['YAHOO_EMAIL'] ?? '',
: (process.env['YAHOO_EMAIL'] ?? ''); fetcherpay: process.env['FETCHERPAY_EMAIL'] ?? '',
garfield: process.env['GARFIELD_EMAIL'] ?? '',
sales: process.env['SALES_EMAIL'] ?? '',
leads: process.env['LEADS_EMAIL'] ?? '',
founder: process.env['FOUNDER_EMAIL'] ?? '',
};
const email = emailMap[account] ?? '';
return { email, name: email.split('@')[0], account }; return { email, name: email.split('@')[0], account };
} }

View File

@@ -1,5 +1,6 @@
import 'dotenv/config'; import 'dotenv/config';
import express from 'express'; import express from 'express';
import cors from 'cors';
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
@@ -11,8 +12,24 @@ import {
import { tools, handleToolCall } from './tools.js'; import { tools, handleToolCall } from './tools.js';
const app = express(); const app = express();
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'mcp-session-id', 'Accept'],
credentials: true
}));
app.use(express.json()); app.use(express.json());
// 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) {
console.log(` Body: ${JSON.stringify(req.body).substring(0, 500)}`);
}
next();
});
function createMcpServer() { function createMcpServer() {
const server = new Server( const server = new Server(
{ name: 'hermes', version: '1.0.0' }, { name: 'hermes', version: '1.0.0' },
@@ -33,11 +50,13 @@ const httpTransports = new Map<string, StreamableHTTPServerTransport>();
app.post('/mcp', async (req, res) => { app.post('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined; const sessionId = req.headers['mcp-session-id'] as string | undefined;
console.log(`[mcp] POST sessionId=${sessionId ?? 'none'}, isInit=${isInitializeRequest(req.body)}`);
let transport: StreamableHTTPServerTransport; let transport: StreamableHTTPServerTransport;
if (sessionId && httpTransports.has(sessionId)) { if (sessionId && httpTransports.has(sessionId)) {
// Known active session — reuse it // Known active session — reuse it
console.log(`[mcp] Reusing existing session ${sessionId}`);
transport = httpTransports.get(sessionId)!; transport = httpTransports.get(sessionId)!;
} else if (isInitializeRequest(req.body)) { } else if (isInitializeRequest(req.body)) {
// Initialize request: create a new session. // Initialize request: create a new session.
@@ -46,15 +65,23 @@ app.post('/mcp', async (req, res) => {
if (sessionId) { if (sessionId) {
console.warn(`[mcp] Stale session ${sessionId} re-initializing — pod may have restarted`); console.warn(`[mcp] Stale session ${sessionId} re-initializing — pod may have restarted`);
} }
console.log(`[mcp] Creating new session`);
transport = new StreamableHTTPServerTransport({ transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(), sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (id) => { httpTransports.set(id, transport); }, onsessioninitialized: (id) => {
console.log(`[mcp] Session initialized: ${id}`);
httpTransports.set(id, transport);
},
}); });
transport.onclose = () => { transport.onclose = () => {
if (transport.sessionId) httpTransports.delete(transport.sessionId); if (transport.sessionId) {
console.log(`[mcp] Session closed: ${transport.sessionId}`);
httpTransports.delete(transport.sessionId);
}
}; };
const server = createMcpServer(); const server = createMcpServer();
await server.connect(transport); await server.connect(transport);
console.log(`[mcp] Server connected to transport`);
} else { } else {
// Unknown session + non-initialize request: session expired (e.g. pod restarted). // Unknown session + non-initialize request: session expired (e.g. pod restarted).
// Return 404 so MCP clients know to re-initialize rather than keep retrying. // Return 404 so MCP clients know to re-initialize rather than keep retrying.
@@ -63,7 +90,13 @@ app.post('/mcp', async (req, res) => {
return; return;
} }
try {
await transport.handleRequest(req, res, req.body); 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', async (req, res) => { app.get('/mcp', async (req, res) => {

View File

@@ -1,19 +1,32 @@
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import type { Account } from './imap.js'; import type { Account } from './imap.js';
function getSmtpTransport(account: Account = 'yahoo') { const FETCHERPAY_SMTP_HOST = process.env['FETCHERPAY_SMTP_HOST'] ?? 'mail.fetcherpay.com';
if (account === 'fetcherpay') { const FETCHERPAY_SMTP_PORT = parseInt(process.env['FETCHERPAY_SMTP_PORT'] ?? '30587');
function fetcherpaySmtpTransport(user: string, pass: string) {
return nodemailer.createTransport({ return nodemailer.createTransport({
host: process.env['FETCHERPAY_SMTP_HOST'] ?? 'mail.fetcherpay.com', host: FETCHERPAY_SMTP_HOST,
port: parseInt(process.env['FETCHERPAY_SMTP_PORT'] ?? '30587'), port: FETCHERPAY_SMTP_PORT,
secure: false, // STARTTLS secure: false, // STARTTLS
auth: { auth: { user, pass },
user: process.env['FETCHERPAY_EMAIL']!, tls: { rejectUnauthorized: false },
pass: process.env['FETCHERPAY_PASSWORD']!,
},
tls: { rejectUnauthorized: false }, // self-signed cert
}); });
} }
function getSmtpTransport(account: Account = 'yahoo') {
switch (account) {
case 'fetcherpay':
return fetcherpaySmtpTransport(process.env['FETCHERPAY_EMAIL']!, process.env['FETCHERPAY_PASSWORD']!);
case 'garfield':
return fetcherpaySmtpTransport(process.env['GARFIELD_EMAIL']!, process.env['GARFIELD_PASSWORD']!);
case 'sales':
return fetcherpaySmtpTransport(process.env['SALES_EMAIL']!, process.env['SALES_PASSWORD']!);
case 'leads':
return fetcherpaySmtpTransport(process.env['LEADS_EMAIL']!, process.env['LEADS_PASSWORD']!);
case 'founder':
return fetcherpaySmtpTransport(process.env['FOUNDER_EMAIL']!, process.env['FOUNDER_PASSWORD']!);
default:
return nodemailer.createTransport({ return nodemailer.createTransport({
host: 'smtp.mail.yahoo.com', host: 'smtp.mail.yahoo.com',
port: 587, port: 587,
@@ -23,12 +36,19 @@ function getSmtpTransport(account: Account = 'yahoo') {
pass: process.env['YAHOO_APP_PASSWORD']!, pass: process.env['YAHOO_APP_PASSWORD']!,
}, },
}); });
}
} }
function getSenderEmail(account: Account = 'yahoo'): string { function getSenderEmail(account: Account = 'yahoo'): string {
return account === 'fetcherpay' const emailMap: Record<Account, string> = {
? process.env['FETCHERPAY_EMAIL']! yahoo: process.env['YAHOO_EMAIL'] ?? '',
: process.env['YAHOO_EMAIL']!; fetcherpay: process.env['FETCHERPAY_EMAIL'] ?? '',
garfield: process.env['GARFIELD_EMAIL'] ?? '',
sales: process.env['SALES_EMAIL'] ?? '',
leads: process.env['LEADS_EMAIL'] ?? '',
founder: process.env['FOUNDER_EMAIL'] ?? '',
};
return emailMap[account] ?? '';
} }
export async function sendEmail( export async function sendEmail(
@@ -55,17 +75,21 @@ export async function createDraft(
): Promise<string> { ): Promise<string> {
const { ImapFlow } = await import('imapflow'); const { ImapFlow } = await import('imapflow');
const imapConfig = account === 'fetcherpay' const fetcherpayImapBase = {
? {
host: process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com', host: process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com',
port: parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993'), port: parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993'),
secure: true, secure: true,
auth: {
user: process.env['FETCHERPAY_EMAIL']!,
pass: process.env['FETCHERPAY_PASSWORD']!,
},
tls: { rejectUnauthorized: false }, tls: { rejectUnauthorized: false },
} };
const fetcherpayImapAccounts: Partial<Record<Account, { user: string; pass: string }>> = {
fetcherpay: { user: process.env['FETCHERPAY_EMAIL']!, pass: process.env['FETCHERPAY_PASSWORD']! },
garfield: { user: process.env['GARFIELD_EMAIL']!, pass: process.env['GARFIELD_PASSWORD']! },
sales: { user: process.env['SALES_EMAIL']!, pass: process.env['SALES_PASSWORD']! },
leads: { user: process.env['LEADS_EMAIL']!, pass: process.env['LEADS_PASSWORD']! },
founder: { user: process.env['FOUNDER_EMAIL']!, pass: process.env['FOUNDER_PASSWORD']! },
};
const imapConfig = fetcherpayImapAccounts[account]
? { ...fetcherpayImapBase, auth: fetcherpayImapAccounts[account]! }
: { : {
host: 'imap.mail.yahoo.com', host: 'imap.mail.yahoo.com',
port: 993, port: 993,

View File

@@ -5,8 +5,8 @@ import { sendEmail, createDraft } from './smtp.js';
const ACCOUNT_PARAM = { const ACCOUNT_PARAM = {
account: { account: {
type: 'string', type: 'string',
enum: ['yahoo', 'fetcherpay'], enum: ['yahoo', 'fetcherpay', 'garfield', 'sales', 'leads', 'founder'],
description: 'Which mailbox to use: "yahoo" (gheron01@yahoo.com) or "fetcherpay" (garfield.heron@fetcherpay.com). Defaults to "yahoo".', description: 'Which mailbox to use: "yahoo" (gheron01@yahoo.com), "fetcherpay" (garfield.heron@fetcherpay.com), "garfield" (garfield@fetcherpay.com), "sales" (sales@fetcherpay.com), "leads" (leads@fetcherpay.com), or "founder" (founder@fetcherpay.com). Defaults to "yahoo".',
}, },
}; };