UNPKG

@spryker-sdk/spryker-mcp-server

Version:

AI-powered e-commerce server that enables intelligent shopping assistants for Spryker Commerce OS platforms

320 lines 14.9 kB
/** * HTTP MCP Server Implementation */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { createServer } from 'node:http'; import { URL } from 'node:url'; import { randomUUID } from 'node:crypto'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { config } from '../config/index.js'; import { logger } from '../utils/logger.js'; import { validateEnvironment } from '../utils/validation.js'; export class HttpMCPServer { httpServer = null; options; registerHandlers; transports = new Map(); constructor(options, registerHandlers) { this.options = { transport: options.transport, httpPort: options.httpPort || config.mcp.http.port, httpHost: options.httpHost || config.mcp.http.host, httpEndpoint: options.httpEndpoint || config.mcp.http.endpoint, }; this.registerHandlers = registerHandlers; } async start() { logger.info('Starting Spryker MCP Server (HTTP)...'); logger.info(`Transport: ${this.options.transport}`); try { // Validate environment configuration validateEnvironment(); // Initialize HTTP server await this.initializeHttpServer(); logger.info(`Spryker MCP Server started successfully`); logger.info(`Server Name: ${config.server.name}`); logger.info(`Server Version: ${config.server.version}`); logger.info(`API Base URL: ${config.api.baseUrl}`); logger.info(`HTTP Server: http://${this.options.httpHost}:${this.options.httpPort}`); logger.info(`MCP Endpoint: ${this.options.httpEndpoint}`); } catch (error) { logger.error('Failed to start HTTP server:', error instanceof Error ? error : new Error(String(error))); throw error; } } async initializeHttpServer() { return new Promise((resolve, reject) => { this.httpServer = createServer((req, res) => { // Wrap the handler in a try-catch to prevent unhandled promise rejections this.handleHttpRequest(req, res).catch(error => { logger.error('HTTP request handler error:', error instanceof Error ? error : new Error(String(error))); // Only send error response if headers haven't been sent if (!res.headersSent) { try { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Internal server error', message: error instanceof Error ? error.message : 'Unknown error', })); } catch (responseError) { logger.error('Failed to send error response:', responseError instanceof Error ? responseError : new Error(String(responseError))); } } }); }); this.httpServer.listen(this.options.httpPort, this.options.httpHost, () => { logger.info(`HTTP server listening on ${this.options.httpHost}:${this.options.httpPort}`); logger.info(`MCP endpoint available at: http://${this.options.httpHost}:${this.options.httpPort}${this.options.httpEndpoint}`); resolve(); }); this.httpServer.on('error', (error) => { logger.error('HTTP server error:', error); reject(error); }); }); } async handleHttpRequest(req, res) { try { // Enable CORS res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id'); if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } const url = new URL(req.url || '/', `http://${req.headers.host}`); if (url.pathname === this.options.httpEndpoint) { if (req.method === 'POST') { // Handle MCP JSON-RPC messages via POST try { let body = ''; req.on('data', chunk => (body += chunk.toString())); req.on('end', async () => { try { const message = JSON.parse(body); logger.debug('Received MCP message via POST:', message); // Handle MCP requests using StreamableHTTPServerTransport await this.handleMCPRequest(req, res, message); } catch (parseError) { logger.error('Failed to parse POST body:', parseError instanceof Error ? parseError : new Error(String(parseError))); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error: Invalid JSON', }, id: null, })); } }); } catch (postError) { logger.error('POST request handling failed:', postError instanceof Error ? postError : new Error(String(postError))); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error', }, id: null, })); } } else if (req.method === 'GET') { // For GET requests, provide information about the MCP endpoint res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ message: 'MCP server is running', transport: this.options.transport, endpoint: this.options.httpEndpoint, instructions: 'Send JSON-RPC requests via POST', })); } else { res.writeHead(405, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed', }, id: null, })); } } else if (url.pathname === '/health') { // Health check endpoint res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'healthy', transport: this.options.transport, timestamp: new Date().toISOString(), })); } else { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Not found', }, id: null, })); } } catch (error) { logger.error('Request handling error:', error instanceof Error ? error : new Error(String(error))); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error', }, id: null, })); } } } async handleMCPRequest(req, res, message) { try { // Check for existing session ID in headers const sessionId = req.headers['mcp-session-id']; let transport; if (sessionId && this.transports.has(sessionId)) { // Reuse existing transport transport = this.transports.get(sessionId); logger.debug(`Reusing transport for session: ${sessionId}`); } else if (!sessionId && isInitializeRequest(message)) { // New initialization request - create new transport with JSON response mode logger.info('Creating new MCP transport for initialization request'); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: true, // Enable JSON response mode for HTTP onsessioninitialized: (newSessionId) => { logger.info(`MCP session initialized with ID: ${newSessionId}`); this.transports.set(newSessionId, transport); }, }); // Create a new server instance for this session const server = new Server({ name: config.server.name, version: config.server.version, }, { capabilities: { tools: {}, prompts: {}, logging: {}, }, }); // Register all handlers for this server instance this.registerHandlers(server); // Connect the transport to the server BEFORE handling the request await server.connect(transport); // Handle the initialization request // For VS Code compatibility, we need to handle the Accept header issue // VS Code might not send the required "application/json, text/event-stream" const originalAccept = req.headers.accept; if (!originalAccept || !originalAccept.includes('text/event-stream')) { // Temporarily modify the Accept header for VS Code compatibility req.headers.accept = 'application/json, text/event-stream'; logger.debug('Modified Accept header for VS Code compatibility'); } await transport.handleRequest(req, res, message); // Restore original header req.headers.accept = originalAccept; return; } else { // Invalid request - no session ID for non-initialization request logger.warn('Received non-initialization request without session ID'); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: Missing session ID for non-initialization request', }, id: message.id || null, })); return; } // Handle the request with existing transport // For VS Code compatibility, ensure proper Accept header const originalAccept = req.headers.accept; if (!originalAccept || !originalAccept.includes('text/event-stream')) { req.headers.accept = 'application/json, text/event-stream'; logger.debug('Modified Accept header for existing session VS Code compatibility'); } await transport.handleRequest(req, res, message); // Restore original header req.headers.accept = originalAccept; } catch (error) { logger.error('MCP request handling error:', error instanceof Error ? error : new Error(String(error))); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', }, id: message?.id || null, })); } } } async shutdown() { logger.info('Shutting down HTTP MCP Server...'); try { // Close all active transports for (const [sessionId, transport] of this.transports) { try { await transport.close(); logger.debug(`Closed transport for session: ${sessionId}`); } catch (error) { logger.error(`Error closing transport for session ${sessionId}:`, error instanceof Error ? error : new Error(String(error))); } } this.transports.clear(); // Close HTTP server if it exists if (this.httpServer) { await new Promise((resolve, reject) => { this.httpServer.close((error) => { if (error) { reject(error); } else { resolve(); } }); }); logger.info('HTTP server stopped'); } logger.info('HTTP server shutdown complete'); } catch (error) { logger.error('Error during HTTP server shutdown:', error instanceof Error ? error : new Error(String(error))); } } } //# sourceMappingURL=http-server.js.map