import 'dotenv/config';
import Fastify from 'fastify';
import { OAuth2Client } from 'google-auth-library';
import { GoogleCalendarAdapter } from './adapters/google-calendar.js';
import { GmailAdapter } from './adapters/gmail-send.js';
import { PolicyEngine } from './policy/rules.js';
import { IdempotencyManager, createIdempotencyStore } from './util/idempotency.js';
import { McpRouter, McpRouterConfig } from './mcp/index.js';
import { TimeConfig } from './util/time.js';
import { PolicyConfig } from './policy/rules.js';
import { createTokenStore } from './auth/token-store.js';
import { GoogleOAuth } from './auth/oauth.js';
import { register as registerMetrics } from 'prom-client';
import pino from 'pino';
const config = {
port: parseInt(process.env.APP_PORT || '8080'),
logLevel: process.env.LOG_LEVEL || 'info',
defaultTz: process.env.DEFAULT_TZ || 'America/Chicago',
workHoursStart: process.env.WORK_HOURS_START || '08:00',
workHoursEnd: process.env.WORK_HOURS_END || '18:00',
workDays: (process.env.WORK_DAYS || 'Mon,Tue,Wed,Thu,Fri').split(','),
allowlistCalendarIds: (process.env.ALLOWLIST_CALENDAR_IDS || 'primary').split(','),
defaultCalendarId: process.env.DEFAULT_CALENDAR_ID || 'primary',
minDurationMinutes: parseInt(process.env.MIN_DURATION_MINUTES || '15'),
maxDurationMinutes: parseInt(process.env.MAX_DURATION_MINUTES || '480'),
googleClientId: process.env.GOOGLE_CLIENT_ID!,
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET!,
googleRedirectUri: process.env.GOOGLE_REDIRECT_URI!,
tokenEncKey: process.env.TOKEN_ENC_KEY!,
redisUrl: process.env.REDIS_URL,
gmailSenderEmail: process.env.GMAIL_SENDER_EMAIL!,
gmailSenderName: process.env.GMAIL_SENDER_NAME || 'Thinh\'s Assistant'
};
const requiredEnvVars = [
'GOOGLE_CLIENT_ID',
'GOOGLE_CLIENT_SECRET',
'GOOGLE_REDIRECT_URI',
'TOKEN_ENC_KEY',
'GMAIL_SENDER_EMAIL'
];
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
console.error(`Missing required environment variable: ${envVar}`);
process.exit(1);
}
}
const logger = pino({
level: config.logLevel,
transport: {
target: 'pino-pretty',
options: {
colorize: true
}
}
});
const fastify = Fastify({
logger: logger.child({ service: 'mcp-router' })
});
await fastify.register(import('@fastify/cors'), {
origin: true,
credentials: true
});
await fastify.register(import('@fastify/helmet'), {
contentSecurityPolicy: false
});
let mcpRouter: McpRouter;
async function initializeRouter() {
try {
const oauth2Client = new OAuth2Client(
config.googleClientId,
config.googleClientSecret,
config.googleRedirectUri
);
const calendarAdapter = new GoogleCalendarAdapter({ client: oauth2Client });
const gmailAdapter = new GmailAdapter({
client: oauth2Client,
senderEmail: config.gmailSenderEmail,
senderName: config.gmailSenderName
});
gmailAdapter.registerTemplate({
name: 'MEETING_CONFIRM',
subject: 'Meeting Confirmed: {{title}}',
html: `<!-- Template content will be loaded from file -->`
});
gmailAdapter.registerTemplate({
name: 'CANCEL_CONFIRM',
subject: 'Meeting Cancelled: {{title}}',
html: `<!-- Template content will be loaded from file -->`
});
const timeConfig: TimeConfig = {
defaultTz: config.defaultTz,
workHoursStart: config.workHoursStart,
workHoursEnd: config.workHoursEnd,
workDays: config.workDays
};
const policyConfig: PolicyConfig = {
timeConfig,
allowlistCalendarIds: config.allowlistCalendarIds,
defaultCalendarId: config.defaultCalendarId,
minDurationMinutes: config.minDurationMinutes,
maxDurationMinutes: config.maxDurationMinutes
};
// Use Redis if URL is provided, otherwise use memory store
let idempotencyStore;
if (config.redisUrl) {
try {
const { createClient } = await import('redis');
const redis = createClient({ url: config.redisUrl });
await redis.connect();
idempotencyStore = createIdempotencyStore(redis);
logger.info('Connected to Redis for idempotency storage');
} catch (error) {
logger.warn({ error }, 'Failed to connect to Redis, falling back to memory store');
idempotencyStore = createIdempotencyStore();
}
} else {
idempotencyStore = createIdempotencyStore();
logger.info('Using memory store for idempotency storage');
}
const idempotencyManager = new IdempotencyManager(idempotencyStore);
const routerConfig: McpRouterConfig = {
timeConfig,
policyConfig,
calendarAdapter,
gmailAdapter,
idempotencyManager
};
mcpRouter = new McpRouter(routerConfig);
logger.info('MCP Router initialized successfully');
} catch (error) {
logger.error({ error }, 'Failed to initialize MCP Router');
throw error;
}
}
fastify.get('/oauth/google/login', async (request: any, reply: any) => {
try {
const oauth = new GoogleOAuth({
clientId: config.googleClientId,
clientSecret: config.googleClientSecret,
redirectUri: config.googleRedirectUri,
scopes: [
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/gmail.send'
]
});
await oauth.initialize();
const { url, codeVerifier, state } = oauth.generateAuthUrl();
reply.header('Set-Cookie', [
`oauth_state=${state}; HttpOnly; Secure; SameSite=Strict`,
`oauth_code_verifier=${codeVerifier}; HttpOnly; Secure; SameSite=Strict`
]);
return reply.redirect(url);
} catch (error) {
logger.error({
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
type: typeof error
}, 'OAuth login error');
return reply.status(500).send({
error: 'OAuth login failed',
details: error instanceof Error ? error.message : String(error)
});
}
});
fastify.get('/oauth/google/callback', async (request: any, reply: any) => {
try {
const { code, state } = request.query as { code: string; state: string };
const cookies = request.headers.cookie || '';
const storedState = cookies.match(/oauth_state=([^;]+)/)?.[1];
const codeVerifier = cookies.match(/oauth_code_verifier=([^;]+)/)?.[1];
if (!code || !state || state !== storedState) {
return reply.status(400).send({ error: 'Invalid OAuth callback' });
}
const oauth = new GoogleOAuth({
clientId: config.googleClientId,
clientSecret: config.googleClientSecret,
redirectUri: config.googleRedirectUri,
scopes: [
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/gmail.send'
]
});
await oauth.initialize();
const tokenSet = await oauth.exchangeCodeForTokens(code, codeVerifier!, state);
const tokens = oauth.extractTokens(tokenSet);
const tokenStore = createTokenStore({ encryptionKey: config.tokenEncKey });
await tokenStore.storeTokens('user', tokens);
reply.header('Set-Cookie', [
'oauth_state=; HttpOnly; Secure; SameSite=Strict; Max-Age=0',
'oauth_code_verifier=; HttpOnly; Secure; SameSite=Strict; Max-Age=0'
]);
return reply.send({ message: 'OAuth authentication successful' });
} catch (error) {
logger.error({ error }, 'OAuth callback error');
return reply.status(500).send({ error: 'OAuth callback failed' });
}
});
fastify.post('/mcp/tools/:toolName', async (request: any, reply: any) => {
try {
const { toolName } = request.params as { toolName: string };
const requestBody = request.body as any;
if (!mcpRouter) {
return reply.status(503).send({ error: 'MCP Router not initialized' });
}
const validation = mcpRouter.validateRequest(toolName, requestBody);
if (!validation.valid) {
return reply.status(400).send({
error: 'Invalid request',
details: validation.errors
});
}
const result = await mcpRouter.executeTool(toolName, requestBody);
return reply.send(result);
} catch (error) {
logger.error({ error }, 'Tool execution error');
return reply.status(500).send({ error: 'Tool execution failed' });
}
});
fastify.get('/mcp/tools', async (request: any, reply: any) => {
try {
if (!mcpRouter) {
return reply.status(503).send({ error: 'MCP Router not initialized' });
}
const tools = mcpRouter.getTools();
return reply.send({ tools });
} catch (error) {
logger.error({ error }, 'Get tools error');
return reply.status(500).send({ error: 'Failed to get tools' });
}
});
fastify.get('/health', async (request: any, reply: any) => {
return reply.send({
status: 'healthy',
timestamp: new Date().toISOString(),
version: '1.0.0'
});
});
fastify.get('/metrics', async (request: any, reply: any) => {
try {
const metrics = await registerMetrics.metrics();
return reply
.type('text/plain')
.send(metrics);
} catch (error) {
logger.error({ error }, 'Metrics error');
return reply.status(500).send({ error: 'Failed to get metrics' });
}
});
async function start() {
try {
await initializeRouter();
await fastify.listen({
port: config.port,
host: '0.0.0.0'
});
logger.info(`MCP Router server listening on port ${config.port}`);
} catch (error) {
logger.error({ error }, 'Failed to start server');
process.exit(1);
}
}
process.on('SIGTERM', async () => {
logger.info('Received SIGTERM, shutting down gracefully');
await fastify.close();
process.exit(0);
});
process.on('SIGINT', async () => {
logger.info('Received SIGINT, shutting down gracefully');
await fastify.close();
process.exit(0);
});
start();