UNPKG

agent-hub-mcp

Version:

Universal AI agent coordination platform based on Model Context Protocol (MCP)

298 lines (244 loc) 9.69 kB
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { createId } from '@paralleldrive/cuid2'; import cors from 'cors'; import express, { type Express } from 'express'; import rateLimit from 'express-rate-limit'; import { AgentStatusCleanup } from '~/agents/cleanup'; import { AgentService } from '~/agents/service'; import { AgentSession, SessionManager } from '~/agents/session'; import { MessageService } from '~/messaging/service'; import { StorageAdapter } from '~/types'; import { createMcpServer } from './mcp'; import { NotificationService } from './notifications'; export interface HttpServerDependencies { agentService: AgentService; messageService: MessageService; storage: StorageAdapter; } // JSON-RPC 2.0 compliant error helper function createJsonRpcError(code: number, message: string, data?: unknown, id: unknown = null) { return { jsonrpc: '2.0', error: data ? { code, message, data } : { code, message }, id, }; } // More lenient initialization check for Claude Code compatibility function isInitializeRequest(body: unknown): boolean { const b = body as any; return ( body && b.jsonrpc === '2.0' && b.method === 'initialize' && b.params && typeof b.params === 'object' && b.params.protocolVersion && b.params.capabilities ); } export function createHttpServer(deps: HttpServerDependencies): Express { const app = express(); const sessionManager = new SessionManager(); const notificationService = new NotificationService(sessionManager.getSessions()); // Start agent status cleanup service const agentCleanup = new AgentStatusCleanup(deps.storage); agentCleanup.startPeriodicCleanup(); // Notification system using MCP's built-in notifications async function broadcastNotification(method: string, params: unknown) { await notificationService.broadcastNotification(method, params); } async function sendNotificationToAgent(agentId: string, method: string, params: unknown) { await notificationService.sendNotificationToAgent(agentId, method, params); } // CORS configuration for browser clients - restricted to localhost app.use( cors({ origin: (origin, callback) => { // Allow requests with no origin (Electron apps, Postman, etc.) if (!origin) { callback(null, true); return; } // Allow only specific localhost ports and official Claude.ai domains const defaultAllowedOrigins = [ // Development servers on common ports 'http://localhost:3000', 'http://localhost:3001', 'http://localhost:8080', 'http://localhost:5173', // Vite default 'http://localhost:4173', // Vite preview 'http://127.0.0.1:3000', 'http://127.0.0.1:3001', 'http://127.0.0.1:8080', 'http://127.0.0.1:5173', 'http://127.0.0.1:4173', // Official Claude.ai domains only 'https://claude.ai', 'https://www.claude.ai', ]; // Allow additional origins from environment variable (comma-separated) const additionalOrigins = process.env.AGENT_HUB_ALLOWED_ORIGINS?.split(',').map(s => s.trim()) || []; const allowedOrigins = [...defaultAllowedOrigins, ...additionalOrigins]; const isAllowed = allowedOrigins.includes(origin); if (isAllowed) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }, exposedHeaders: ['Mcp-Session-Id'], allowedHeaders: ['Content-Type', 'mcp-session-id'], credentials: true, }), ); app.use(express.json()); // Rate limiting for DoS protection const generalLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per windowMs message: 'Too many requests from this IP, please try again later.', standardHeaders: true, legacyHeaders: false, }); // Apply general rate limiting to all routes app.use(generalLimiter); // Security headers app.use((_, response, next) => { response.setHeader('X-Content-Type-Options', 'nosniff'); response.setHeader('X-Frame-Options', 'DENY'); response.setHeader('X-XSS-Protection', '1; mode=block'); response.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); response.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); next(); }); // Health check endpoint app.get('/ping', (_request, response) => { response.json({ status: 'ok', timestamp: Date.now(), }); }); // MCP HTTP endpoints app.post('/mcp', async (request, response) => { const sessionId = request.headers['mcp-session-id'] as string | undefined; // Validate request body exists if (!request.body || typeof request.body !== 'object') { response.status(400).json(createJsonRpcError(-32700, 'Parse error: Invalid request body')); return; } let session: AgentSession; if (sessionId && sessionManager.has(sessionId)) { // Reuse existing session session = sessionManager.get(sessionId)!; } else if (!sessionId && isInitializeRequest(request.body)) { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => createId(), onsessioninitialized: async newSessionId => { // eslint-disable-next-line no-console console.log(`🔗 New session initialized: ${newSessionId}`); // Create temporary session without persistent agent file // Agent will be created when they call register_agent sessionManager.set(newSessionId, { transport, agent: null, // No agent until registration server, }); }, enableDnsRebindingProtection: false, }); // Clean up session when closed transport.onclose = () => { if (transport.sessionId) { const closeSession = sessionManager.get(transport.sessionId); if (closeSession?.agent) { // eslint-disable-next-line no-console console.log(`🔌 Session closed: ${transport.sessionId} (${closeSession.agent.id})`); sessionManager.delete(transport.sessionId); // Broadcast agent left notification broadcastNotification('agent_left', { agent: closeSession.agent }); } else if (closeSession) { // eslint-disable-next-line no-console console.log(`🔌 Session closed: ${transport.sessionId} (no agent)`); sessionManager.delete(transport.sessionId); } } }; const server = createMcpServer({ storage: deps.storage, messageService: deps.messageService, agentService: deps.agentService, getCurrentSession: () => transport.sessionId ? sessionManager.get(transport.sessionId) : undefined, broadcastNotification, sendNotificationToAgent, sendResourceNotification: async (agentId: string, uri: string) => { await notificationService.sendResourceChangedNotification(agentId, uri); }, }); await server.connect(transport); // This should not happen with proper session initialization // No agent created until proper registration session = { transport, agent: null, server, }; } else { // Invalid request - missing session ID for non-initialize request const requestId = request.body?.id || null; response .status(400) .json( createJsonRpcError( -32600, 'Invalid Request: No valid session ID provided and not an initialize request', undefined, requestId, ), ); return; } // Handle the request await session.transport.handleRequest(request, response, request.body); }); // Handle GET requests for server-to-client notifications via SSE app.get('/mcp', async (request, response) => { const sessionId = request.headers['mcp-session-id'] as string | undefined; // Validate session ID format if (!sessionId || typeof sessionId !== 'string' || sessionId.length === 0) { response .status(400) .json(createJsonRpcError(-32602, 'Invalid params: Invalid or missing session ID')); return; } if (!sessionManager.has(sessionId)) { response.status(404).json(createJsonRpcError(-32001, 'Session not found')); return; } const session = sessionManager.get(sessionId)!; // Update notification service with latest sessions for registered agent if (session.agent) { notificationService.updateSessions(sessionManager.getSessions()); } await session.transport.handleRequest(request, response); }); // Handle DELETE requests for session termination app.delete('/mcp', async (request, response) => { const sessionId = request.headers['mcp-session-id'] as string | undefined; // Validate session ID format if (!sessionId || typeof sessionId !== 'string' || sessionId.length === 0) { response .status(400) .json(createJsonRpcError(-32602, 'Invalid params: Invalid or missing session ID')); return; } if (!sessionManager.has(sessionId)) { response.status(404).json(createJsonRpcError(-32001, 'Session not found')); return; } const session = sessionManager.get(sessionId)!; await session.transport.handleRequest(request, response); }); return app; }