UNPKG

mcp-grocy-api

Version:

Model Context Protocol (MCP) wrapper for grocy API

242 lines (241 loc) 12.6 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'; // HTTP Transport for MCP (Context7 style) export function startHttpServer(mcpServer, port = 8080) { 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 = {}; // Middleware to log all requests app.use((req, res, next) => { console.error(`[${new Date().toISOString()}] ${req.method} ${req.path} - Headers: ${JSON.stringify(req.headers)}`); next(); }); // 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')) { console.error('[ERROR] Client must accept application/json or text/event-stream'); 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) { console.error(`[DEBUG] Using existing transport for session ID: ${clientSessionId}`); } else { console.error(`[ERROR] Invalid or expired session ID provided: ${clientSessionId}. No active transport found.`); 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, this should be an 'initialize' request or similar. console.error('[DEBUG] No session ID provided by client. Creating new transport.'); const newGeneratedSessionId = randomUUID(); // Always generate a fresh ID for a new transport. const newTransportInstance = new StreamableHTTPServerTransport({ sessionIdGenerator: () => { // This generator is called by the transport. We want it to use the ID we just generated. console.error(`[DEBUG] StreamableHTTPServerTransport internal sessionIdGenerator using pre-generated ID: ${newGeneratedSessionId}`); return newGeneratedSessionId; }, onsessioninitialized: (initializedSid) => { console.error(`[DEBUG] Event: StreamableHTTPServerTransport internal session initialized with actual ID: ${initializedSid}.`); if (initializedSid !== newGeneratedSessionId) { console.error(`[CRITICAL_WARNING] Mismatch between generated session ID ${newGeneratedSessionId} and transport's initialized ID ${initializedSid}!`); } } }); transport = newTransportInstance; // Verify the transport's public sessionId property matches our generated ID. // This relies on the transport setting its public .sessionId based on its sessionIdGenerator. if (!transport.sessionId || transport.sessionId !== newGeneratedSessionId) { console.error(`[CRITICAL_ERROR] Newly created transport's public sessionId ('${transport.sessionId}') does not match expected new ID ('${newGeneratedSessionId}'). This may indicate an issue with the transport's constructor or sessionId property assignment.`); // If the transport.sessionId is not correctly set by its constructor based on sessionIdGenerator, // session management will fail. We should not try to force it here as it hides an SDK issue. } console.error(`[DEBUG] New transport created. Effective session ID: ${transport.sessionId || 'undefined!'}. Registering with ID: ${newGeneratedSessionId}.`); // We must use newGeneratedSessionId as the key, as that's what the transport *should* be using. streamableTransports[newGeneratedSessionId] = transport; transport.onclose = () => { // Use newGeneratedSessionId in closure for safety, or rely on transport.sessionId if consistently set. const closedSessionId = transport?.sessionId || newGeneratedSessionId; if (streamableTransports[closedSessionId]) { console.error(`[DEBUG] Transport for session ID: ${closedSessionId} closed. Removing from active transports.`); delete streamableTransports[closedSessionId]; } else { console.error(`[DEBUG] Transport for session ID: ${closedSessionId} closed, but was already removed or not found in active transports.`); } }; console.error(`[DEBUG] Connecting new transport (intended ID: ${newGeneratedSessionId}, actual transport.sessionId: ${transport.sessionId}) to MCP server`); await mcpServer.connect(transport); console.error(`[DEBUG] New transport (ID: ${transport.sessionId}) connected successfully`); } if (!transport) { console.error('[CRITICAL_ERROR] Transport is undefined before handling request. This should not happen.'); 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) { console.error(`[DEBUG] Setting Mcp-Session-Id header to: ${transport.sessionId}`); res.setHeader('Mcp-Session-Id', transport.sessionId); } else { console.error(`[WARNING] Transport instance (client sent ID: ${clientSessionId || 'none'}) has no session ID to set in response headers. This is unexpected.`); } await transport.handleRequest(req, res, req.body); } catch (error) { console.error('[ERROR] Failed to handle streamable HTTP request:', 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) => { console.error('[DEBUG] Incoming SSE connection request'); 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; console.error(`[DEBUG] Created SSE transport with session ID: ${sessionId}`); sseTransports[sessionId] = transport; // Handle connection close res.on('close', () => { console.error(`[DEBUG] SSE connection closed for session ID: ${sessionId}`); delete sseTransports[sessionId]; }); // Adding event for error handling res.on('error', (err) => { console.error(`[ERROR] SSE connection error for session ID: ${sessionId}:`, err); delete sseTransports[sessionId]; }); // Connect transport to MCP server console.error('[DEBUG] Connecting SSE transport to MCP server'); await mcpServer.connect(transport); console.error(`[DEBUG] SSE transport connected for session ${sessionId}`); // Send initial comment to keep connection alive res.write(': connected\n\n'); } catch (error) { console.error('[ERROR] Failed to handle SSE connection:', error); // Send error response if headers not sent yet if (!res.headersSent) { res.status(500).send('Internal Server Error'); } else { // If headers are sent, we need to end the response res.end(); } } }); // Message endpoint for SSE app.post('/mcp/messages', async (req, res) => { const sessionId = req.query.sessionId; console.error(`[DEBUG] Incoming message for SSE session ID: ${sessionId}`); if (!sessionId) { console.error('[ERROR] No sessionId provided in request'); res.status(400).json({ error: 'Missing sessionId parameter', status: 400 }); return; } const transport = sseTransports[sessionId]; if (transport) { try { console.error(`[DEBUG] Found transport for session ID: ${sessionId}, handling message`); await transport.handlePostMessage(req, res, req.body); } catch (error) { console.error(`[ERROR] Failed to handle SSE message for session ID: ${sessionId}:`, error); res.status(500).json({ error: `Internal server error: ${error instanceof Error ? error.message : String(error)}`, status: 500 }); } } else { console.error(`[ERROR] No transport found for session ID: ${sessionId}`); 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) => { console.error(`[ERROR] HTTP server error: ${error.message}`); }); // Start the server server.listen(port, () => { console.error(`[MCP] HTTP server listening on port ${port}`); console.error(`[MCP] Available endpoints:`); console.error(`[MCP] - Health check: http://localhost:${port}/`); console.error(`[MCP] - Streamable HTTP: http://localhost:${port}/mcp`); console.error(`[MCP] - SSE: http://localhost:${port}/mcp/sse`); console.error(`[MCP] - SSE Messages: http://localhost:${port}/mcp/messages`); }); return server; }