From ba50fe729d8d0807569893f7d60dc6156ac02181 Mon Sep 17 00:00:00 2001 From: hermes Date: Thu, 9 Jul 2026 17:48:45 +0200 Subject: [PATCH] initial: mcp-img-gen server.mjs + Dockerfile --- .gitignore | 5 + Dockerfile | 20 ++++ README.md | 49 ++++++++ package.json | 15 +++ server.mjs | 327 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 416 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 package.json create mode 100644 server.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..552ad43 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +generated-images/ +.env +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e016d1a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:22-slim + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev + +COPY server.mjs ./ + +ENV PORT=8935 +ENV IMAGE_MCP_OUTPUT_DIR=/app/generated-images + +RUN mkdir -p /app/generated-images + +EXPOSE 8935 + +HEALTHCHECK --interval=10s --timeout=3s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8935/health || exit 1 + +CMD ["node", "server.mjs"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7fdfd0a --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# mcp-img-gen + +Self-hosted MCP server for image generation via OpenRouter. + +## Tools + +- `generate_image(prompt, model?, aspect?)` — generates an image, saves to `/app/generated-images/` +- `list_image_models()` — returns ~35 image-capable models (1h cache) + +## Endpoints + +- `/mcp` — MCP Streamable HTTP transport (Bearer-auth required) +- `/health` — liveness probe (no auth) + +## Auth + +Required on every `/mcp*` request: + +``` +Authorization: Bearer $MCP_TOKEN +``` + +## Riverflow note + +Sourceful Riverflow models (`sourceful/riverflow-*`) live on `/api/v1/images` +instead of `/chat/completions` — server branches automatically by model id. + +## Deploy (Coolify) + +Build pack: Dockerfile (default). Port `8935` exposed on the container. +Env vars required: + +- `OPENROUTER_API_KEY` +- `MCP_TOKEN` + +Volume: `/app/generated-images` (persistent). + +## Client config + +```json +{ + "mcpServers": { + "img-gen": { + "url": "https://.193.43.72.4.sslip.io/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..0dd91b1 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "mcp-img-gen", + "version": "1.0.0", + "description": "Self-hosted MCP server for image generation via OpenRouter", + "type": "module", + "scripts": { + "start": "node server.mjs" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4" + }, + "engines": { + "node": ">=20" + } +} diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..27ce9cb --- /dev/null +++ b/server.mjs @@ -0,0 +1,327 @@ +#!/usr/bin/env node +/** + * MCP image-gen server (thin custom wrapper around OpenRouter). + * + * Tools: + * - generate_image(prompt, model, aspect) -> saves PNG to IMAGE_MCP_OUTPUT_DIR + * - list_image_models() -> 35 image-capable models (1h cache) + * + * Auth: Bearer token in Authorization header (token from env MCP_TOKEN). + * Sourceful Riverflow models branch to /api/v1/images instead of /chat/completions. + * + * 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 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'; + +// ---------- 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 isRiverflow(modelId) { + return /^sourceful\/riverflow/i.test(modelId); +} + +function aspectToOpenRouter(ar) { + // OpenRouter accepts 1:1 / 16:9 / 9:16 etc. via image_config.aspect_ratio (vendor-specific, optional). + // To stay safe we just include the keyword in the prompt — let the model decide when the + // vendor-specific field is unsupported. + 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 }) { + // Riverflow models: OpenRouter exposes /api/v1/images for them (chat endpoint returns 404 + // even though models appear in the ?output_modalities=image list — verified 2026-07-05). + if (isRiverflow(model)) { + return await callRiverflowImages({ prompt, model, aspect }); + } + return await callChatCompletions({ prompt, model, aspect }); +} + +async function callChatCompletions({ prompt, model, aspect }) { + const ar = aspectToOpenRouter(aspect); + const augmentedPrompt = ar ? `${prompt} (aspect ratio ${ar})` : prompt; + 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: [{ role: 'user', content: augmentedPrompt }], + 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)}`, + ); + } + // Pick first image_url entry that has a data: URL + 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', + }; +} + +async function callRiverflowImages({ prompt, model, aspect }) { + // OpenRouter /api/v1/images accepts a Shape similar to OpenAI Images API. + // Token is sent as Bearer (same as chat completions). + const sizeMap = { + square: '1024x1024', + landscape: '1536x1024', + portrait: '1024x1536', + }; + const resp = await fetch('https://openrouter.ai/api/v1/images', { + method: 'POST', + headers: { + Authorization: `Bearer ${API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model, + prompt, + size: sizeMap[aspect] || '1024x1024', + n: 1, + }), + signal: AbortSignal.timeout(120_000), + }); + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`OpenRouter images ${resp.status}: ${body.slice(0, 600)}`); + } + const data = await resp.json(); + // OpenAI-style response: { data: [{ url, b64_json }] } + const item = data?.data?.[0]; + if (!item) throw new Error('Riverflow returned empty data[]'); + // Prefer b64_json; fall back to URL fetch + if (item.b64_json) { + const mime = 'image/png'; + return { dataUrl: `data:${mime};base64,${item.b64_json}`, provider: 'images_ep' }; + } + 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', + }; + } + throw new Error('Riverflow response has no url or b64_json'); +} + +// ---------- MCP server ---------- +const server = new Server( + { name: 'mcp-img-gen', version: '1.0.0' }, + { capabilities: { tools: {} } }, +); + +server.setRequestHandler(ListToolsRequestSchema, async () => { + const models = await getImageModels(); + return { + tools: [ + { + name: 'generate_image', + description: + 'Generate an image via OpenRouter. Aspect: square|landscape|portrait. ' + + 'Returns {model,filepath,bytes,provider,latency_ms}.', + inputSchema: { + type: 'object', + properties: { + prompt: { type: 'string', description: 'Text prompt' }, + model: { + type: 'string', + enum: models, + default: 'google/gemini-2.5-flash-image', + }, + aspect: { + type: 'string', + enum: ['square', 'landscape', 'portrait'], + default: 'square', + }, + }, + required: ['prompt'], + }, + }, + { + name: 'list_image_models', + description: 'List 35 image-capable models on OpenRouter (cached 1h).', + inputSchema: { type: 'object', properties: {} }, + }, + ], + }; +}); + +server.setRequestHandler(CallToolRequestSchema, async (req) => { + const { name, arguments: args } = req.params; + try { + if (name === 'list_image_models') { + const list = (await getImageModels()).join('\n'); + return { + content: [{ type: 'text', text: `# OpenRouter image models (${MODEL_CACHE.length})\n\n${list}` }], + }; + } + if (name === 'generate_image') { + const prompt = String(args?.prompt || '').trim(); + if (!prompt) return errResp('prompt required'); + const model = args?.model || 'google/gemini-2.5-flash-image'; + const aspect = args?.aspect || 'square'; + + process.stderr.write(`[gen] req model=${model} aspect=${aspect} prompt="${prompt.slice(0, 80)}"\n`); + const t0 = Date.now(); + const { dataUrl, provider } = await callOpenRouter({ prompt, model, aspect }); + 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 = `${safeModel}-${Date.now()}.png`; + const filepath = path.join(OUTPUT_DIR, filename); + await fs.writeFile(filepath, bytes); + process.stderr.write(`[gen] OK provider=${provider} ${Date.now() - t0}ms ${filepath} ${bytes.length}b\n`); + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + model, + provider, + aspect, + filepath, + filename, + bytes: bytes.length, + latency_ms: Date.now() - t0, + }, + 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, + }; + } +}); + +function errResp(text) { + return { content: [{ type: 'text', text }], isError: true }; +} + +// ---------- HTTP transport w/ Bearer auth ---------- +const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), +}); +await server.connect(transport); + +const httpServer = http.createServer(async (req, res) => { + // Auth gate (Skips the public /health endpoint). + if (!req.url.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`); + 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 (req.url === '/health') { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ ok: true, version: '1.0.0', uptime: process.uptime() })); + return; + } + + process.stderr.write(`[HTTP] ${req.method} ${req.url} -> incoming\n`); + try { + await transport.handleRequest(req, res); + process.stderr.write(`[HTTP] ${req.method} ${req.url} -> ${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`); +});