mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-30 16:23:35 +08:00
- 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>
814 lines
28 KiB
JavaScript
814 lines
28 KiB
JavaScript
#!/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()
|