446 lines
15 KiB
JavaScript
446 lines
15 KiB
JavaScript
/**
|
|
* @claude-flow/mcp - HTTP Transport
|
|
*
|
|
* HTTP/REST transport with WebSocket support
|
|
*/
|
|
import { EventEmitter } from 'events';
|
|
import express from 'express';
|
|
import { createServer } from 'http';
|
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
import cors from 'cors';
|
|
import helmet from 'helmet';
|
|
export class HttpTransport extends EventEmitter {
|
|
logger;
|
|
config;
|
|
type = 'http';
|
|
requestHandler;
|
|
notificationHandler;
|
|
app;
|
|
server;
|
|
wss;
|
|
running = false;
|
|
activeConnections = new Set();
|
|
messagesReceived = 0;
|
|
messagesSent = 0;
|
|
errors = 0;
|
|
httpRequests = 0;
|
|
wsMessages = 0;
|
|
constructor(logger, config) {
|
|
super();
|
|
this.logger = logger;
|
|
this.config = config;
|
|
this.app = express();
|
|
this.setupMiddleware();
|
|
this.setupRoutes();
|
|
}
|
|
async start() {
|
|
if (this.running) {
|
|
throw new Error('HTTP transport already running');
|
|
}
|
|
this.logger.info('Starting HTTP transport', {
|
|
host: this.config.host,
|
|
port: this.config.port,
|
|
});
|
|
this.server = createServer(this.app);
|
|
this.wss = new WebSocketServer({
|
|
server: this.server,
|
|
path: '/ws',
|
|
});
|
|
this.setupWebSocketHandlers();
|
|
await new Promise((resolve, reject) => {
|
|
this.server.listen(this.config.port, this.config.host, () => {
|
|
resolve();
|
|
});
|
|
this.server.on('error', reject);
|
|
});
|
|
this.running = true;
|
|
this.logger.info('HTTP transport started', {
|
|
url: `http://${this.config.host}:${this.config.port}`,
|
|
});
|
|
}
|
|
async stop() {
|
|
if (!this.running) {
|
|
return;
|
|
}
|
|
this.logger.info('Stopping HTTP transport');
|
|
this.running = false;
|
|
for (const ws of this.activeConnections) {
|
|
try {
|
|
ws.close(1000, 'Server shutting down');
|
|
}
|
|
catch {
|
|
// Ignore errors
|
|
}
|
|
}
|
|
this.activeConnections.clear();
|
|
if (this.wss) {
|
|
this.wss.close();
|
|
this.wss = undefined;
|
|
}
|
|
if (this.server) {
|
|
await new Promise((resolve) => {
|
|
this.server.close(() => resolve());
|
|
});
|
|
this.server = undefined;
|
|
}
|
|
this.logger.info('HTTP transport stopped');
|
|
}
|
|
onRequest(handler) {
|
|
this.requestHandler = handler;
|
|
}
|
|
onNotification(handler) {
|
|
this.notificationHandler = handler;
|
|
}
|
|
async getHealthStatus() {
|
|
return {
|
|
healthy: this.running,
|
|
metrics: {
|
|
messagesReceived: this.messagesReceived,
|
|
messagesSent: this.messagesSent,
|
|
errors: this.errors,
|
|
httpRequests: this.httpRequests,
|
|
wsMessages: this.wsMessages,
|
|
activeConnections: this.activeConnections.size,
|
|
},
|
|
};
|
|
}
|
|
async sendNotification(notification) {
|
|
const message = JSON.stringify(notification);
|
|
for (const ws of this.activeConnections) {
|
|
try {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(message);
|
|
this.messagesSent++;
|
|
}
|
|
}
|
|
catch (error) {
|
|
this.logger.error('Failed to send notification', { error });
|
|
this.errors++;
|
|
}
|
|
}
|
|
}
|
|
setupMiddleware() {
|
|
this.app.use(helmet({
|
|
contentSecurityPolicy: false,
|
|
}));
|
|
if (this.config.corsEnabled !== false) {
|
|
const allowedOrigins = this.config.corsOrigins;
|
|
if (!allowedOrigins || allowedOrigins.length === 0) {
|
|
this.logger.warn('CORS: No origins configured, restricting to same-origin only');
|
|
}
|
|
this.app.use(cors({
|
|
origin: (origin, callback) => {
|
|
if (!origin) {
|
|
callback(null, true);
|
|
return;
|
|
}
|
|
if (allowedOrigins && allowedOrigins.length > 0) {
|
|
if (allowedOrigins.includes(origin) || allowedOrigins.includes('*')) {
|
|
callback(null, true);
|
|
}
|
|
else {
|
|
callback(new Error(`CORS: Origin '${origin}' not allowed`));
|
|
}
|
|
}
|
|
else {
|
|
callback(new Error('CORS: Cross-origin requests not allowed'));
|
|
}
|
|
},
|
|
credentials: true,
|
|
maxAge: 86400,
|
|
methods: ['GET', 'POST', 'OPTIONS'],
|
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
|
|
}));
|
|
}
|
|
this.app.use(express.json({
|
|
limit: this.config.maxRequestSize || '10mb',
|
|
}));
|
|
if (this.config.requestTimeout) {
|
|
this.app.use((req, res, next) => {
|
|
res.setTimeout(this.config.requestTimeout, () => {
|
|
res.status(408).json({
|
|
jsonrpc: '2.0',
|
|
id: null,
|
|
error: { code: -32000, message: 'Request timeout' },
|
|
});
|
|
});
|
|
next();
|
|
});
|
|
}
|
|
this.app.use((req, res, next) => {
|
|
const startTime = performance.now();
|
|
res.on('finish', () => {
|
|
const duration = performance.now() - startTime;
|
|
this.logger.debug('HTTP request', {
|
|
method: req.method,
|
|
path: req.path,
|
|
status: res.statusCode,
|
|
duration: `${duration.toFixed(2)}ms`,
|
|
});
|
|
});
|
|
next();
|
|
});
|
|
}
|
|
setupRoutes() {
|
|
this.app.get('/health', (req, res) => {
|
|
res.json({
|
|
status: 'ok',
|
|
timestamp: new Date().toISOString(),
|
|
connections: this.activeConnections.size,
|
|
});
|
|
});
|
|
this.app.post('/rpc', async (req, res) => {
|
|
await this.handleHttpRequest(req, res);
|
|
});
|
|
this.app.post('/mcp', async (req, res) => {
|
|
await this.handleHttpRequest(req, res);
|
|
});
|
|
this.app.get('/info', (req, res) => {
|
|
res.json({
|
|
name: 'Claude-Flow MCP Server V3',
|
|
version: '3.0.0',
|
|
transport: 'http',
|
|
capabilities: {
|
|
jsonrpc: true,
|
|
websocket: true,
|
|
},
|
|
});
|
|
});
|
|
this.app.use((req, res) => {
|
|
res.status(404).json({
|
|
error: 'Not found',
|
|
path: req.path,
|
|
});
|
|
});
|
|
this.app.use((err, req, res, next) => {
|
|
this.logger.error('Express error', { error: err });
|
|
this.errors++;
|
|
res.status(500).json({
|
|
jsonrpc: '2.0',
|
|
id: null,
|
|
error: { code: -32603, message: 'Internal error' },
|
|
});
|
|
});
|
|
}
|
|
setupWebSocketHandlers() {
|
|
if (!this.wss)
|
|
return;
|
|
// SECURITY: Handle WebSocket authentication via upgrade request
|
|
this.wss.on('connection', (ws, req) => {
|
|
// Validate authentication if enabled
|
|
if (this.config.auth?.enabled) {
|
|
const url = new URL(req.url || '', `http://${req.headers.host}`);
|
|
const token = url.searchParams.get('token') || req.headers['authorization']?.replace(/^Bearer\s+/i, '');
|
|
if (!token) {
|
|
this.logger.warn('WebSocket connection rejected: no authentication token');
|
|
ws.close(4001, 'Authentication required');
|
|
return;
|
|
}
|
|
// SECURITY: Timing-safe token validation
|
|
let valid = false;
|
|
if (this.config.auth.tokens?.length) {
|
|
for (const validToken of this.config.auth.tokens) {
|
|
if (this.timingSafeCompare(token, validToken)) {
|
|
valid = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!valid) {
|
|
this.logger.warn('WebSocket connection rejected: invalid token');
|
|
ws.close(4003, 'Invalid token');
|
|
return;
|
|
}
|
|
}
|
|
this.activeConnections.add(ws);
|
|
this.logger.info('WebSocket client connected', {
|
|
total: this.activeConnections.size,
|
|
authenticated: !!this.config.auth?.enabled,
|
|
});
|
|
ws.on('message', async (data) => {
|
|
await this.handleWebSocketMessage(ws, data.toString());
|
|
});
|
|
ws.on('close', () => {
|
|
this.activeConnections.delete(ws);
|
|
this.logger.info('WebSocket client disconnected', {
|
|
total: this.activeConnections.size,
|
|
});
|
|
});
|
|
ws.on('error', (error) => {
|
|
this.logger.error('WebSocket error', { error });
|
|
this.errors++;
|
|
this.activeConnections.delete(ws);
|
|
});
|
|
});
|
|
}
|
|
async handleHttpRequest(req, res) {
|
|
this.httpRequests++;
|
|
this.messagesReceived++;
|
|
const requiresAuth = this.config.auth?.enabled !== false;
|
|
if (requiresAuth && this.config.auth) {
|
|
const authResult = this.validateAuth(req);
|
|
if (!authResult.valid) {
|
|
this.logger.warn('Authentication failed', {
|
|
ip: req.ip,
|
|
path: req.path,
|
|
error: authResult.error,
|
|
});
|
|
res.status(401).json({
|
|
jsonrpc: '2.0',
|
|
id: null,
|
|
error: { code: -32001, message: 'Unauthorized' },
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
else if (requiresAuth && !this.config.auth) {
|
|
this.logger.warn('No authentication configured - running in development mode');
|
|
}
|
|
const message = req.body;
|
|
if (message.jsonrpc !== '2.0') {
|
|
res.status(400).json({
|
|
jsonrpc: '2.0',
|
|
id: message.id || null,
|
|
error: { code: -32600, message: 'Invalid JSON-RPC version' },
|
|
});
|
|
return;
|
|
}
|
|
if (!message.method) {
|
|
res.status(400).json({
|
|
jsonrpc: '2.0',
|
|
id: message.id || null,
|
|
error: { code: -32600, message: 'Missing method' },
|
|
});
|
|
return;
|
|
}
|
|
if (message.id === undefined) {
|
|
if (this.notificationHandler) {
|
|
await this.notificationHandler(message);
|
|
}
|
|
res.status(204).end();
|
|
}
|
|
else {
|
|
if (!this.requestHandler) {
|
|
res.status(500).json({
|
|
jsonrpc: '2.0',
|
|
id: message.id,
|
|
error: { code: -32603, message: 'No request handler' },
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
const response = await this.requestHandler(message);
|
|
res.json(response);
|
|
this.messagesSent++;
|
|
}
|
|
catch (error) {
|
|
this.errors++;
|
|
res.status(500).json({
|
|
jsonrpc: '2.0',
|
|
id: message.id,
|
|
error: {
|
|
code: -32603,
|
|
message: error instanceof Error ? error.message : 'Internal error',
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
async handleWebSocketMessage(ws, data) {
|
|
this.wsMessages++;
|
|
this.messagesReceived++;
|
|
try {
|
|
const message = JSON.parse(data);
|
|
if (message.jsonrpc !== '2.0') {
|
|
ws.send(JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: message.id || null,
|
|
error: { code: -32600, message: 'Invalid JSON-RPC version' },
|
|
}));
|
|
return;
|
|
}
|
|
if (message.id === undefined) {
|
|
if (this.notificationHandler) {
|
|
await this.notificationHandler(message);
|
|
}
|
|
}
|
|
else {
|
|
if (!this.requestHandler) {
|
|
ws.send(JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: message.id,
|
|
error: { code: -32603, message: 'No request handler' },
|
|
}));
|
|
return;
|
|
}
|
|
const response = await this.requestHandler(message);
|
|
ws.send(JSON.stringify(response));
|
|
this.messagesSent++;
|
|
}
|
|
}
|
|
catch (error) {
|
|
this.errors++;
|
|
this.logger.error('WebSocket message error', { error });
|
|
try {
|
|
const parsed = JSON.parse(data);
|
|
ws.send(JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: parsed.id || null,
|
|
error: { code: -32700, message: 'Parse error' },
|
|
}));
|
|
}
|
|
catch {
|
|
ws.send(JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: null,
|
|
error: { code: -32700, message: 'Parse error' },
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* SECURITY: Timing-safe token comparison to prevent timing attacks
|
|
*/
|
|
timingSafeCompare(a, b) {
|
|
const crypto = require('crypto');
|
|
// Ensure both strings are the same length for timing-safe comparison
|
|
const bufA = Buffer.from(a, 'utf-8');
|
|
const bufB = Buffer.from(b, 'utf-8');
|
|
// If lengths differ, still do a comparison to prevent length-based timing
|
|
if (bufA.length !== bufB.length) {
|
|
// Compare against itself to maintain constant time
|
|
crypto.timingSafeEqual(bufA, bufA);
|
|
return false;
|
|
}
|
|
return crypto.timingSafeEqual(bufA, bufB);
|
|
}
|
|
validateAuth(req) {
|
|
const auth = req.headers.authorization;
|
|
if (!auth) {
|
|
return { valid: false, error: 'Authorization header required' };
|
|
}
|
|
const tokenMatch = auth.match(/^Bearer\s+(.+)$/i);
|
|
if (!tokenMatch) {
|
|
return { valid: false, error: 'Invalid authorization format' };
|
|
}
|
|
const token = tokenMatch[1];
|
|
if (this.config.auth?.tokens?.length) {
|
|
// SECURITY: Use timing-safe comparison to prevent timing attacks
|
|
let valid = false;
|
|
for (const validToken of this.config.auth.tokens) {
|
|
if (this.timingSafeCompare(token, validToken)) {
|
|
valid = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!valid) {
|
|
return { valid: false, error: 'Invalid token' };
|
|
}
|
|
}
|
|
return { valid: true };
|
|
}
|
|
}
|
|
export function createHttpTransport(logger, config) {
|
|
return new HttpTransport(logger, config);
|
|
}
|
|
//# sourceMappingURL=http.js.map
|