#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ErrorCode,
McpError,
} from "@modelcontextprotocol/sdk/types.js";
import { setGlobalDispatcher, ProxyAgent } from "undici";
// === 1. 全局代理配置 ===
const PROXY_URL = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
if (PROXY_URL) {
try {
const dispatcher = new ProxyAgent(PROXY_URL);
setGlobalDispatcher(dispatcher);
console.error(`[System] Proxy enabled: ${PROXY_URL}`);
} catch (error) {
console.error(`[System] Failed to set proxy:`, error);
}
}
// 图片尺寸定义
interface ImageDimension {
width: number;
height: number;
}
const ASPECT_RATIOS: Record<string, ImageDimension> = {
"1x1": { width: 1024, height: 1024 },
"16x9": { width: 1344, height: 768 },
"9x16": { width: 768, height: 1344 },
"4x3": { width: 1152, height: 896 },
"3x4": { width: 896, height: 1152 },
"21x9": { width: 1536, height: 640 },
"9x21": { width: 640, height: 1536 },
"3x2": { width: 1280, height: 832 },
"2x3": { width: 832, height: 1280 },
};
class KetchupMCPServer {
private server: Server;
constructor() {
this.server = new Server(
{
name: "ketchup-draw-mcp-server",
version: "1.2.0", // 版本升级
},
{
capabilities: {
tools: {},
},
}
);
this.setupToolHandlers();
this.server.onerror = (error) => console.error("[MCP Error]", error);
process.on("SIGINT", async () => {
await this.server.close();
process.exit(0);
});
}
private setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "optimize_prompt",
description: "Optimize a simple drawing prompt into a detailed professional prompt using Ketchup AI.",
inputSchema: {
type: "object",
properties: {
prompt: {
type: "string",
description: "The simple prompt to optimize (e.g., 'a cute dog')",
},
},
required: ["prompt"],
},
},
{
name: "generate_image",
description: "Generate images based on a list of prompts. Each prompt in the list generates one image.",
inputSchema: {
type: "object",
properties: {
prompts: {
type: "array",
items: {
type: "string"
},
minItems: 1,
maxItems: 4,
description: "A list of detailed prompts. 1 to 4 prompts allowed.",
},
ratio: {
type: "string",
description: "The aspect ratio for all images",
enum: Object.keys(ASPECT_RATIOS),
},
},
required: ["prompts", "ratio"],
},
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
switch (request.params.name) {
case "optimize_prompt":
return this.handleOptimizePrompt(request.params.arguments);
case "generate_image":
return this.handleGenerateImage(request.params.arguments);
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`
);
}
});
}
// === 辅助函数:统一的 Fetch 请求 ===
private async fetchKetchup(endpoint: string, payload: any) {
const url = `https://ketchup-ai.com/api/image/${endpoint}`;
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Ketchup API returned ${response.status}: ${errorText.substring(0, 200)}`);
}
const data = await response.json() as any;
if (data.code !== 0) {
throw new Error(`API Logic Error: ${data.message || 'Unknown error'}`);
}
return data;
} catch (error: any) {
console.error(`[Fetch Failed]`, error);
throw error;
}
}
// === 辅助函数:上传到 URUSAI! ===
private async uploadToUrusai(buffer: Buffer, filename: string): Promise<string> {
const URUSAI_API_URL = "https://api.urusai.cc/v1/upload";
const formData = new FormData();
const blob = new Blob([new Uint8Array(buffer)], { type: "image/jpeg" });
formData.append("file", blob, filename);
formData.append("r18", "0");
try {
const response = await fetch(URUSAI_API_URL, {
method: "POST",
body: formData,
});
const responseData = await response.json() as any;
if (!response.ok || responseData.status !== "success") {
const errorMessage = responseData.message || `HTTP error! status: ${response.status}`;
throw new Error(`Failed to upload to URUSAI!: ${errorMessage}`);
}
return responseData.data.url_direct;
} catch (error: any) {
console.error(`[Upload Failed]`, error);
throw error;
}
}
private async handleOptimizePrompt(args: any) {
if (!args || typeof args.prompt !== "string") {
throw new McpError(ErrorCode.InvalidParams, "Invalid arguments: prompt is required");
}
try {
const data = await this.fetchKetchup("optimize-prompt", {
prompt: args.prompt
});
return {
content: [
{
type: "text",
text: data.data.optimizedPrompt,
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: `Failed to optimize prompt: ${error.message}`,
},
],
isError: true,
};
}
}
// === 单张图片处理逻辑封装 ===
private async processSingleGeneration(
prompt: string,
width: number,
height: number,
index: number,
total: number
): Promise<{ prompt: string; url: string; base64Data: string }> {
const prefix = `[${index}/${total}]`;
// 截取 prompt 前20个字符用于日志
const promptShort = prompt.length > 20 ? prompt.substring(0, 20) + "..." : prompt;
console.error(`${prefix} Generating for: "${promptShort}"`);
// 1. 生成
const data = await this.fetchKetchup("generate", {
prompt: prompt,
width: width,
height: height
});
const base64String = data.data.image;
const base64Data = base64String.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
// 2. 上传
console.error(`${prefix} Uploading...`);
const imageUrl = await this.uploadToUrusai(buffer, `ketchup_${Date.now()}_${index}.jpg`);
console.error(`${prefix} Success: ${imageUrl}`);
return {
prompt: prompt,
url: imageUrl,
base64Data: base64Data
};
}
private async handleGenerateImage(args: any) {
// 参数校验
if (!args || !args.prompts || !Array.isArray(args.prompts)) {
throw new McpError(ErrorCode.InvalidParams, "Invalid arguments: prompts must be an array");
}
if (typeof args.ratio !== "string") {
throw new McpError(ErrorCode.InvalidParams, "Invalid arguments: ratio is required");
}
const prompts = args.prompts as string[];
// 数量校验
if (prompts.length === 0) {
throw new McpError(ErrorCode.InvalidParams, "Prompts list cannot be empty");
}
if (prompts.length > 4) {
throw new McpError(ErrorCode.InvalidParams, "Maximum 4 prompts allowed per request");
}
const dimensions = ASPECT_RATIOS[args.ratio];
if (!dimensions) {
throw new McpError(ErrorCode.InvalidParams, `Invalid ratio. Supported: ${Object.keys(ASPECT_RATIOS).join(", ")}`);
}
const count = prompts.length;
console.error(`[Batch] Starting generation for ${count} prompt(s) with ratio ${args.ratio}...`);
try {
// 创建并发任务数组,遍历 prompts
const tasks = prompts.map((prompt, i) =>
this.processSingleGeneration(prompt, dimensions.width, dimensions.height, i + 1, count)
);
// 并行执行
const results = await Promise.all(tasks);
// 组装文本摘要
let summaryText = `Successfully generated ${count} image(s)!\n\n`;
results.forEach((res, i) => {
// 在摘要中显示每张图对应的 Prompt(截断长文本)
const promptDisplay = res.prompt.length > 50 ? res.prompt.substring(0, 50) + "..." : res.prompt;
summaryText += `**Image ${i + 1}**\nPrompt: _${promptDisplay}_\nURL: ${res.url}\n\n`;
});
summaryText += `**Size:** ${dimensions.width}x${dimensions.height}`;
// 组装返回内容
const content: any[] = [
{
type: "text",
text: summaryText,
}
];
// 添加所有图片
results.forEach((res) => {
content.push({
type: "image",
data: res.base64Data,
mimeType: "image/jpeg",
});
});
return { content };
} catch (error: any) {
console.error("[Batch Error]", error);
return {
content: [
{
type: "text",
text: `Failed to generate images: ${error.message}`,
},
],
isError: true,
};
}
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error("Ketchup Draw MCP Server running on stdio");
}
}
const server = new KetchupMCPServer();
server.run().catch(console.error);