From 24eff3a35acc227643f4f62b3c9bb29169551cef Mon Sep 17 00:00:00 2001 From: hermes Date: Thu, 9 Jul 2026 19:51:35 +0200 Subject: [PATCH] fix: add /sse + /messages endpoints for Figma compatibility --- server.mjs | 57 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/server.mjs b/server.mjs index 9631ede..929ff2b 100644 --- a/server.mjs +++ b/server.mjs @@ -29,6 +29,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import http from 'node:http'; import crypto from 'node:crypto'; import fs from 'node:fs/promises'; @@ -594,17 +595,25 @@ function errResp(text) { } // ---------- HTTP transport w/ Bearer auth ---------- -const transport = new StreamableHTTPServerTransport({ + +// Traditional SSE transport sessions (for Figma / older MCP clients) +const sseSessions = new Map(); + +// Stateful Streamable HTTP transport (for Cursor / modern clients) +const mcpTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), }); -await server.connect(transport); +await server.connect(mcpTransport); const httpServer = http.createServer(async (req, res) => { - if (!req.url.startsWith('/health')) { + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + + // Auth gate — skip /health only + if (!url.pathname.startsWith('/health')) { const auth = req.headers.authorization || ''; const expected = `Bearer ${MCP_TOKEN}`; if (auth !== expected) { - process.stderr.write(`[auth] denied ${req.method} ${req.url}\n`); + process.stderr.write(`[auth] denied ${req.method} ${url.pathname}\n`); res.statusCode = 401; res.setHeader('www-authenticate', 'Bearer'); res.setHeader('content-type', 'application/json'); @@ -613,17 +622,49 @@ const httpServer = http.createServer(async (req, res) => { } } - if (req.url === '/health') { + // Lightweight /health for Coolify / Traefik probes + if (url.pathname === '/health') { res.statusCode = 200; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ ok: true, version: '1.2.0', uptime: process.uptime() })); return; } - process.stderr.write(`[HTTP] ${req.method} ${req.url} -> incoming\n`); + process.stderr.write(`[HTTP] ${req.method} ${url.pathname} -> incoming\n`); + try { - await transport.handleRequest(req, res); - process.stderr.write(`[HTTP] ${req.method} ${req.url} -> ${res.statusCode}\n`); + // ---- Traditional SSE transport (for Figma) ---- + if (url.pathname === '/sse' && req.method === 'GET') { + const sseTransport = new SSEServerTransport('/messages', res); + const sessionId = sseTransport.sessionId; + sseSessions.set(sessionId, sseTransport); + // Clean up on disconnect + res.on('close', () => { + sseSessions.delete(sessionId); + process.stderr.write(`[sse] session ${sessionId} closed\n`); + }); + await server.connect(sseTransport); + process.stderr.write(`[sse] session ${sessionId} established\n`); + return; + } + + if (url.pathname === '/messages' && req.method === 'POST') { + const sessionId = url.searchParams.get('sessionId'); + if (!sessionId || !sseSessions.has(sessionId)) { + res.statusCode = 400; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: `SSE session ${sessionId || '(missing)'} not found` })); + return; + } + const sseTransport = sseSessions.get(sessionId); + await sseTransport.handlePostMessage(req, res); + return; + } + + // ---- Unified /mcp transport (Streamable HTTP) ---- + // Must go last — catches any path that isn't /health /sse /messages + await mcpTransport.handleRequest(req, res); + process.stderr.write(`[mcp] ${req.method} ${url.pathname} -> ${res.statusCode}\n`); } catch (e) { process.stderr.write(`[HTTP-ERR] ${e.stack || e}\n`); if (!res.headersSent) {