cc-haha/plugins/image-gen/mcp/image-gen-server.mjs
小橙子 506c4c8754
fix(image-gen): auto-detect /v1 prefix and return image_url blocks for inline rendering (#63)
- Add /v1 path auto-detection: when BASE_URL omits /v1 and gets 403/404,
  automatically retry with /v1 prefix (generate, edit, list_models)
- Add minimal params fallback after /v1 retry for providers that reject
  response_format (e.g. Agnes)
- Return image_url content block for URL results so Desktop IDE renders
  images inline in the conversation

Co-authored-by: 你的姓名 <you@example.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 22:55:12 +08:00

814 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* image-gen MCP Server
*
* Multi-provider image generation with automatic fallback.
* Supports any OpenAI-compatible /v1/images/generations endpoint.
* Zero external dependencies — uses Node.js built-in fetch + raw JSON-RPC over stdio.
*
* Compatible providers: Agnes, GPT-image-2, Gemini image, nano-banana, DALL-E,
* Flux, Stable Diffusion, and any OpenAI-compatible relay (New API / OneAPI style).
*/
import { createInterface } from 'readline'
// ─── Config ───────────────────────────────────────────────────────────────────
function isUnset(val) {
return !val || val.trim() === '' || val.startsWith('${user_config.')
}
function isPrivateHostname(hostname) {
// Strip IPv6 brackets: new URL() returns "[::1]" but we compare against "::1"
const h = hostname.replace(/^\[|\]$/g, '')
// IPv4-mapped IPv6: ::ffff:127.0.0.1 → extract trailing IPv4
const v4Mapped = h.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)
if (v4Mapped) return isPrivateHostname(v4Mapped[1])
// IPv4 private ranges
if (
h === 'localhost' ||
h === '127.0.0.1' ||
h === '0.0.0.0' ||
h.startsWith('169.254.') ||
h.startsWith('10.') ||
h.startsWith('192.168.') ||
/^172\.(1[6-9]|2\d|3[01])\./.test(h)
) return true
// IPv6 loopback / link-local / private
if (
h === '::1' ||
h.startsWith('fe80:') || // link-local
h.startsWith('fc00:') || // unique local (fc00::/7)
h.startsWith('fd') // unique local (fd00::/8)
) return true
return false
}
function validateUrlSafety(urlString, label) {
const parsed = new URL(urlString)
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw new Error(`${label}: 不允许的协议 ${parsed.protocol}。仅支持 http/https。`)
}
if (isPrivateHostname(parsed.hostname)) {
throw new Error(`${label}: 不允许访问内网地址 ${parsed.hostname.replace(/^\[|\]$/g, '')}`)
}
}
const MODEL_CAPABILITIES = {
// Agnes image models
'agnes-image-2.1-flash': { sizes: ['512x512', '768x768', '1024x1024'], edit: false, transparent: false, maxN: 4, format: 'url' },
'agnes-image-2.0-flash': { sizes: ['512x512', '768x768', '1024x1024'], edit: false, transparent: false, maxN: 4, format: 'url' },
// GPT image models
'gpt-image-2': { sizes: ['1024x1024', '1536x1024', '1024x1536', 'auto'], edit: true, transparent: true, maxN: 10, format: 'b64_json', notes: 'size must be multiple of 16, max 3840px' },
'gpt-image-1': { sizes: ['1024x1024', '1536x1024', '1024x1536', '256x256', '512x512', 'auto'], edit: true, transparent: true, maxN: 10, format: 'b64_json' },
// DALL-E models
'dall-e-3': { sizes: ['1024x1024', '1792x1024', '1024x1792'], edit: false, transparent: false, maxN: 1, format: 'url' },
'dall-e-2': { sizes: ['256x256', '512x512', '1024x1024'], edit: true, transparent: false, maxN: 10, format: 'url' },
// Gemini image
'gemini-2.5-flash-image-preview': { sizes: ['1024x1024', '512x512', '1536x1536'], edit: true, transparent: false, maxN: 4, format: 'b64_json' },
'gemini-2.0-flash-exp-image-generation': { sizes: ['1024x1024', '512x512'], edit: false, transparent: false, maxN: 4, format: 'b64_json' },
// Flux models
'flux-schnell': { sizes: ['512x512', '768x768', '1024x1024', '1536x1024', '1024x1536'], edit: false, transparent: false, maxN: 4, format: 'url' },
'flux-pro': { sizes: ['512x512', '768x768', '1024x1024', '1536x1024', '1024x1536'], edit: false, transparent: true, maxN: 4, format: 'url' },
// Stable Diffusion
'stable-diffusion-xl': { sizes: ['512x512', '768x768', '1024x1024'], edit: true, transparent: false, maxN: 4, format: 'url' },
}
function getModelCapabilities(model) {
// 1. Exact match
if (MODEL_CAPABILITIES[model]) return MODEL_CAPABILITIES[model]
// 2. Prefix match: known model name is prefix of user's model (e.g. "gpt-image-2-turbo" starts with "gpt-image-2")
const lower = model.toLowerCase()
for (const [key, caps] of Object.entries(MODEL_CAPABILITIES)) {
if (lower.startsWith(key)) return caps
}
// 3. Contains match: user's model contains known name (e.g. "my-gpt-image-2-fork" contains "gpt-image-2")
for (const [key, caps] of Object.entries(MODEL_CAPABILITIES)) {
if (lower.includes(key)) return caps
}
// 4. Pattern-based defaults
if (/image/i.test(lower)) return { sizes: ['512x512', '1024x1024'], edit: false, transparent: false, maxN: 4, format: 'url' }
// Unknown
return null
}
function loadProviderFromEnv(prefix) {
const name = process.env[`${prefix}_NAME`]
const baseUrl = process.env[`${prefix}_BASE_URL`]
const apiKey = process.env[`${prefix}_API_KEY`]
const model = process.env[`${prefix}_MODEL`]
if (isUnset(baseUrl) || isUnset(apiKey) || isUnset(model)) return null
const cleanedUrl = baseUrl.replace(/\/+$/, '')
// Validate provider baseUrl is not a private/internal address
try {
validateUrlSafety(cleanedUrl, `${prefix}_BASE_URL`)
} catch (err) {
console.error(`[image-gen] Skipping ${prefix}: ${err.message}`)
return null
}
return {
name: (!isUnset(name) && name) || model,
baseUrl: cleanedUrl,
apiKey,
model,
enabled: true,
timeoutMs: 300_000,
capabilities: getModelCapabilities(model),
}
}
function loadProviders() {
const providers = []
for (let i = 1; i <= 3; i++) {
const p = loadProviderFromEnv(`IMAGE_GEN_P${i}`)
if (p) providers.push(p)
}
if (providers.length === 0) {
return []
}
return providers
}
// ─── HTTP helpers ─────────────────────────────────────────────────────────────
function trimSlash(s) {
return s.replace(/\/+$/, '')
}
function jsonHeaders(apiKey) {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
}
}
async function fetchWithTimeout(url, init, timeoutMs) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
return await fetch(url, { ...init, signal: controller.signal })
} finally {
clearTimeout(timer)
}
}
async function readJson(res) {
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { raw: text }
}
}
function pickError(data, fallback) {
if (data?.error) {
if (typeof data.error === 'string') return data.error
if (typeof data.error?.message === 'string') return data.error.message
}
if (typeof data?.message === 'string') return data.message
return fallback
}
// ─── Compatibility fallback (from spriteflow) ─────────────────────────────────
function shouldRetryWithMinimalPayload(error) {
const msg = error instanceof Error ? error.message : String(error || '')
return /ECONNRESET|connection was reset|unsupported.*response_format|unsupported.*background|unknown parameter|unrecognized parameter/i.test(msg)
}
function buildImageBody(model, prompt, size, n, transparent, minimal) {
const body = { model, prompt, n: n || 1, size: size || '1024x1024' }
if (!minimal) {
body.response_format = 'b64_json'
if (transparent) body.background = 'transparent'
}
return body
}
async function resolveImageResult(data) {
const first = data?.data?.[0]
if (!first) throw new Error('图像接口没有返回数据')
if (first.b64_json) return { type: 'base64', data: first.b64_json }
if (first.url) return { type: 'url', data: first.url }
throw new Error('图像响应缺少 b64_json 或 url')
}
// ─── Core: generate with provider fallback ────────────────────────────────────
function shouldRetryWithV1Prefix(baseUrl, error) {
const msg = error instanceof Error ? error.message : String(error || '')
return /HTTP (403|404)/.test(msg) && !/\/v1(\/|$)/.test(baseUrl)
}
async function generateWithFallback(prompt, size, n, transparent, providers) {
const errors = []
for (const provider of providers) {
if (!provider.enabled && provider.enabled !== undefined) continue
const baseUrl = trimSlash(provider.baseUrl)
const url = `${baseUrl}/images/generations`
const timeoutMs = provider.timeoutMs || 300_000
// Attempt 1: full params
try {
const body = buildImageBody(provider.model, prompt, size, n, transparent, false)
const res = await fetchWithTimeout(url, {
method: 'POST',
headers: jsonHeaders(provider.apiKey),
body: JSON.stringify(body),
}, timeoutMs)
const data = await readJson(res)
if (!res.ok) throw new Error(pickError(data, `HTTP ${res.status}`))
const result = await resolveImageResult(data)
return { ...result, provider: provider.name, model: provider.model, warnings: [] }
} catch (err1) {
// Attempt 2: retry with /v1 prefix if base URL is missing it (common misconfiguration)
if (shouldRetryWithV1Prefix(baseUrl, err1)) {
const v1Url = `${baseUrl}/v1/images/generations`
// Try /v1 with full params first
try {
const body = buildImageBody(provider.model, prompt, size, n, transparent, false)
const res = await fetchWithTimeout(v1Url, {
method: 'POST',
headers: jsonHeaders(provider.apiKey),
body: JSON.stringify(body),
}, timeoutMs)
const data = await readJson(res)
if (!res.ok) throw new Error(pickError(data, `HTTP ${res.status}`))
const result = await resolveImageResult(data)
return {
...result,
provider: provider.name,
model: provider.model,
warnings: [`[${provider.name}] 自动添加 /v1 前缀重试成功(建议更新 BASE_URL 为 ${baseUrl}/v1`],
}
} catch (err1v1) {
// Try /v1 with minimal params (some providers reject response_format etc.)
try {
const body = buildImageBody(provider.model, prompt, size, n, transparent, true)
const res = await fetchWithTimeout(v1Url, {
method: 'POST',
headers: jsonHeaders(provider.apiKey),
body: JSON.stringify(body),
}, timeoutMs)
const data = await readJson(res)
if (!res.ok) throw new Error(pickError(data, `HTTP ${res.status}`))
const result = await resolveImageResult(data)
return {
...result,
provider: provider.name,
model: provider.model,
warnings: [`[${provider.name}] 自动添加 /v1 前缀 + 兼容模式重试成功(建议更新 BASE_URL 为 ${baseUrl}/v1`],
}
} catch {
// Fall through to other retry strategies
}
}
}
// Attempt 3: minimal params (compatibility fallback)
if (shouldRetryWithMinimalPayload(err1)) {
try {
const body = buildImageBody(provider.model, prompt, size, n, transparent, true)
const res = await fetchWithTimeout(url, {
method: 'POST',
headers: jsonHeaders(provider.apiKey),
body: JSON.stringify(body),
}, timeoutMs)
const data = await readJson(res)
if (!res.ok) throw new Error(pickError(data, `HTTP ${res.status}`))
const result = await resolveImageResult(data)
return {
...result,
provider: provider.name,
model: provider.model,
warnings: [`[${provider.name}] 使用兼容模式(精简参数)重试成功`],
}
} catch (err2) {
errors.push({ provider: provider.name, error: err2.message })
continue
}
}
errors.push({ provider: provider.name, error: err1.message })
}
}
return {
type: 'error',
error: `所有 provider 均失败:\n${errors.map(e => ` - ${e.provider}: ${e.error}`).join('\n')}`,
}
}
// ─── Core: edit with provider fallback ────────────────────────────────────────
async function editWithFallback(prompt, imageUrl, size, n, transparent, providers) {
const errors = []
for (const provider of providers) {
if (!provider.enabled && provider.enabled !== undefined) continue
const baseUrl = trimSlash(provider.baseUrl)
const url = `${baseUrl}/images/edits`
const timeoutMs = provider.timeoutMs || 600_000
// Fetch reference image as blob (with SSRF protection)
let imageBlob
try {
if (imageUrl.startsWith('data:')) {
const [meta, b64] = imageUrl.split(',')
const mime = meta.match(/data:([^;]+)/)?.[1] || 'image/png'
const buf = Buffer.from(b64, 'base64')
imageBlob = new Blob([buf], { type: mime })
} else {
validateUrlSafety(imageUrl, 'image_url')
const imgRes = await fetchWithTimeout(imageUrl, { method: 'GET' }, 30_000)
if (!imgRes.ok) throw new Error(`下载参考图失败: HTTP ${imgRes.status}`)
imageBlob = await imgRes.blob()
}
} catch (imgErr) {
errors.push({ provider: provider.name, error: `参考图获取失败: ${imgErr.message}` })
continue
}
async function attemptEdit(editUrl, form) {
const res = await fetchWithTimeout(editUrl, {
method: 'POST',
headers: { Authorization: `Bearer ${provider.apiKey}` },
body: form,
}, timeoutMs)
const data = await readJson(res)
if (!res.ok) throw new Error(pickError(data, `HTTP ${res.status}`))
return resolveImageResult(data)
}
function buildFullForm() {
const form = new FormData()
form.set('model', provider.model)
form.set('prompt', prompt)
form.set('n', String(n || 1))
form.set('size', size || '1024x1024')
form.set('response_format', 'b64_json')
if (transparent) form.set('background', 'transparent')
form.append('image', imageBlob, 'reference.png')
return form
}
function buildMinimalForm() {
const form = new FormData()
form.set('model', provider.model)
form.set('prompt', prompt)
form.set('n', String(n || 1))
form.set('size', size || '1024x1024')
form.append('image', imageBlob, 'reference.png')
return form
}
// Attempt 1: full params
try {
const result = await attemptEdit(url, buildFullForm())
return { ...result, provider: provider.name, model: provider.model, warnings: [] }
} catch (err1) {
// Attempt 2: retry with /v1 prefix if base URL is missing it
if (shouldRetryWithV1Prefix(baseUrl, err1)) {
try {
const v1Url = `${baseUrl}/v1/images/edits`
const result = await attemptEdit(v1Url, buildFullForm())
return {
...result,
provider: provider.name,
model: provider.model,
warnings: [`[${provider.name}] 自动添加 /v1 前缀重试成功(建议更新 BASE_URL 为 ${baseUrl}/v1`],
}
} catch (err1v1) {
// Fall through to minimal payload attempt
}
}
// Attempt 3: minimal params
if (shouldRetryWithMinimalPayload(err1)) {
try {
const result = await attemptEdit(url, buildMinimalForm())
return {
...result,
provider: provider.name,
model: provider.model,
warnings: [`[${provider.name}] 使用兼容模式(精简参数)重试成功`],
}
} catch (err2) {
errors.push({ provider: provider.name, error: err2.message })
continue
}
}
errors.push({ provider: provider.name, error: err1.message })
}
}
return {
type: 'error',
error: `所有 provider 均失败:\n${errors.map(e => ` - ${e.provider}: ${e.error}`).join('\n')}`,
}
}
// ─── List models for a provider ───────────────────────────────────────────────
async function listModelsForProvider(provider) {
const baseUrl = trimSlash(provider.baseUrl)
const timeoutMs = Math.min(60_000, provider.timeoutMs || 60_000)
async function tryFetchModels(modelsUrl) {
const res = await fetchWithTimeout(modelsUrl, {
method: 'GET',
headers: { Authorization: `Bearer ${provider.apiKey}` },
}, timeoutMs)
const data = await readJson(res)
if (!res.ok) throw new Error(pickError(data, `HTTP ${res.status}`))
const ids = Array.isArray(data?.data)
? data.data.map(m => typeof m === 'string' ? m : m?.id).filter(Boolean)
: []
return { success: true, models: ids }
}
try {
return await tryFetchModels(`${baseUrl}/models`)
} catch (err) {
// Retry with /v1 prefix if missing
if (shouldRetryWithV1Prefix(baseUrl, err)) {
try {
return await tryFetchModels(`${baseUrl}/v1/models`)
} catch {
// fall through
}
}
return { success: false, error: err.message, models: [] }
}
}
// ─── Tool definitions ─────────────────────────────────────────────────────────
const TOOLS = [
{
name: 'generate_image',
description: 'Generate an image from a text prompt. Tries providers in priority order with automatic fallback. Supports any OpenAI-compatible image generation API (Agnes, GPT-image-2, Gemini, DALL-E, Flux, etc.).',
inputSchema: {
type: 'object',
properties: {
prompt: {
type: 'string',
description: 'Text description of the image to generate',
},
size: {
type: 'string',
description: 'Image size (e.g. "1024x1024", "512x512", "auto"). Default: 1024x1024',
default: '1024x1024',
},
n: {
type: 'number',
description: 'Number of images to generate. Default: 1',
default: 1,
},
transparent: {
type: 'boolean',
description: 'Request transparent background (not supported by all providers). Default: false',
default: false,
},
},
required: ['prompt'],
},
},
{
name: 'edit_image',
description: 'Edit an existing image using a text prompt and a reference image. Tries providers in priority order with automatic fallback.',
inputSchema: {
type: 'object',
properties: {
prompt: {
type: 'string',
description: 'Text description of the desired edit',
},
image_url: {
type: 'string',
description: 'URL or data URL of the reference image to edit',
},
size: {
type: 'string',
description: 'Output image size. Default: 1024x1024',
default: '1024x1024',
},
n: {
type: 'number',
description: 'Number of images to generate. Default: 1',
default: 1,
},
transparent: {
type: 'boolean',
description: 'Request transparent background. Default: false',
default: false,
},
},
required: ['prompt', 'image_url'],
},
},
{
name: 'list_providers',
description: 'List all configured image generation providers with their status and priority order.',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'list_models',
description: 'List available models from all configured providers (calls each provider\'s /v1/models endpoint).',
inputSchema: {
type: 'object',
properties: {
provider_index: {
type: 'number',
description: 'Only query a specific provider by index (0-based). Omit to query all.',
},
},
},
},
]
// ─── MCP JSON-RPC server ──────────────────────────────────────────────────────
function formatResult(result) {
if (result.type === 'error') {
return {
content: [{ type: 'text', text: result.error }],
isError: true,
}
}
const parts = []
if (result.type === 'base64') {
parts.push({
type: 'image',
data: result.data,
mimeType: 'image/png',
})
}
if (result.type === 'url') {
// Return image_url block so Desktop IDE renders the image inline
parts.push({
type: 'image_url',
image_url: { url: result.data },
})
}
const meta = []
if (result.provider) meta.push(`Provider: ${result.provider}`)
if (result.model) meta.push(`Model: ${result.model}`)
if (result.warnings?.length) meta.push(...result.warnings)
if (result.type === 'url') meta.push(`URL: ${result.data}`)
if (meta.length) parts.push({ type: 'text', text: meta.join('\n') })
return { content: parts, isError: false }
}
// Cache providers at startup (env vars don't change during process lifetime)
const cachedProviders = loadProviders()
async function handleToolCall(name, args) {
const providers = cachedProviders
if (providers.length === 0) {
return {
content: [{ type: 'text', text: '没有可用的 provider。请在插件配置中设置 PROVIDERS_JSON。' }],
isError: true,
}
}
switch (name) {
case 'generate_image': {
if (!args.prompt || typeof args.prompt !== 'string' || args.prompt.trim() === '') {
return { content: [{ type: 'text', text: 'Error: prompt is required and must be a non-empty string.' }], isError: true }
}
const firstCaps = providers[0]?.capabilities
const maxN = firstCaps?.maxN || 10
const n = Math.min(Math.max(Math.floor(Number(args.n) || 1), 1), maxN)
const requestedSize = args.size || '1024x1024'
if (firstCaps && firstCaps.sizes.length > 0 && !firstCaps.sizes.includes(requestedSize) && requestedSize !== 'auto') {
return {
content: [{ type: 'text', text: `Size "${requestedSize}" may not be supported by ${providers[0].model}. Supported sizes: ${firstCaps.sizes.join(', ')}. Will attempt anyway with compatibility fallback.` }],
isError: false,
}
}
const result = await generateWithFallback(
args.prompt.trim(),
requestedSize,
n,
args.transparent,
providers,
)
return formatResult(result)
}
case 'edit_image': {
if (!args.prompt || typeof args.prompt !== 'string' || args.prompt.trim() === '') {
return { content: [{ type: 'text', text: 'Error: prompt is required.' }], isError: true }
}
if (!args.image_url || typeof args.image_url !== 'string') {
return { content: [{ type: 'text', text: 'Error: image_url is required.' }], isError: true }
}
const editProviders = providers.filter(p => p.capabilities?.edit !== false)
if (editProviders.length === 0) {
return {
content: [{ type: 'text', text: 'None of the configured providers support image editing. Use generate_image instead, or add a provider that supports editing (e.g., gpt-image-2, gemini-2.5-flash-image-preview).' }],
isError: true,
}
}
const result = await editWithFallback(
args.prompt.trim(),
args.image_url,
args.size || '1024x1024',
args.n || 1,
args.transparent,
editProviders,
)
return formatResult(result)
}
case 'list_providers': {
const lines = providers.map((p, i) => {
const status = p.enabled === false ? ' [DISABLED]' : ''
const caps = p.capabilities
let capStr = ''
if (caps) {
const parts = []
parts.push(`sizes: ${caps.sizes.join(', ')}`)
if (caps.edit) parts.push('supports edit')
if (caps.transparent) parts.push('supports transparent')
parts.push(`max n: ${caps.maxN}`)
parts.push(`returns: ${caps.format}`)
if (caps.notes) parts.push(`note: ${caps.notes}`)
capStr = `\n capabilities: ${parts.join(' | ')}`
} else {
capStr = '\n capabilities: unknown (will try all params, fallback on error)'
}
return `${i}. ${p.name}${status}\n baseUrl: ${p.baseUrl}\n model: ${p.model}${capStr}`
})
return {
content: [{ type: 'text', text: `Configured providers (${providers.length}):\n\n${lines.join('\n\n')}` }],
isError: false,
}
}
case 'list_models': {
const indices = args?.provider_index !== undefined
? [args.provider_index]
: providers.map((_, i) => i)
const results = []
for (const idx of indices) {
const p = providers[idx]
if (!p) {
results.push(`[${idx}] INVALID INDEX`)
continue
}
const r = await listModelsForProvider(p)
if (r.success) {
const imageModels = r.models.filter(m =>
/image|img|vision|dall|flux|stable|banana|gemini.*image|gpt-image|agnes-image/i.test(m)
)
results.push(`${p.name} (${r.models.length} models, ${imageModels.length} image models):\n Image models: ${imageModels.join(', ') || '(none matched)'}\n All: ${r.models.join(', ')}`)
} else {
results.push(`${p.name}: ERROR - ${r.error}`)
}
}
return {
content: [{ type: 'text', text: results.join('\n\n') }],
isError: false,
}
}
default:
return {
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
isError: true,
}
}
}
// ─── Stdio JSON-RPC transport ─────────────────────────────────────────────────
const MAX_BUFFER_SIZE = 1024 * 1024 // 1MB
function sendResponse(id, result) {
const msg = { jsonrpc: '2.0', id, result }
process.stdout.write(JSON.stringify(msg) + '\n')
}
function sendError(id, code, message) {
const msg = { jsonrpc: '2.0', id, error: { code, message } }
process.stdout.write(JSON.stringify(msg) + '\n')
}
function sendNotification(method, params) {
const msg = { jsonrpc: '2.0', method, params }
process.stdout.write(JSON.stringify(msg) + '\n')
}
async function handleMessage(msg) {
const { id, method, params } = msg
switch (method) {
case 'initialize':
sendResponse(id, {
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
serverInfo: {
name: 'image-gen',
version: '1.0.0',
},
})
break
case 'notifications/initialized':
// no response needed for notification
break
case 'tools/list':
sendResponse(id, { tools: TOOLS })
break
case 'tools/call': {
try {
const result = await handleToolCall(params.name, params.arguments || {})
sendResponse(id, result)
} catch (err) {
sendResponse(id, {
content: [{ type: 'text', text: `Error: ${err.message}` }],
isError: true,
})
}
break
}
case 'ping':
sendResponse(id, {})
break
default:
if (id !== undefined) {
sendError(id, -32601, `Method not found: ${method}`)
}
break
}
}
// ─── Main ─────────────────────────────────────────────────────────────────────
function main() {
const rl = createInterface({ input: process.stdin })
let buffer = ''
const pending = new Set()
rl.on('line', (line) => {
buffer += line
if (buffer.length > MAX_BUFFER_SIZE) {
console.error(`[image-gen] Buffer overflow (>1MB), resetting`)
buffer = ''
return
}
try {
const msg = JSON.parse(buffer)
buffer = ''
const p = handleMessage(msg).catch(err => {
if (msg.id !== undefined) {
sendError(msg.id, -32603, `Internal error: ${err.message}`)
}
}).finally(() => pending.delete(p))
pending.add(p)
} catch {
// Incomplete message, wait for more lines
}
})
rl.on('close', async () => {
// Wait for all pending message handlers to complete before exiting
if (pending.size > 0) {
await Promise.allSettled([...pending])
}
process.exit(0)
})
process.on('SIGTERM', () => process.exit(0))
process.on('SIGINT', () => process.exit(0))
// Log to stderr (not stdout, which is reserved for JSON-RPC)
console.error('[image-gen] MCP server started')
}
main()