import http from "node:http"; import fs from "fs"; import path from "path"; import os from "os"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { MCP_PORT } from "./config/index.js"; import { sessionCredentials, sessionApiClients, clearSessionCredentials } from "./auth/index.js"; // Active SSE sessions const activeSessions = new Map(); /** * Create and start the MCP HTTP server for SSE transport */ export function startHttpServer(server) { const mcpHttpServer = http.createServer(async (req, res) => { const url = new URL(req.url ?? "/", `http://${req.headers.host}`); // Handle SSE connection (GET /sse) if (req.method === "GET" && url.pathname === "/sse") { console.error(`[MCP HTTP] GET /sse - New SSE connection from ${req.headers.origin || req.socket.remoteAddress}`); // Extract credentials from headers const token = req.headers['x-acai-token']; const tokenHash = req.headers['x-acai-token-hash']; const website = req.headers['x-acai-website']; // Create SSE transport const transport = new SSEServerTransport("/message", res); const sessionId = transport.sessionId; // Wrap the send method to add logging and fix SSE format const originalSend = transport.send.bind(transport); transport.send = async (message) => { try { if (!transport._sseResponse) { throw new Error('Not connected'); } // Serialize message and ensure it's valid for SSE format const messageJson = JSON.stringify(message); console.error(`[MCP HTTP] SSE.send - Session ${sessionId} - Sending message (${messageJson.substring(0, 100)}...)`); // SSE format: properly handle multiline data // If JSON contains newlines, each line must be prefixed with "data: " const lines = messageJson.split('\n'); let sseData = 'event: message\n'; if (lines.length === 1) { // Single line JSON sseData += `data: ${messageJson}\n\n`; } else { // Multiline JSON - prefix each line for (const line of lines) { sseData += `data: ${line}\n`; } sseData += '\n'; } transport._sseResponse.write(sseData); console.error(`[MCP HTTP] SSE.send - Session ${sessionId} - Message sent successfully (${sseData.length} bytes)`); } catch (error) { console.error(`[MCP HTTP] SSE.send - Session ${sessionId} - ERROR sending message:`, error.message); throw error; } }; console.error(`[MCP HTTP] GET /sse - Session ${sessionId} created`); // Store credentials for this session if (token && website) { sessionCredentials.set(sessionId, { token, tokenHash: tokenHash || null, website, profileName: 'http-session' }); console.error(`[MCP HTTP] GET /sse - Session ${sessionId} authenticated for ${website}`); } else { console.warn(`[MCP HTTP] GET /sse - Session ${sessionId} started without credentials`); } // Store session activeSessions.set(sessionId, transport); // Handle transport close transport.onclose = () => { console.error(`[MCP HTTP] GET /sse - Session ${sessionId} closed`); activeSessions.delete(sessionId); clearSessionCredentials(sessionId); }; // Connect server to transport try { console.error(`[MCP HTTP] GET /sse - Session ${sessionId} connecting server to transport...`); await server.connect(transport); console.error(`[MCP HTTP] GET /sse - Session ${sessionId} server connected successfully`); } catch (error) { console.error(`[MCP HTTP] GET /sse - Session ${sessionId} ERROR:`, error); activeSessions.delete(sessionId); clearSessionCredentials(sessionId); } return; } // Handle POST messages (POST /message?sessionId=xxx) if (req.method === "POST" && url.pathname === "/message") { const sessionId = url.searchParams.get("sessionId"); if (!sessionId) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Missing session ID" })); return; } const transport = activeSessions.get(sessionId); if (!transport) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Session not found" })); return; } // Read request body let body = ""; req.on("data", (chunk) => { body += chunk.toString(); }); req.on("end", async () => { try { console.error(`[MCP HTTP] POST /message - Session ${sessionId} - Received request, parsing...`); const parsedBody = JSON.parse(body); console.error(`[MCP HTTP] POST /message - Session ${sessionId} - Parsed body: ${JSON.stringify(parsedBody).substring(0, 100)}...`); // Track when handlePostMessage starts and ends const beforeTime = Date.now(); await transport.handlePostMessage(req, res, parsedBody); const afterTime = Date.now(); console.error(`[MCP HTTP] POST /message - Session ${sessionId} - handlePostMessage completed (took ${afterTime - beforeTime}ms)`); console.error(`[MCP HTTP] POST /message - Session ${sessionId} - Response headersSent: ${res.headersSent}, writableEnded: ${res.writableEnded}`); } catch (error) { console.error(`[MCP HTTP] POST /message - Session ${sessionId} - ERROR:`, error.message); console.error(`[MCP HTTP] POST /message - Session ${sessionId} - Stack:`, error.stack); if (!res.headersSent) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: error.message })); } } }); return; } // Health check if (req.method === "GET" && url.pathname === "/health") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "ok", activeSessions: activeSessions.size, mode: "sse" })); return; } // Serve generated images from temp folder if (req.method === "GET" && url.pathname.startsWith("/generated-images/")) { const fileId = url.pathname.split("/generated-images/")[1]; if (!fileId) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "File ID required" })); return; } try { const tempDir = process.env.GENERATED_IMAGES_TEMP_DIR || path.join(os.tmpdir(), 'generated-images'); const filename = `generated-${fileId}.jpg`; const filePath = path.join(tempDir, filename); // Security: ensure file is within temp directory (prevent path traversal) const resolvedPath = path.resolve(filePath); const resolvedTempDir = path.resolve(tempDir); if (!resolvedPath.startsWith(resolvedTempDir)) { res.writeHead(403, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Access denied" })); return; } // Check if file exists if (!fs.existsSync(filePath)) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "File not found" })); return; } // Read and serve file const fileBuffer = fs.readFileSync(filePath); res.writeHead(200, { "Content-Type": "image/jpeg", "Content-Length": fileBuffer.length, "Cache-Control": "public, max-age=3600" }); res.end(fileBuffer); return; } catch (error) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: error.message })); return; } } // 404 for other routes res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found" })); }); mcpHttpServer.on("error", (error) => { console.error(`[MCP] HTTP server error:`, error); }); mcpHttpServer.listen(MCP_PORT, () => { console.error(`[MCP] SSE server listening on http://localhost:${MCP_PORT}/sse`); console.error(`[MCP] Clients should connect to: http://localhost:${MCP_PORT}/sse`); console.error(`[MCP] Provide credentials via headers: X-Acai-Token, X-Acai-Website, X-Acai-Token-Hash`); }); return mcpHttpServer; } export { activeSessions };