diff --git a/server.mjs b/server.mjs index 27ce9cb..ddcbb6f 100644 --- a/server.mjs +++ b/server.mjs @@ -3,11 +3,17 @@ * 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) + * - 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). - * Sourceful Riverflow models branch to /api/v1/images instead of /chat/completions. + * + * 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) @@ -43,6 +49,61 @@ if (!MCP_TOKEN) { const PORT = Number(process.env.PORT || 8935); const OUTPUT_DIR = process.env.IMAGE_MCP_OUTPUT_DIR || '/app/generated-images'; +// ---------- 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; @@ -64,14 +125,7 @@ async function getImageModels() { 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'; @@ -79,18 +133,43 @@ function aspectToOpenRouter(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 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 }); } -async function callChatCompletions({ prompt, model, aspect }) { +// 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 augmentedPrompt = ar ? `${prompt} (aspect ratio ${ar})` : prompt; + 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: { @@ -99,7 +178,7 @@ async function callChatCompletions({ prompt, model, aspect }) { }, body: JSON.stringify({ model, - messages: [{ role: 'user', content: augmentedPrompt }], + messages: buildChatMessages({ prompt, aspect, imageUrls }), modalities: ['image', 'text'], }), signal: AbortSignal.timeout(120_000), @@ -112,57 +191,135 @@ async function callChatCompletions({ prompt, model, aspect }) { 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)'); + 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 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). +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({ - model, - prompt, - size: sizeMap[aspect] || '1024x1024', - n: 1, - }), + body: JSON.stringify(body), 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 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(); - // 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' }; + 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) }); @@ -170,15 +327,28 @@ async function callRiverflowImages({ prompt, model, aspect }) { const buf = Buffer.from(await fetched.arrayBuffer()); return { dataUrl: `data:image/png;base64,${buf.toString('base64')}`, - provider: 'images_ep', + 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 { filepath, filename, bytes: bytes.length }; +} + // ---------- MCP server ---------- const server = new Server( - { name: 'mcp-img-gen', version: '1.0.0' }, + { name: 'mcp-img-gen', version: '1.1.0' }, { capabilities: { tools: {} } }, ); @@ -189,7 +359,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { { name: 'generate_image', description: - 'Generate an image via OpenRouter. Aspect: square|landscape|portrait. ' + + '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}.', inputSchema: { type: 'object', @@ -205,6 +378,53 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { 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. Empty array = no refs.', + default: [], + }, + }, + required: ['prompt'], + }, + }, + { + name: 'generate_image_transparent', + description: + 'Generate an image with a TRANSPARENT background (alpha channel PNG). ' + + 'Only works with models that support background removal natively: ' + + '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. ' + + 'Other models will return an error from the upstream. ' + + 'Aspect: square|landscape|portrait. ' + + 'Optional image_urls — use to apply style from reference images while ' + + 'keeping transparent background.', + inputSchema: { + type: 'object', + properties: { + prompt: { type: 'string', description: 'Text prompt (mention "isolated on transparent background" for best results)' }, + 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 images. Each item is an http(s):// URL OR ' + + 'a data:image/... URL. For Recraft v3: single reference. ' + + 'For GPT-Image: up to ~4 references recommended.', + default: [], + }, }, required: ['prompt'], }, @@ -224,38 +444,69 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { 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}` }], + content: [ + { + type: 'text', + text: `# OpenRouter image models (${MODEL_CACHE.length})\n\n${list}`, + }, + ], }; } - if (name === 'generate_image') { + if (name === 'generate_image' || name === 'generate_image_transparent') { 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'; + const imageUrls = normalizeImageUrls(args?.image_urls || []); - process.stderr.write(`[gen] req model=${model} aspect=${aspect} prompt="${prompt.slice(0, 80)}"\n`); + let model; + if (name === 'generate_image_transparent') { + model = args?.model || 'recraft/recraft-v3'; + if (!isTransparentCapable(model)) { + return errResp( + `Model ${model} is not in the transparent-capable list. ` + + `Use one of: ${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} image_ref(s) passed but model ${model} may not support refs. Will be forwarded anyway as chat-completions image_url parts or top-level image[] field.\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 } = 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`); + const { dataUrl, provider, mime } = await callOpenRouter({ + prompt, model, aspect, transparent, imageUrls, + }); + const { filepath, filename, bytes } = await saveImage({ + dataUrl, model, prefix: transparent ? 'transparent' : 'opaque', + }); + process.stderr.write( + `[gen] OK provider=${provider} mime=${mime} ${Date.now() - t0}ms ${filepath} ${bytes}b\n`, + ); return { content: [ { type: 'text', text: JSON.stringify( { + tool: name, model, provider, + mime, aspect, + transparent, + refs_used: imageUrls.length, filepath, filename, - bytes: bytes.length, + bytes, latency_ms: Date.now() - t0, }, null, @@ -286,7 +537,6 @@ const transport = new StreamableHTTPServerTransport({ 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}`; @@ -300,11 +550,10 @@ const httpServer = http.createServer(async (req, res) => { } } - // 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() })); + res.end(JSON.stringify({ ok: true, version: '1.1.0', uptime: process.uptime() })); return; }