UNPKG

mcp-grocy

Version:

Model Context Protocol (MCP) server for Grocy integration

219 lines (218 loc) 9.93 kB
import express from 'express'; import { randomUUID } from 'crypto'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { VERSION, PACKAGE_NAME as SERVER_NAME } from '../version.js'; import cors from 'cors'; import http from 'http'; import { logger } from '../utils/logger.js'; // HTTP Transport for MCP (Context7 style) export function startHttpServer(mcpServer, port = 8080) { return new Promise((resolve, reject) => { const app = express(); // Enable JSON body parsing with increased limit app.use(express.json({ limit: '10mb' })); // Enable CORS for all routes app.use(cors({ origin: '*', methods: ['GET', 'POST', 'OPTIONS'], allowedHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Mcp-Session-Id', 'Authorization'], exposedHeaders: ['Mcp-Session-Id', 'Content-Type'], optionsSuccessStatus: 200 })); // Simple health check endpoint app.get('/', (_req, res) => { res.json({ status: 'ok', service: SERVER_NAME, version: VERSION, message: 'MCP server is running', endpoints: { streamable: '/mcp', sse: '/mcp/sse', sseMessages: '/mcp/messages' } }); }); // Session management for transports const streamableTransports = {}; const sseTransports = {}; const sseServerInstances = {}; // Simplified request logging app.use((req, _res, next) => { logger.server(`${req.method} ${req.path}`); next(); }); // Helper function to get server instance (shared or new) const getServerInstance = () => { if (typeof mcpServer === 'function') { return mcpServer(); // Create new instance } else { return mcpServer; // Use shared instance } }; // Streamable HTTP endpoint (Context7 modern) app.post('/mcp', async (req, res) => { try { const clientSessionId = req.headers['mcp-session-id']; let transport = undefined; // Accept header check (can be done early) const accept = req.headers.accept || ''; if (!accept.includes('application/json') && !accept.includes('text/event-stream')) { logger.error('Client must accept application/json or text/event-stream', 'server'); res.status(406).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Not Acceptable: Client must accept application/json or text/event-stream' }, id: req.body?.id || null }); return; } if (clientSessionId) { transport = streamableTransports[clientSessionId]; if (!transport) { res.status(400).json({ jsonrpc: '2.0', error: { code: -32001, message: `Invalid or expired session ID: ${clientSessionId}. Please re-initialize.` }, id: req.body?.id || null }); return; } } else { // No session ID provided by client, create new transport const newGeneratedSessionId = randomUUID(); const newTransportInstance = new StreamableHTTPServerTransport({ sessionIdGenerator: () => newGeneratedSessionId }); transport = newTransportInstance; streamableTransports[newGeneratedSessionId] = transport; transport.onclose = () => { const closedSessionId = transport?.sessionId || newGeneratedSessionId; delete streamableTransports[closedSessionId]; }; const serverInstance = getServerInstance(); await serverInstance.connect(transport); // Type assertion to work around SDK type issue } if (!transport) { logger.error('Transport is undefined before handling request. This should not happen.', 'server'); res.status(500).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Internal server error: Transport not available' }, id: req.body?.id || null }); return; } if (transport.sessionId) { res.setHeader('Mcp-Session-Id', transport.sessionId); } await transport.handleRequest(req, res, req.body); } catch (error) { logger.error('Failed to handle streamable HTTP request', 'server', { error }); // Send error response if headers not sent yet if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32000, message: `Internal server error: ${error instanceof Error ? error.message : String(error)}` }, id: req.body?.id || null }); } } }); // SSE endpoint app.get('/mcp/sse', async (_req, res) => { try { // Set SSE headers before creating transport res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); const transport = new SSEServerTransport('/mcp/messages', res); const sessionId = transport.sessionId; sseTransports[sessionId] = transport; // Handle connection cleanup const cleanup = () => { delete sseTransports[sessionId]; delete sseServerInstances[sessionId]; }; res.on('close', cleanup); res.on('error', (err) => { logger.error(`SSE connection error for session ${sessionId}`, 'server', { error: err }); cleanup(); }); // Create isolated server instance for this SSE connection to prevent response cross-talk const isolatedServer = getServerInstance(); sseServerInstances[sessionId] = isolatedServer; // Connect transport to isolated MCP server (non-blocking) isolatedServer.connect(transport).catch((error) => { logger.error(`Failed to connect SSE transport for session ${sessionId}`, 'server', { error }); cleanup(); if (!res.headersSent) { res.status(500).end(); } }); // Send initial comment to keep connection alive res.write(': connected\n\n'); } catch (error) { logger.error('Failed to handle SSE connection', 'server', { error }); if (!res.headersSent) { res.status(500).send('Internal Server Error'); } else { res.end(); } } }); // Message endpoint for SSE app.post('/mcp/messages', async (req, res) => { const sessionId = req.query.sessionId; if (!sessionId) { res.status(400).json({ error: 'Missing sessionId parameter', status: 400 }); return; } const transport = sseTransports[sessionId]; if (transport) { try { await transport.handlePostMessage(req, res, req.body); } catch (error) { logger.error(`Failed to handle SSE message for session ${sessionId}`, 'server', { error }); res.status(500).json({ error: `Internal server error: ${error instanceof Error ? error.message : String(error)}`, status: 500 }); } } else { res.status(404).json({ error: `No active SSE connection found for session ID: ${sessionId}`, status: 404 }); } }); // Create HTTP server with explicit error handling const server = http.createServer(app); server.on('error', (error) => { logger.error(`HTTP server error: ${error.message}`, 'server'); reject(error); }); // Start the server server.listen(port, () => { logger.server(`HTTP server listening on port ${port}`); logger.server(`Available endpoints:`); logger.server(` - Health check: http://localhost:${port}/`); logger.server(` - Streamable HTTP: http://localhost:${port}/mcp`); logger.server(` - SSE: http://localhost:${port}/mcp/sse`); logger.server(` - SSE Messages: http://localhost:${port}/mcp/messages`); resolve(server); }); }); }