/**
* Core MCP Server implementation
*/
import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid';
import { ToolRegistry } from './registry.js';
import { MCPRequest, MCPResponse, MCPError, EnvConfig, ToolCallLog, ImageAttachment } from '../types/core.js';
import { MCPException, wrapError } from './errors.js';
export interface ServerConfig {
port: number;
host: string;
logLevel: 'debug' | 'info' | 'warn' | 'error';
sessionTtlMs: number;
enableCors: boolean;
maxRequestSizeBytes: number;
}
export interface ServerEvents {
'request': (request: MCPRequest) => void;
'response': (response: MCPResponse) => void;
'error': (error: Error) => void;
'tool_call': (log: ToolCallLog) => void;
}
export class MCPServer extends EventEmitter {
private registry: ToolRegistry;
private config: ServerConfig;
private envConfig: EnvConfig;
private toolCallLogs: ToolCallLog[] = [];
private running = false;
constructor(config: Partial<ServerConfig> = {}) {
super();
this.config = {
port: parseInt(process.env.PORT || '3000'),
host: process.env.HOST || 'localhost',
logLevel: (process.env.LOG_LEVEL as any) || 'info',
sessionTtlMs: 24 * 60 * 60 * 1000, // 24 hours
enableCors: true,
maxRequestSizeBytes: 50 * 1024 * 1024, // 50MB
...config
};
this.envConfig = this.loadEnvConfig();
this.registry = new ToolRegistry();
this.setupRegistryEvents();
this.setupCleanupTimer();
}
private loadEnvConfig(): EnvConfig {
return {
SUPABASE_URL: process.env.SUPABASE_URL,
SUPABASE_SERVICE_ROLE: process.env.SUPABASE_SERVICE_ROLE,
SUPABASE_ANON: process.env.SUPABASE_ANON,
RENDER_API_TOKEN: process.env.RENDER_API_TOKEN,
RENDER_ACCOUNT_ID: process.env.RENDER_ACCOUNT_ID,
VERCEL_TOKEN: process.env.VERCEL_TOKEN,
VERCEL_TEAM_ID: process.env.VERCEL_TEAM_ID,
POSTHOG_API_HOST: process.env.POSTHOG_API_HOST,
POSTHOG_PROJECT_KEY: process.env.POSTHOG_PROJECT_KEY,
POSTHOG_PERSONAL_API_KEY: process.env.POSTHOG_PERSONAL_API_KEY,
WEB_DRIVER_REMOTE_URL: process.env.WEB_DRIVER_REMOTE_URL,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
TRACKING_REDACT_RULES_JSON: process.env.TRACKING_REDACT_RULES_JSON,
PORT: process.env.PORT,
HOST: process.env.HOST,
LOG_LEVEL: process.env.LOG_LEVEL
};
}
private setupRegistryEvents(): void {
this.registry.on('tool_registered', (toolName, sessionId) => {
this.log('debug', `Tool registered: ${toolName}${sessionId ? ` (session: ${sessionId})` : ''}`);
});
this.registry.on('tool_unregistered', (toolName, sessionId) => {
this.log('debug', `Tool unregistered: ${toolName}${sessionId ? ` (session: ${sessionId})` : ''}`);
});
this.registry.on('session_created', (sessionId, type) => {
this.log('info', `Session created: ${sessionId} (${type})`);
});
this.registry.on('session_destroyed', (sessionId, type) => {
this.log('info', `Session destroyed: ${sessionId} (${type})`);
});
}
private setupCleanupTimer(): void {
// Clean up expired sessions every hour
setInterval(() => {
const cleaned = this.registry.cleanupExpiredSessions(this.config.sessionTtlMs);
if (cleaned > 0) {
this.log('info', `Cleaned up ${cleaned} expired sessions`);
}
}, 60 * 60 * 1000);
}
/**
* Handle an MCP request
*/
async handleRequest(request: MCPRequest): Promise<MCPResponse> {
const startTime = Date.now();
const requestId = request.x_request_id || uuidv4();
this.emit('request', { ...request, x_request_id: requestId });
try {
// Handle built-in methods
if (request.method === 'tools/list') {
const tools = this.registry.getAllTools();
return this.createResponse(request.id, tools, requestId);
}
if (request.method === 'tools/call') {
const { name, arguments: args } = request.params || {};
if (!name) {
throw new MCPException('INVALID_ARG', 'Tool name is required');
}
const result = await this.executeTool(name, args || {}, requestId);
return this.createResponse(request.id, result, requestId);
}
// Handle session management
if (request.method === 'sessions/list') {
const sessions = this.registry.getAllSessions();
return this.createResponse(request.id, { sessions }, requestId);
}
if (request.method === 'sessions/stats') {
const stats = this.registry.getStats();
return this.createResponse(request.id, stats, requestId);
}
if (request.method === 'logs/recent') {
const { limit = 100 } = request.params || {};
const logs = this.toolCallLogs
.slice(-limit)
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
return this.createResponse(request.id, { logs }, requestId);
}
throw new MCPException('NOT_FOUND', `Unknown method: ${request.method}`);
} catch (error) {
const duration = Date.now() - startTime;
const mcpError = wrapError(error);
this.log('error', `Request failed: ${request.method}`, { error: mcpError, duration });
const response: MCPResponse = {
jsonrpc: '2.0',
id: request.id,
error: mcpError,
x_request_id: requestId
};
this.emit('response', response);
return response;
}
}
private async executeTool(name: string, params: any, requestId: string): Promise<any> {
const startTime = Date.now();
let result: any;
let error: MCPError | undefined;
const images: ImageAttachment[] = [];
try {
result = await this.registry.executeTool(name, params, requestId);
// Extract image attachments from result if present
if (result && typeof result === 'object') {
this.extractImageAttachments(result, images);
}
} catch (err) {
error = wrapError(err);
throw err;
} finally {
const duration = Date.now() - startTime;
const log: ToolCallLog = {
id: uuidv4(),
tool_name: name,
params,
result: error ? undefined : result,
error,
timestamp: new Date().toISOString(),
duration_ms: duration,
images: images.length > 0 ? images : undefined
};
// Store log (keep last 1000 entries)
this.toolCallLogs.push(log);
if (this.toolCallLogs.length > 1000) {
this.toolCallLogs.shift();
}
this.emit('tool_call', log);
}
return result;
}
private extractImageAttachments(obj: any, images: ImageAttachment[]): void {
if (!obj || typeof obj !== 'object') return;
for (const [key, value] of Object.entries(obj)) {
if (key.endsWith('_image_data') && Buffer.isBuffer(value)) {
const name = key.replace('_image_data', '');
const mimeType = obj[`${name}_mime_type`] || 'image/png';
const width = obj[`${name}_width`];
const height = obj[`${name}_height`];
images.push({
name: obj[`${name}_image_name`] || `${name}.png`,
data: value,
mimeType,
width,
height
});
}
}
}
private createResponse<T>(id: string | number, result: T, requestId: string): MCPResponse<T> {
const response: MCPResponse<T> = {
jsonrpc: '2.0',
id,
result,
x_request_id: requestId
};
this.emit('response', response);
return response;
}
private log(level: string, message: string, meta?: any): void {
if (this.shouldLog(level)) {
const timestamp = new Date().toISOString();
const logEntry = {
timestamp,
level,
message,
...meta
};
console.log(JSON.stringify(logEntry));
}
}
private shouldLog(level: string): boolean {
const levels = ['debug', 'info', 'warn', 'error'];
const currentLevelIndex = levels.indexOf(this.config.logLevel);
const messageLevel = levels.indexOf(level);
return messageLevel >= currentLevelIndex;
}
/**
* Get the tool registry
*/
getRegistry(): ToolRegistry {
return this.registry;
}
/**
* Get environment configuration
*/
getEnvConfig(): EnvConfig {
return { ...this.envConfig };
}
/**
* Check if required env vars are present for a service
*/
checkRequiredEnv(keys: string[]): void {
const missing = keys.filter(key => !this.envConfig[key as keyof EnvConfig]);
if (missing.length > 0) {
throw new MCPException('CONFIG_MISSING', `Missing required environment variables: ${missing.join(', ')}`);
}
}
/**
* Get server configuration
*/
getConfig(): ServerConfig {
return { ...this.config };
}
/**
* Get recent tool call logs
*/
getRecentLogs(limit = 100): ToolCallLog[] {
return this.toolCallLogs
.slice(-limit)
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
}
/**
* Check if server is running
*/
isRunning(): boolean {
return this.running;
}
/**
* Mark server as running/stopped
*/
setRunning(running: boolean): void {
this.running = running;
}
}