745 lines
29 KiB
JavaScript
745 lines
29 KiB
JavaScript
#!/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 also capture to our buffer
|
|
const origStdErrWrite = process.stderr.write.bind(process.stderr);
|
|
process.stderr.write = (chunk, ...args) => {
|
|
logBuffer.push(String(chunk));
|
|
if (logBuffer.length > LOG_MAX) logBuffer.shift();
|
|
return origStdErrWrite(chunk, ...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: <prompt + aspect suffix> },
|
|
// { type: 'image_url', image_url: { url: <ref> } }, ...
|
|
// ] }
|
|
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. 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'],
|
|
},
|
|
},
|
|
{
|
|
name: 'upload_image',
|
|
description:
|
|
'Upload a reference image to the server for use in image_urls. ' +
|
|
'Accepts a base64 data URL (e.g. "data:image/png;base64,iVBOR...") or ' +
|
|
'an HTTP(S) URL. Returns a download_url that can be passed to generate_image ' +
|
|
'or generate_image_transparent as image_urls entry. ' +
|
|
'The uploaded file persists only while the container is alive.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
data: {
|
|
type: 'string',
|
|
description: 'Base64-encoded image data (raw base64 string, not including data: URI prefix) OR a full data:image URL OR an http(s):// URL to fetch.',
|
|
},
|
|
filename: {
|
|
type: 'string',
|
|
description: 'Optional filename hint (e.g. "ref.png"). Auto-generated if omitted.',
|
|
default: '',
|
|
},
|
|
},
|
|
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 / modern 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/<filename>) ----
|
|
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`);
|
|
});
|