modelscope-image-mcp 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +3 -2
  2. package/server.js +118 -17
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "modelscope-image-mcp",
3
- "version": "1.0.2",
4
- "description": "ModelScope (魔搭) 文生图 / 图生图 MCP 服务:免费调用 Qwen-Image、Z-Image、FLUX.1 等模型",
3
+ "version": "1.0.3",
4
+ "description": "ModelScope (魔搭) + SenseNova (商汤日日新) 文生图 / 图生图 MCP 服务:免费调用 Qwen-Image、Z-Image、FLUX.1、SenseNova U1.5 等模型",
5
5
  "type": "module",
6
6
  "main": "server.js",
7
7
  "bin": {
@@ -17,6 +17,7 @@
17
17
  "mcp",
18
18
  "model-context-protocol",
19
19
  "modelscope",
20
+ "sensenova",
20
21
  "text-to-image",
21
22
  "image-to-image",
22
23
  "image-editing",
package/server.js CHANGED
@@ -13,7 +13,11 @@
13
13
  * MODELSCOPE_OUTPUT_DIR 图片保存目录,默认 ~/modelscope-images
14
14
  * MODELSCOPE_TIMEOUT_MS 任务超时毫秒数,默认 300000
15
15
  *
16
+ * SENSENOVA_API_KEY (可选)商汤日日新 API Key,配置后才能调用 sensenova-* 模型
17
+ * SENSENOVA_API_BASE 商汤接口地址,默认 https://token.sensenova.cn/v1
18
+ *
16
19
  * 说明:零依赖,需要 Node.js >= 18(内置 fetch)。
20
+ * 魔搭走异步任务(提交 + 轮询);商汤走同步接口(直接返回 base64),调用方无感。
17
21
  */
18
22
 
19
23
  import fs from "node:fs";
@@ -30,11 +34,21 @@ const OUTPUT_DIR =
30
34
  const TASK_TIMEOUT_MS = Number(process.env.MODELSCOPE_TIMEOUT_MS || 300000);
31
35
  const POLL_INTERVAL_MS = 3000;
32
36
 
37
+ const SENSENOVA_API_BASE = (
38
+ process.env.SENSENOVA_API_BASE || "https://token.sensenova.cn/v1"
39
+ ).replace(/\/+$/, "");
40
+ const SENSENOVA_API_KEY = process.env.SENSENOVA_API_KEY || "";
41
+
33
42
  const TEXT_TO_IMAGE_MODELS = [
34
- { id: "Qwen/Qwen-Image", name: "Qwen-Image", desc: "通义千问图像生成,中英文文字渲染强,综合最稳" },
35
- { id: "Tongyi-MAI/Z-Image-Turbo", name: "Z-Image-Turbo", desc: "轻量快速,出图速度快" },
36
- { id: "Tongyi-MAI/Z-Image", name: "Z-Image", desc: "Z-Image 标准版,画质更好" },
37
- { id: "black-forest-labs/FLUX.1-Krea-dev", name: "FLUX.1-Krea-dev", desc: "FLUX 系列,质感细腻" },
43
+ { id: "Qwen/Qwen-Image", name: "Qwen-Image", desc: "魔搭|通义千问图像生成,中英文文字渲染强,综合最稳" },
44
+ { id: "Tongyi-MAI/Z-Image-Turbo", name: "Z-Image-Turbo", desc: "魔搭|轻量快速,出图速度快" },
45
+ { id: "Tongyi-MAI/Z-Image", name: "Z-Image", desc: "魔搭|Z-Image 标准版,画质更好" },
46
+ { id: "black-forest-labs/FLUX.1-Krea-dev", name: "FLUX.1-Krea-dev", desc: "魔搭|FLUX 系列,质感细腻,中文 prompt 遵循差,建议英文" },
47
+ {
48
+ id: "sensenova-u1.5-lite",
49
+ name: "SenseNova U1.5 Lite",
50
+ desc: "商汤|中文 prompt 直出效果好,默认无水印;不支持 negative_prompt,需配置 SENSENOVA_API_KEY",
51
+ },
38
52
  ];
39
53
 
40
54
  const IMAGE_TO_IMAGE_MODELS = [
@@ -135,12 +149,20 @@ function modelSlug(model) {
135
149
  .replace(/^-+|-+$/g, "");
136
150
  }
137
151
 
152
+ /** 把图片字节写到输出目录,返回绝对路径。tag 是模型标识,会拼进文件名便于事后辨认 */
153
+ async function saveImageBuffer(buf, ext, dir, tag = "") {
154
+ await fs.promises.mkdir(dir, { recursive: true });
155
+ const prefix = tag ? `ms-${tag}` : "ms";
156
+ const file = path.join(dir, `${prefix}-${timestamp()}-${Math.floor(Math.random() * 1e4)}${ext}`);
157
+ await fs.promises.writeFile(file, buf);
158
+ return file;
159
+ }
160
+
138
161
  async function downloadImage(url, dir, tag = "") {
139
162
  const res = await fetch(url);
140
163
  if (!res.ok) throw new Error(`下载图片失败:HTTP ${res.status} ${url}`);
141
164
 
142
165
  const buf = Buffer.from(await res.arrayBuffer());
143
- await fs.promises.mkdir(dir, { recursive: true });
144
166
 
145
167
  let ext = ".png";
146
168
  try {
@@ -150,10 +172,78 @@ async function downloadImage(url, dir, tag = "") {
150
172
  /* 忽略,用默认后缀 */
151
173
  }
152
174
 
153
- const prefix = tag ? `ms-${tag}` : "ms";
154
- const file = path.join(dir, `${prefix}-${timestamp()}-${Math.floor(Math.random() * 1e4)}${ext}`);
155
- await fs.promises.writeFile(file, buf);
156
- return file;
175
+ return saveImageBuffer(buf, ext, dir, tag);
176
+ }
177
+
178
+ // --------------------------------------------------------------- 商汤调用
179
+
180
+ /** sensenova-* 走商汤同步接口,其余走魔搭异步任务 */
181
+ function isSenseNova(model) {
182
+ return /^sensenova-/i.test(String(model));
183
+ }
184
+
185
+ /** 商汤 size 约束:32 的倍数、512–4096、宽高比不超过 3:1 */
186
+ function checkSenseNovaSize(size) {
187
+ if (!size) return undefined;
188
+ if (String(size).trim() === "auto") return "auto";
189
+
190
+ const m = /^(\d+)x(\d+)$/.exec(String(size).trim());
191
+ if (!m) throw new Error(`商汤 size 格式应为 WIDTHxHEIGHT(如 1024x1024)或 auto,当前:${size}`);
192
+
193
+ const w = Number(m[1]);
194
+ const h = Number(m[2]);
195
+ const bad = [];
196
+ if (w % 32 || h % 32) bad.push("宽高都必须是 32 的倍数(如 1280x720 需改成 1280x736)");
197
+ if (w < 512 || w > 4096 || h < 512 || h > 4096) bad.push("宽高需在 512–4096 之间");
198
+ if (Math.max(w, h) / Math.min(w, h) > 3) bad.push("宽高比不能超过 3:1");
199
+ if (bad.length) throw new Error(`商汤 size 不合法(${size}):${bad.join(";")}`);
200
+
201
+ return `${w}x${h}`;
202
+ }
203
+
204
+ async function runSenseNova({ model, prompt, size, dir, tag }) {
205
+ if (!SENSENOVA_API_KEY) {
206
+ throw new Error(
207
+ "未配置 SENSENOVA_API_KEY 环境变量:请在商汤日日新控制台获取 API Key,并在 MCP 配置的 env 中设置"
208
+ );
209
+ }
210
+
211
+ // watermark: false 关掉右下角的「日日新 sensenova」水印
212
+ const payload = { model, prompt, n: 1, watermark: false };
213
+ const checkedSize = checkSenseNovaSize(size);
214
+ if (checkedSize) payload.size = checkedSize;
215
+
216
+ const res = await fetch(`${SENSENOVA_API_BASE}/images/generations`, {
217
+ method: "POST",
218
+ headers: {
219
+ Authorization: `Bearer ${SENSENOVA_API_KEY}`,
220
+ "Content-Type": "application/json",
221
+ },
222
+ body: JSON.stringify(payload),
223
+ });
224
+
225
+ const text = await res.text();
226
+ let data;
227
+ try {
228
+ data = JSON.parse(text);
229
+ } catch {
230
+ throw new Error(`商汤生成失败:HTTP ${res.status} ${text.slice(0, 300)}`);
231
+ }
232
+ if (!res.ok) {
233
+ throw new Error(`商汤生成失败:HTTP ${res.status} ${JSON.stringify(data).slice(0, 500)}`);
234
+ }
235
+
236
+ const items = Array.isArray(data.data) ? data.data : [];
237
+ const files = [];
238
+ for (const item of items) {
239
+ if (item?.b64_json) {
240
+ files.push(await saveImageBuffer(Buffer.from(item.b64_json, "base64"), ".png", dir, tag));
241
+ } else if (item?.url) {
242
+ files.push(await downloadImage(item.url, dir, tag));
243
+ }
244
+ }
245
+
246
+ return { files, urls: [], model };
157
247
  }
158
248
 
159
249
  /** 把入参转成接口能接受的图片引用:http(s) 直接透传,本地文件转 data URI */
@@ -175,6 +265,15 @@ async function toImageRef(input) {
175
265
  // ------------------------------------------------------------------ 生成流程
176
266
 
177
267
  async function runGeneration({ model, prompt, negativePrompt, size, seed, image, outputDir }) {
268
+ const dir = outputDir || OUTPUT_DIR;
269
+ const tag = modelSlug(model);
270
+
271
+ if (isSenseNova(model)) {
272
+ if (image) throw new Error("商汤模型暂不支持图生图,请改用魔搭的图片编辑模型");
273
+ if (negativePrompt) log("提示:商汤接口不支持 negative_prompt,已忽略");
274
+ return runSenseNova({ model, prompt, size, dir, tag });
275
+ }
276
+
178
277
  const payload = { model, prompt };
179
278
  if (negativePrompt) payload.negative_prompt = negativePrompt;
180
279
  if (size) payload.size = size;
@@ -187,8 +286,6 @@ async function runGeneration({ model, prompt, negativePrompt, size, seed, image,
187
286
  const raw = result.output_images ?? result.output_image ?? [];
188
287
  const urls = (Array.isArray(raw) ? raw : [raw]).filter(Boolean);
189
288
 
190
- const dir = outputDir || OUTPUT_DIR;
191
- const tag = modelSlug(model);
192
289
  const files = [];
193
290
  for (const url of urls) {
194
291
  files.push(await downloadImage(url, dir, tag));
@@ -199,14 +296,14 @@ async function runGeneration({ model, prompt, negativePrompt, size, seed, image,
199
296
 
200
297
  function renderResult({ taskId, files, urls, model }) {
201
298
  if (!files.length) {
202
- return `模型:${model}\n任务 ${taskId} 已完成,但未返回图片。原始响应:${JSON.stringify(urls)}`;
299
+ return `模型:${model}\n任务 ${taskId ?? "-"} 已完成,但未返回图片。原始响应:${JSON.stringify(urls)}`;
203
300
  }
204
301
  const lines = [`模型:${model}`, "", `已生成 ${files.length} 张图片:`, ""];
205
302
  files.forEach((f, i) => {
206
303
  lines.push(`${i + 1}. 本地文件:${f}`);
207
304
  if (urls[i]) lines.push(` 原始链接:${urls[i]}`);
208
305
  });
209
- lines.push("", `任务 ID:${taskId}`);
306
+ if (taskId) lines.push("", `任务 ID:${taskId}`);
210
307
  return lines.join("\n");
211
308
  }
212
309
 
@@ -271,7 +368,7 @@ const TOOLS = [
271
368
  {
272
369
  name: "text_to_image",
273
370
  description:
274
- "用魔搭(ModelScope)的文生图模型,按文字描述生成图片,自动保存到本地并返回文件路径。",
371
+ "文生图:用魔搭(ModelScope)或商汤日日新(SenseNova)的模型,按文字描述生成图片,自动保存到本地并返回文件路径。sensenova-* 模型走商汤同步接口(默认无水印)。",
275
372
  inputSchema: {
276
373
  type: "object",
277
374
  properties: {
@@ -280,8 +377,12 @@ const TOOLS = [
280
377
  type: "string",
281
378
  description: `模型 ID,默认 ${DEFAULT_TEXT_TO_IMAGE_MODEL}。可选:${TEXT_TO_IMAGE_MODELS.map((m) => m.id).join("、")}`,
282
379
  },
283
- negative_prompt: { type: "string", description: "反向提示词,描述不希望出现的内容" },
284
- size: { type: "string", description: "图片尺寸,如 1024x1024、1280x720(部分模型支持)" },
380
+ negative_prompt: { type: "string", description: "反向提示词,描述不希望出现的内容(商汤模型不支持,会被忽略)" },
381
+ size: {
382
+ type: "string",
383
+ description:
384
+ "图片尺寸,如 1024x1024、1280x720(部分模型支持)。商汤模型需为 32 的倍数且宽高比不超过 3:1,如 1280x736",
385
+ },
285
386
  seed: { type: "integer", description: "随机种子,便于复现同一张图" },
286
387
  output_dir: { type: "string", description: "图片保存目录,默认 ~/modelscope-images" },
287
388
  },
@@ -342,7 +443,7 @@ async function handleMessage(msg) {
342
443
  reply(id, {
343
444
  protocolVersion: params?.protocolVersion || "2024-11-05",
344
445
  capabilities: { tools: {} },
345
- serverInfo: { name: "modelscope-image-mcp", version: "1.0.2" },
446
+ serverInfo: { name: "modelscope-image-mcp", version: "1.0.3" },
346
447
  });
347
448
  return;
348
449