import { randomUUID } from "node:crypto"; import http from "node:http"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { createServer } from "./server.js"; const PORT = parseInt(process.env.PORT || "3000", 10); const DEFAULT_SOLIDTIME_API_URL = process.env.SOLIDTIME_API_URL; interface Session { transport: StreamableHTTPServerTransport; createdAt: number; } const sessions = new Map(); // Clean up stale sessions every 10 minutes setInterval( () => { const now = Date.now(); const maxAge = 30 * 60 * 1000; // 30 minutes for (const [id, session] of sessions) { if (now - session.createdAt > maxAge) { session.transport.close().catch(() => {}); sessions.delete(id); } } }, 10 * 60 * 1000 ); function extractConfig(req: http.IncomingMessage) { const apiToken = req.headers["x-solidtime-api-token"] as string | undefined; const organizationId = req.headers["x-solidtime-organization-id"] as string | undefined; const apiUrl = (req.headers["x-solidtime-api-url"] as string | undefined) || DEFAULT_SOLIDTIME_API_URL; const timezone = req.headers["x-solidtime-timezone"] as string | undefined; if (!apiToken) { throw new Error( "Missing required header: x-solidtime-api-token. " + "Configure it in your MCP client settings." ); } return { apiToken, organizationId, apiUrl, timezone }; } function setCorsHeaders(res: http.ServerResponse) { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); res.setHeader( "Access-Control-Allow-Headers", "Content-Type, mcp-session-id, x-solidtime-api-token, x-solidtime-organization-id, x-solidtime-api-url, x-solidtime-timezone" ); res.setHeader("Access-Control-Expose-Headers", "mcp-session-id"); } const httpServer = http.createServer(async (req, res) => { setCorsHeaders(res); if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } if (req.url === "/health") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "ok", sessions: sessions.size })); return; } if (req.url !== "/mcp") { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found. Use /mcp for MCP protocol or /health for health check." })); return; } const sessionId = req.headers["mcp-session-id"] as string | undefined; try { // GET = SSE stream listener, DELETE = close session if (req.method === "GET" || req.method === "DELETE") { if (!sessionId || !sessions.has(sessionId)) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Invalid or missing session" })); return; } const session = sessions.get(sessionId)!; if (req.method === "DELETE") { await session.transport.close(); sessions.delete(sessionId); res.writeHead(204); res.end(); return; } await session.transport.handleRequest(req, res); return; } if (req.method === "POST") { const body = await new Promise((resolve, reject) => { let data = ""; req.on("data", (chunk: Buffer) => (data += chunk.toString())); req.on("end", () => resolve(data)); req.on("error", reject); }); const parsedBody = JSON.parse(body); if (!sessionId) { // Must be an initialization request const messages = Array.isArray(parsedBody) ? parsedBody : [parsedBody]; const isInit = messages.some(isInitializeRequest); if (!isInit) { res.writeHead(400, { "Content-Type": "application/json" }); res.end( JSON.stringify({ error: "Missing mcp-session-id header for non-initialization request", }) ); return; } // Extract credentials from headers and create a per-session MCP server const config = extractConfig(req); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), }); const mcpServer = await createServer(config); await mcpServer.connect(transport); const sid = transport.sessionId!; sessions.set(sid, { transport, createdAt: Date.now() }); transport.onclose = () => { sessions.delete(sid); }; await transport.handleRequest(req, res, parsedBody); } else { // Existing session const session = sessions.get(sessionId); if (!session) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Session not found. It may have expired." })); return; } // Refresh session timestamp session.createdAt = Date.now(); await session.transport.handleRequest(req, res, parsedBody); } return; } res.writeHead(405, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Method not allowed" })); } catch (err) { console.error("Request error:", err); if (!res.headersSent) { res.writeHead(500, { "Content-Type": "application/json" }); res.end( JSON.stringify({ error: err instanceof Error ? err.message : "Internal server error", }) ); } } }); httpServer.listen(PORT, "0.0.0.0", () => { console.log(`SolidTime MCP HTTP server listening on port ${PORT}`); console.log(` MCP endpoint: http://0.0.0.0:${PORT}/mcp`); console.log(` Health check: http://0.0.0.0:${PORT}/health`); if (DEFAULT_SOLIDTIME_API_URL) { console.log(` Default API URL: ${DEFAULT_SOLIDTIME_API_URL}`); } });