claude-flow-tbowman01
Version:
Enterprise-grade AI agent orchestration with ruv-swarm integration (Alpha Release)
464 lines (398 loc) • 13 kB
text/typescript
/**
* HTTP transport for MCP
*/
import express, { Express, Request, Response } from 'express';
import { createServer, Server } from 'node:http';
import { WebSocketServer, WebSocket } from 'ws';
import cors from 'cors';
import helmet from 'helmet';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ITransport, RequestHandler, NotificationHandler } from './base.js';
import type { MCPRequest, MCPResponse, MCPNotification, MCPConfig } from '../../utils/types.js';
import type { ILogger } from '../../core/logger.js';
import { MCPTransportError } from '../../utils/errors.js';
/**
* HTTP transport implementation
*/
export class HttpTransport implements ITransport {
private requestHandler?: RequestHandler;
private notificationHandler?: NotificationHandler;
private app: Express;
private server?: Server;
private wss?: WebSocketServer;
private messageCount = 0;
private notificationCount = 0;
private running = false;
private connections = new Set<WebSocket>();
private activeWebSockets = new Set<WebSocket>();
constructor(
private host: string,
private port: number,
private tlsEnabled: boolean,
private logger: ILogger,
private config?: MCPConfig,
) {
this.app = express();
this.setupMiddleware();
this.setupRoutes();
}
async start(): Promise<void> {
if (this.running) {
throw new MCPTransportError('Transport already running');
}
this.logger.info('Starting HTTP transport', {
host: this.host,
port: this.port,
tls: this.tlsEnabled,
});
try {
// Create HTTP server
this.server = createServer(this.app);
// Create WebSocket server
this.wss = new WebSocketServer({
server: this.server,
path: '/ws',
});
this.setupWebSocketHandlers();
// Start server
await new Promise<void>((resolve, reject) => {
this.server!.listen(this.port, this.host, () => {
this.logger.info(`HTTP server listening on ${this.host}:${this.port}`);
resolve();
});
this.server!.on('error', reject);
});
this.running = true;
this.logger.info('HTTP transport started');
} catch (error) {
throw new MCPTransportError('Failed to start HTTP transport', { error });
}
}
async stop(): Promise<void> {
if (!this.running) {
return;
}
this.logger.info('Stopping HTTP transport');
this.running = false;
// Close all WebSocket connections
for (const ws of this.activeWebSockets) {
try {
ws.close();
} catch {
// Ignore errors
}
}
this.activeWebSockets.clear();
this.connections.clear();
// Close WebSocket server
if (this.wss) {
this.wss.close();
this.wss = undefined;
}
// Shutdown HTTP server
if (this.server) {
await new Promise<void>((resolve) => {
this.server!.close(() => resolve());
});
this.server = undefined;
}
this.logger.info('HTTP transport stopped');
}
onRequest(handler: RequestHandler): void {
this.requestHandler = handler;
}
onNotification(handler: NotificationHandler): void {
this.notificationHandler = handler;
}
async getHealthStatus(): Promise<{
healthy: boolean;
error?: string;
metrics?: Record<string, number>;
}> {
return {
healthy: this.running,
metrics: {
messagesReceived: this.messageCount,
notificationsSent: this.notificationCount,
activeConnections: this.connections.size,
activeWebSockets: this.activeWebSockets.size,
},
};
}
private setupMiddleware(): void {
// Security middleware
this.app.use(helmet());
// CORS middleware
if (this.config?.corsEnabled) {
const origins = this.config.corsOrigins || ['*'];
this.app.use(
cors({
origin: origins,
credentials: true,
maxAge: 86400, // 24 hours
}),
);
}
// Body parsing middleware
this.app.use(express.json({ limit: '10mb' }));
this.app.use(express.text());
}
private setupRoutes(): void {
// Get current file directory for static files
const __filename =
typeof import.meta?.url !== 'undefined'
? fileURLToPath(import.meta.url)
: __filename || __dirname + '/http.ts';
const __dirname = dirname(__filename);
const consoleDir = join(__dirname, '../../ui/console');
// Serve static files for the web console
this.app.use('/console', express.static(consoleDir));
// Web console route
this.app.get('/', (req, res) => {
res.redirect('/console');
});
this.app.get('/console', (req, res) => {
res.sendFile(join(consoleDir, 'index.html'));
});
// Health check endpoint
this.app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// MCP JSON-RPC endpoint
this.app.post('/rpc', async (req, res) => {
await this.handleJsonRpcRequest(req, res);
});
// Handle preflight requests
this.app.options('*', (req, res) => {
res.status(204).end();
});
// 404 handler
this.app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
// Error handler
this.app.use(
(err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
this.logger.error('Express error', err);
res.status(500).json({
error: 'Internal server error',
message: err.message,
});
},
);
}
private setupWebSocketHandlers(): void {
if (!this.wss) return;
this.wss.on('connection', (ws: WebSocket, req) => {
this.activeWebSockets.add(ws);
this.logger.info('WebSocket client connected', {
totalClients: this.activeWebSockets.size,
});
ws.on('close', () => {
this.activeWebSockets.delete(ws);
this.logger.info('WebSocket client disconnected', {
totalClients: this.activeWebSockets.size,
});
});
ws.on('error', (error) => {
this.logger.error('WebSocket error', error);
this.activeWebSockets.delete(ws);
});
ws.on('message', async (data) => {
try {
const message = JSON.parse(data.toString());
if (message.id === undefined) {
// Notification from client
await this.handleNotificationMessage(message as MCPNotification);
} else {
// Request from client
const response = await this.handleRequestMessage(message as MCPRequest);
ws.send(JSON.stringify(response));
}
} catch (error) {
this.logger.error('Error processing WebSocket message', error);
// Send error response if it was a request
try {
const parsed = JSON.parse(data.toString());
if (parsed.id !== undefined) {
ws.send(
JSON.stringify({
jsonrpc: '2.0',
id: parsed.id,
error: {
code: -32603,
message: 'Internal error',
},
}),
);
}
} catch {
// Ignore parse errors for error responses
}
}
});
});
}
private async handleJsonRpcRequest(req: Request, res: Response): Promise<void> {
// Check content type
if (!req.is('application/json')) {
res.status(400).json({
jsonrpc: '2.0',
id: null,
error: {
code: -32600,
message: 'Invalid content type - expected application/json',
},
});
return;
}
// Check authorization if authentication is enabled
if (this.config?.auth?.enabled) {
const authResult = await this.validateAuth(req);
if (!authResult.valid) {
res.status(401).json({
error: authResult.error || 'Unauthorized',
});
return;
}
}
try {
const mcpMessage = req.body;
// Validate JSON-RPC format
if (!mcpMessage.jsonrpc || mcpMessage.jsonrpc !== '2.0') {
res.status(400).json({
jsonrpc: '2.0',
id: mcpMessage.id || null,
error: {
code: -32600,
message: 'Invalid request - missing or invalid jsonrpc version',
},
});
return;
}
if (!mcpMessage.method) {
res.status(400).json({
jsonrpc: '2.0',
id: mcpMessage.id || null,
error: {
code: -32600,
message: 'Invalid request - missing method',
},
});
return;
}
this.messageCount++;
// Check if this is a notification (no id) or request
if (mcpMessage.id === undefined) {
// Handle notification
await this.handleNotificationMessage(mcpMessage as MCPNotification);
// Notifications don't get responses
res.status(204).end();
} else {
// Handle request
const response = await this.handleRequestMessage(mcpMessage as MCPRequest);
res.json(response);
}
} catch (error) {
this.logger.error('Error handling JSON-RPC request', error);
res.status(500).json({
jsonrpc: '2.0',
id: null,
error: {
code: -32603,
message: 'Internal error',
data: error instanceof Error ? error.message : String(error),
},
});
}
}
private async handleRequestMessage(request: MCPRequest): Promise<MCPResponse> {
if (!this.requestHandler) {
return {
jsonrpc: '2.0',
id: request.id,
error: {
code: -32603,
message: 'No request handler registered',
},
};
}
try {
return await this.requestHandler(request);
} catch (error) {
this.logger.error('Request handler error', { request, error });
return {
jsonrpc: '2.0',
id: request.id,
error: {
code: -32603,
message: 'Internal error',
data: error instanceof Error ? error.message : String(error),
},
};
}
}
private async handleNotificationMessage(notification: MCPNotification): Promise<void> {
if (!this.notificationHandler) {
this.logger.warn('Received notification but no handler registered', {
method: notification.method,
});
return;
}
try {
await this.notificationHandler(notification);
} catch (error) {
this.logger.error('Notification handler error', { notification, error });
// Notifications don't send error responses
}
}
private async validateAuth(req: Request): Promise<{ valid: boolean; error?: string }> {
const auth = req.headers.authorization;
if (!auth) {
return { valid: false, error: 'Authorization header required' };
}
// Extract token from Authorization header
const tokenMatch = auth.match(/^Bearer\s+(.+)$/i);
if (!tokenMatch) {
return { valid: false, error: 'Invalid authorization format - use Bearer token' };
}
const token = tokenMatch[1];
// Validate against configured tokens
if (this.config?.auth?.tokens && this.config.auth.tokens.length > 0) {
const isValid = this.config.auth.tokens.includes(token);
if (!isValid) {
return { valid: false, error: 'Invalid token' };
}
}
return { valid: true };
}
async connect(): Promise<void> {
// For HTTP transport, connect is handled by start()
if (!this.running) {
await this.start();
}
}
async disconnect(): Promise<void> {
// For HTTP transport, disconnect is handled by stop()
await this.stop();
}
async sendRequest(request: MCPRequest): Promise<MCPResponse> {
// HTTP transport is server-side, it doesn't send requests
throw new Error('HTTP transport does not support sending requests');
}
async sendNotification(notification: MCPNotification): Promise<void> {
// Broadcast notification to all connected WebSocket clients
const message = JSON.stringify(notification);
for (const ws of this.activeWebSockets) {
try {
if (ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
} catch (error) {
this.logger.error('Failed to send notification to WebSocket', error);
}
}
this.notificationCount++;
}
}