#!/usr/bin/env node /** * MCP image-gen server (thin custom wrapper around OpenRouter). * * Tools: * - generate_image(prompt, model, aspect) -> opaque PNG * - generate_image_transparent(prompt, model, aspect) -> PNG with alpha channel * - list_image_models() -> 35 image-capable models (1h cache) * * Auth: Bearer token in Authorization header (token from env MCP_TOKEN). * * Routing: * - sourceful/riverflow-* -> /api/v1/images (provider requires it) * - recraft/recraft-v* -> /api/v1/images (transparent_background flag) * - openai/gpt-image-* -> /chat/completions + image_config (background=transparent) * - everything else -> /chat/completions + modalities:[image,text] * * Design notes (from mcp-image-generation skill, validated 2026-07): * - Stateful StreamableHTTPServerTransport (sessionIdGenerator: crypto.randomUUID) * - try/catch around fetch AND transport.handleRequest * - AbortSignal.timeout(120_000) on upstream * - process.stderr.write on every request/response (SDK swallows errors) * - MODEL_CACHE 1h TTL */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; 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'; import path from 'node:path'; const API_KEY = process.env.OPENROUTER_API_KEY; const MCP_TOKEN = process.env.MCP_TOKEN; if (!API_KEY) { console.error('FATAL: OPENROUTER_API_KEY env not set'); process.exit(1); } if (!MCP_TOKEN) { console.error('FATAL: MCP_TOKEN env not set (required to protect the endpoint)'); process.exit(1); } const PORT = Number(process.env.PORT || 8935); const OUTPUT_DIR = process.env.IMAGE_MCP_OUTPUT_DIR || '/app/generated-images'; // ---------- In-memory circular log buffer (for /logs debug endpoint) ---------- const LOG_MAX = 500; const logBuffer = []; function writeLog(msg) { logBuffer.push(msg); if (logBuffer.length > LOG_MAX) logBuffer.shift(); process.stderr.write(msg); } function readLogBuffer(n) { return logBuffer.slice(-n).join(''); } // Override stderr.write to add timestamps and capture to buffer const origStdErrWrite = process.stderr.write.bind(process.stderr); process.stderr.write = (chunk, ...args) => { const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ''); const line = String(chunk); const tsLine = line.split('\n').filter(l => l).map(l => `[${ts}] ${l}`).join('\n') + '\n'; logBuffer.push(tsLine); if (logBuffer.length > LOG_MAX) logBuffer.shift(); return origStdErrWrite(tsLine, ...args); }; // ---------- Models that support transparent background ---------- // Recraft (v3 confirmed via OpenRouter /api/v1/images endpoint, accepts background param) // GPT-Image (1/2/5-image) via /chat/completions with image_config.background=transparent // // Order matters: riverflow check first because regex is more specific. const TRANSPARENT_MODELS = [ 'recraft/recraft-v3', 'openai/gpt-image-1', 'openai/gpt-image-1-mini', 'openai/gpt-image-2', 'openai/gpt-5-image', 'openai/gpt-5-image-mini', ]; // ---------- Models known to accept image references (image-to-image / style refs) ---------- // Order matters: more specific first. const IMAGE_REF_MODELS = new Set([ // OpenAI 'openai/gpt-image-1', 'openai/gpt-image-1-mini', 'openai/gpt-image-2', 'openai/gpt-5-image', 'openai/gpt-5-image-mini', 'openai/gpt-5.4-image-2', // Google Gemini 'google/gemini-2.5-flash-image', 'google/gemini-3-pro-image', 'google/gemini-3.1-flash-image', 'google/gemini-3.1-flash-lite-image', 'google/gemini-3.1-flash-image-preview', 'google/gemini-3-pro-image-preview', // ByteDance Seedream (style ref supported via seed image) 'bytedance-seed/seedream-4.5', // FLUX 2 (in editing variants) 'black-forest-labs/flux.2-flex', 'black-forest-labs/flux.2-pro', 'black-forest-labs/flux.2-max', ]); function isRiverflow(modelId) { return /^sourceful\/riverflow/i.test(modelId); } function isRecraft(modelId) { return /^recraft\//i.test(modelId); } function isGptImage(modelId) { return /^openai\/gpt-image-/i.test(modelId) || /^openai\/gpt-5-image/i.test(modelId); } function isTransparentCapable(modelId) { return TRANSPARENT_MODELS.includes(modelId); } // ---------- OpenRouter model catalog (cached 1h) ---------- let MODEL_CACHE = null; let MODEL_CACHE_TIME = 0; const MODEL_TTL_MS = 3600_000; async function getImageModels() { if (MODEL_CACHE && Date.now() - MODEL_CACHE_TIME < MODEL_TTL_MS) { return MODEL_CACHE; } const r = await fetch( 'https://openrouter.ai/api/v1/models?output_modalities=image', { headers: { Authorization: `Bearer ${API_KEY}` } }, ); if (!r.ok) throw new Error(`OpenRouter /models ${r.status}`); const data = await r.json(); MODEL_CACHE = data.data.map((m) => m.id); MODEL_CACHE_TIME = Date.now(); process.stderr.write(`[cache] refreshed: ${MODEL_CACHE.length} image models\n`); return MODEL_CACHE; } function aspectToOpenRouter(ar) { if (ar === 'square') return '1:1'; if (ar === 'landscape') return '16:9'; if (ar === 'portrait') return '9:16'; return ar; } // ---------- API call router ---------- async function callOpenRouter({ prompt, model, aspect, transparent = false, imageUrls = [] }) { if (isRiverflow(model)) return await callRiverflowImages({ prompt, model, aspect, imageUrls }); if (isRecraft(model)) return await callImagesEndpoint({ prompt, model, aspect, transparent, kind: 'recraft', imageUrls }); if (isGptImage(model)) return await callImagesEndpoint({ prompt, model, aspect, transparent, kind: 'gpt-image', imageUrls }); return await callChatCompletions({ prompt, model, aspect, imageUrls }); } // Helper: validate that an image reference is acceptable. // Accepts http(s) URLs and data:image/... URLs. Anything else -> warn and drop. function normalizeImageUrls(rawUrls) { if (!Array.isArray(rawUrls)) return []; const out = []; for (const u of rawUrls) { if (typeof u !== 'string' || !u) continue; if (/^https?:\/\//i.test(u)) { out.push(u); continue; } if (/^data:image\//i.test(u)) { out.push(u); continue; } process.stderr.write(`[ref] skipping unsupported ref URL: ${u.slice(0, 80)}\n`); } return out; } // Build a multimodal user message for chat completions: // { role: 'user', content: [ // { type: 'text', text: }, // { type: 'image_url', image_url: { url: } }, ... // ] } function buildChatMessages({ prompt, aspect, imageUrls }) { const ar = aspectToOpenRouter(aspect); const text = ar ? `${prompt} (aspect ratio ${ar})` : prompt; const content = [{ type: 'text', text }]; for (const url of imageUrls) { content.push({ type: 'image_url', image_url: { url } }); } return [{ role: 'user', content }]; } async function callChatCompletions({ prompt, model, aspect, imageUrls = [] }) { const resp = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model, messages: buildChatMessages({ prompt, aspect, imageUrls }), modalities: ['image', 'text'], }), signal: AbortSignal.timeout(120_000), }); if (!resp.ok) { const body = await resp.text(); throw new Error(`OpenRouter chat ${resp.status}: ${body.slice(0, 600)}`); } const data = await resp.json(); const images = data.choices?.[0]?.message?.images; if (!images?.length) { const text = data.choices?.[0]?.message?.content; throw new Error(`No images returned. Text content: ${JSON.stringify(text).slice(0, 300)}`); } const imgEntry = images.find((e) => e?.image_url?.url?.startsWith('data:image/')); if (!imgEntry) throw new Error('images[] has no data: URLs (unsupported shape)'); return { dataUrl: imgEntry.image_url.url, provider: 'chat_completions', mime: 'image/png', }; } async function callImagesEndpoint({ prompt, model, aspect, transparent, kind, imageUrls = [] }) { // Same endpoint shape for Recraft and GPT-Image inside OpenRouter: // POST /api/v1/images // body: { model, prompt, size, n, background?, image? } // // image (single) / image[] (multiple refs) are routed by OpenRouter to models // that accept them. Recraft v3 supports 1 image ref. GPT-Image (chat completions // path) prefers the messages[] multimodal format — but OpenRouter also routes // a single `image` field for some providers. We pass the image_urls through // in BOTH places when supported to maximize compatibility — providers that // ignore the unused field will still receive the prompt. const sizeMap = { square: '1024x1024', landscape: '1536x1024', portrait: '1024x1536', }; const body = { model, prompt, size: sizeMap[aspect] || '1024x1024', n: 1, }; if (transparent) { // OpenAI Images API standard: "background": "transparent" (also accepts "opaque") body.background = 'transparent'; } if (imageUrls.length > 0) { // OpenRouter accepts an array of reference image URLs via this top-level key. // Each provider may map it to its native convention (seed image, style ref, etc). body.image = imageUrls.length === 1 ? imageUrls[0] : imageUrls; } const resp = await fetch('https://openrouter.ai/api/v1/images', { method: 'POST', headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), signal: AbortSignal.timeout(120_000), }); if (!resp.ok) { const errBody = await resp.text(); throw new Error( `OpenRouter /api/v1/images (${kind}) ${resp.status}: ${errBody.slice(0, 600)}`, ); } const data = await resp.json(); const item = data?.data?.[0]; if (!item) { throw new Error(`${kind} returned empty data[]: ${JSON.stringify(data).slice(0, 300)}`); } // OpenRouter may return either b64_json directly, a data URL, or a hosted URL. // For GPT-Image and Recraft, b64_json is most common. let dataUrl = null; let mime = 'image/png'; if (item.b64_json) { dataUrl = `data:${mime};base64,${item.b64_json}`; } else if (item.url?.startsWith('data:')) { dataUrl = item.url; mime = item.url.match(/^data:([^;]+);/)?.[1] || 'image/png'; } else if (item.url) { // Hosted URL — fetch and inline-base64 const fetched = await fetch(item.url, { signal: AbortSignal.timeout(60_000) }); if (!fetched.ok) throw new Error(`${kind} URL fetch ${fetched.status}`); const ct = fetched.headers.get('content-type') || 'image/png'; mime = ct.split(';')[0].trim(); const buf = Buffer.from(await fetched.arrayBuffer()); dataUrl = `data:${mime};base64,${buf.toString('base64')}`; } else { throw new Error(`${kind} response has no url or b64_json`); } return { dataUrl, provider: `images_ep/${kind}`, mime }; } async function callRiverflowImages({ prompt, model, aspect, imageUrls = [] }) { // Sourceful Riverflow lives on /api/v1/images (not chat completions). // Riverflow accepts up to N reference images via `image_input` / // `image_inputs` array depending on the upstream. We pass both keys for safety. const sizeMap = { square: '1024x1024', landscape: '1536x1024', portrait: '1024x1536', }; const body = { model, prompt, size: sizeMap[aspect] || '1024x1024', n: 1, }; if (imageUrls.length > 0) { body.image_input = imageUrls; body.image_inputs = imageUrls; } const resp = await fetch('https://openrouter.ai/api/v1/images', { method: 'POST', headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), signal: AbortSignal.timeout(120_000), }); if (!resp.ok) { const errBody = await resp.text(); throw new Error( `OpenRouter /api/v1/images (riverflow) ${resp.status}: ${errBody.slice(0, 600)}`, ); } const data = await resp.json(); const item = data?.data?.[0]; if (!item) throw new Error('Riverflow returned empty data[]'); if (item.b64_json) { return { dataUrl: `data:image/png;base64,${item.b64_json}`, provider: 'images_ep/riverflow', mime: 'image/png', }; } if (item.url) { const fetched = await fetch(item.url, { signal: AbortSignal.timeout(60_000) }); if (!fetched.ok) throw new Error(`Riverflow URL fetch ${fetched.status}`); const buf = Buffer.from(await fetched.arrayBuffer()); return { dataUrl: `data:image/png;base64,${buf.toString('base64')}`, provider: 'images_ep/riverflow', mime: 'image/png', }; } throw new Error('Riverflow response has no url or b64_json'); } // ---------- Save helper ---------- async function saveImage({ dataUrl, model, prefix }) { const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, ''); const bytes = Buffer.from(base64, 'base64'); await fs.mkdir(OUTPUT_DIR, { recursive: true }); const safeModel = model.replace(/[/:]/g, '_'); const filename = `${prefix}-${safeModel}-${Date.now()}.png`; const filepath = path.join(OUTPUT_DIR, filename); await fs.writeFile(filepath, bytes); // Return Buffer under `bytes` (NOT Buffer.length) — the caller's // `bytes.toString('base64')` in the inline image block needs a real Buffer, // not a Number. `size` holds the byte length for metadata/logging. return { filepath, filename, bytes, size: bytes.length }; } // ---------- Server instances (one per transport) ---------- // Both instances get the same handlers — each serves one transport type. function registerHandlers(srv) { srv.setRequestHandler(ListToolsRequestSchema, async () => { const models = await getImageModels(); return { tools: [ { name: 'generate_image', description: 'Generate an opaque (background filled) image via OpenRouter. ' + 'Aspect: square|landscape|portrait. ' + 'Optional: image_urls — list of HTTP(S) or data: URLs of reference images ' + '(style ref, seed, i2i) — works with Gemini, GPT-Image, FLUX 2, Seedream. ' + 'Returns {model,filepath,bytes,provider,latency_ms}. ' + 'Use list_image_models for the full 35-model catalog.', inputSchema: { type: 'object', properties: { prompt: { type: 'string', description: 'Text prompt' }, model: { type: 'string', enum: ['google/gemini-2.5-flash-image', 'openai/gpt-image-2', 'recraft/recraft-v3', 'black-forest-labs/flux.2-flex', 'bytedance-seed/seedream-4.5', 'openai/gpt-image-1', 'microsoft/mai-image-2.5', 'x-ai/grok-imagine-image-quality'], default: 'google/gemini-2.5-flash-image', }, aspect: { type: 'string', enum: ['square', 'landscape', 'portrait'], default: 'square', }, image_urls: { type: 'array', items: { type: 'string' }, description: 'Optional reference images. Each item is an http(s):// URL OR ' + 'a data:image/... URL. Up to ~4 references recommended for best ' + 'compatibility.', }, }, required: ['prompt'], }, }, { name: 'generate_image_transparent', description: 'Generate an image with a TRANSPARENT background (alpha channel PNG). ' + 'Supported models: recraft/recraft-v3, openai/gpt-image-1, gpt-image-1-mini, ' + 'gpt-image-2, gpt-5-image, gpt-5-image-mini. ' + 'Aspect: square|landscape|portrait. ' + 'Optional image_urls for style references.', inputSchema: { type: 'object', properties: { prompt: { type: 'string', description: 'Text prompt' }, model: { type: 'string', enum: TRANSPARENT_MODELS, default: 'recraft/recraft-v3', }, aspect: { type: 'string', enum: ['square', 'landscape', 'portrait'], default: 'square', }, image_urls: { type: 'array', items: { type: 'string' }, description: 'Optional reference image URLs.', }, }, required: ['prompt'], }, }, { name: 'upload_image', description: 'Upload an image to use as reference. Returns a URL for image_urls.', inputSchema: { type: 'object', properties: { data: { type: 'string', description: 'base64 image data (with or without data: URI prefix)' }, }, required: ['data'], }, }, { name: 'get_image', description: 'Read a previously generated image by filename. Returns multipart content: ' + 'one inline image (base64) + one text metadata block. Use this if you want ' + 'to re-fetch an image after a generate_image call (file persists across ' + 'the same container lifetime but is lost on container restart). ' + 'Filename is the value returned in the generate_image metadata.', inputSchema: { type: 'object', properties: { filename: { type: 'string', description: 'Filename as returned in `filename` field from generate_image / ' + 'generate_image_transparent output (e.g. ' + '"opaque-google_gemini-2.5-flash-image-1783615606608.png").', }, }, required: ['filename'], }, }, ], }; }); // end ListToolsRequestSchema // ---- CallTool handler ---- srv.setRequestHandler(CallToolRequestSchema, async (req) => { const { name, arguments: args } = req.params; try { if (name === 'generate_image' || name === 'generate_image_transparent') { const prompt = String(args?.prompt || '').trim(); if (!prompt) return errResp('prompt required'); const aspect = args?.aspect || 'square'; const imageUrls = normalizeImageUrls(args?.image_urls || []); let model; if (name === 'generate_image_transparent') { model = args?.model || 'recraft/recraft-v3'; if (!isTransparentCapable(model)) return errResp(`Model ${model} not transparent-capable. Use: ${TRANSPARENT_MODELS.join(', ')}`); } else { model = args?.model || 'google/gemini-2.5-flash-image'; } const transparent = name === 'generate_image_transparent'; if (imageUrls.length > 0 && !IMAGE_REF_MODELS.has(model)) process.stderr.write(`[gen] WARN: ${imageUrls.length} refs on model that may not support them\n`); process.stderr.write(`[gen] req tool=${name} model=${model} aspect=${aspect} transparent=${transparent} refs=${imageUrls.length} prompt="${prompt.slice(0, 80)}"\n`); const t0 = Date.now(); const { dataUrl, provider, mime } = await callOpenRouter({ prompt, model, aspect, transparent, imageUrls }); const { filepath, filename, bytes, size } = await saveImage({ dataUrl, model, prefix: transparent ? 'transparent' : 'opaque' }); process.stderr.write(`[gen] OK provider=${provider} mime=${mime} ${Date.now() - t0}ms ${filepath} ${size}b\n`); return { content: [ { type: 'image', mimeType: mime, data: bytes.toString('base64') }, { type: 'text', text: JSON.stringify({ tool: name, model, provider, mime, aspect, transparent, refs_used: imageUrls.length, filepath, filename, bytes: size, latency_ms: Date.now() - t0, download_url: `https://img-gen-ai.dynv6.net/images/${filename}`, }, null, 2) }, ], } } if (name === 'get_image') { const filename = String(args?.filename || '').trim(); if (!filename) return errResp('filename required'); if (!/^[\w.-]+$/.test(filename)) return errResp('filename must be alphanumeric with dots/hyphens only'); const filepath = path.join(OUTPUT_DIR, filename); let buf; try { buf = await fs.readFile(filepath); } catch (e) { return errResp(`Image not found: ${filename}`); } const mime = filename.endsWith('.png') ? 'image/png' : filename.endsWith('.jpg') || filename.endsWith('.jpeg') ? 'image/jpeg' : filename.endsWith('.webp') ? 'image/webp' : 'application/octet-stream'; process.stderr.write(`[get] OK ${filename} ${buf.length}b\n`); return { content: [ { type: 'image', mimeType: mime, data: buf.toString('base64') }, { type: 'text', text: JSON.stringify({ filename, filepath, bytes: buf.length, mime, download_url: `https://img-gen-ai.dynv6.net/images/${filename}` }, null, 2) }, ], }; } if (name === 'upload_image') { const rawData = String(args?.data || '').trim(); if (!rawData) return errResp('data is required'); let buf, ext = 'png'; if (/^https?:\/\//i.test(rawData)) { const fetched = await fetch(rawData, { signal: AbortSignal.timeout(60_000) }); if (!fetched.ok) return errResp(`URL fetch ${fetched.status}`); const ct = fetched.headers.get('content-type') || ''; if (ct.includes('png')) ext = 'png'; else if (ct.includes('jpeg') || ct.includes('jpg')) ext = 'jpg'; else if (ct.includes('webp')) ext = 'webp'; buf = Buffer.from(await fetched.arrayBuffer()); } else { // data: URL or raw base64 const base64 = rawData.replace(/^data:image\/\w+;base64,/, ''); try { buf = Buffer.from(base64, 'base64'); } catch (e) { return errResp('Invalid base64 data'); } } await fs.mkdir(OUTPUT_DIR, { recursive: true }); const filename = args?.filename || `upload-${Date.now()}.${ext}`; if (!/^[\w.-]+$/.test(filename)) return errResp('Invalid filename'); const filepath = path.join(OUTPUT_DIR, filename); await fs.writeFile(filepath, buf); const downloadUrl = `https://img-gen-ai.dynv6.net/images/${filename}`; process.stderr.write(`[upload] ${filename} ${buf.length}b -> ${downloadUrl}\n`); return { content: [ { type: 'image', mimeType: `image/${ext}`, data: buf.toString('base64') }, { type: 'text', text: JSON.stringify({ filename, filepath, bytes: buf.length, download_url: downloadUrl }, null, 2) }, ], }; } return errResp(`Unknown tool: ${name}`); } catch (e) { process.stderr.write(`[gen-ERR] ${e.stack || e}\n`); return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true }; } }); // end CallToolRequestSchema } // end registerHandlers // ---------- Create MCP server instance ---------- const mcpServer = new Server( { name: 'mcp-img-gen', version: '1.2.0' }, { capabilities: { tools: {} } }, ); registerHandlers(mcpServer); function errResp(text) { return { content: [{ type: 'text', text }], isError: true }; } // ---------- HTTP transport w/ Bearer auth ---------- // Traditional SSE transport sessions (for Figma / older MCP clients) const sseSessions = new Map(); // Stateful Streamable HTTP transport (for Cursor / native MCP clients) const mcpTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), }); await mcpServer.connect(mcpTransport); const httpServer = http.createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); // Auth gate — skip public paths if (!url.pathname.startsWith('/health') && !url.pathname.startsWith('/images/') && !url.pathname.startsWith('/logs') && url.pathname !== '/favicon.ico') { const auth = req.headers.authorization || ''; const expected = `Bearer ${MCP_TOKEN}`; if (auth !== expected) { process.stderr.write(`[auth] denied ${req.method} ${url.pathname}\n`); res.statusCode = 401; res.setHeader('www-authenticate', 'Bearer'); res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ error: 'invalid or missing token' })); return; } } // 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; } // Recent logs viewer (no auth, for debugging Figma connections) if (url.pathname === '/logs') { const maxLines = parseInt(url.searchParams.get('n') || '50', 10); const logLines = readLogBuffer(maxLines); res.statusCode = 200; res.setHeader('content-type', 'text/plain; charset=utf-8'); res.end(logLines); return; } // Suppress favicon requests if (url.pathname === '/favicon.ico') { res.statusCode = 204; res.end(); return; } process.stderr.write(`[HTTP] ${req.method} ${url.pathname} -> incoming\n`); try { // ---- Serve saved images directly (GET /images/) ---- if (url.pathname.startsWith('/images/') && req.method === 'GET') { const filename = url.pathname.slice('/images/'.length); // Security: prevent path traversal if (!filename || filename.includes('/') || filename.includes('\\') || filename.includes('..')) { res.statusCode = 400; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ error: 'invalid filename' })); return; } const filepath = path.join(OUTPUT_DIR, filename); try { const buf = await fs.readFile(filepath); const ext = path.extname(filename).toLowerCase(); const mime = ext === '.png' ? 'image/png' : ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : ext === '.webp' ? 'image/webp' : 'application/octet-stream'; res.statusCode = 200; res.setHeader('content-type', mime); res.setHeader('content-length', buf.length); res.setHeader('cache-control', 'public, max-age=3600'); res.end(buf); } catch (e) { res.statusCode = 404; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ error: 'file not found' })); } process.stderr.write(`[image] GET ${filename} -> ${res.statusCode}\n`); return; } // ---- 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); res.on('close', () => { sseSessions.delete(sessionId); process.stderr.write(`[sse] session ${sessionId} closed\n`); }); // Each SSE session gets its own server instance (can't reuse mcpServer) const sseServer = new Server( { name: 'mcp-img-gen', version: '1.2.0' }, { capabilities: { tools: {} } }, ); registerHandlers(sseServer); await sseServer.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) { res.statusCode = 500; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ error: e.message })); } } }); httpServer.listen(PORT, '0.0.0.0', () => { process.stderr.write(`mcp-img-gen listening on http://0.0.0.0:${PORT}\n`); });