feat: generate_image_transparent + image_urls support
This commit is contained in:
1 parent
850a5242c1
commit
0200cb0b94
1 file changed
+314
-65
+314
-65
@@ -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: <prompt + aspect suffix> },
|
||||
// { type: 'image_url', image_url: { url: <ref> } }, ...
|
||||
// ] }
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user