@laomeifun/gemini-image-mcp 0.0.1

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 (3) hide show
  1. package/README.md +64 -0
  2. package/package.json +40 -0
  3. package/src/index.js +629 -0
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # gemini-image-mcp
2
+
3
+ 一个基于 **MCP (Model Context Protocol)** 的 Node.js 服务:通过本地 `OpenAI-compatible` 网关(默认 `http://127.0.0.1:8317`)调用 Gemini 图片模型 `gemini-3-pro-image-preview` 生成图片,供 Claude Code/Claude Desktop 等 IDE 工具动态使用。
4
+
5
+ ## 1) 安装
6
+
7
+ ```bash
8
+ npm install
9
+ ```
10
+
11
+ ## 2) 环境变量(可选)
12
+
13
+ - `OPENAI_BASE_URL`:OpenAI-compatible 服务地址(默认 `http://127.0.0.1:8317`)
14
+ - `OPENAI_API_KEY`:如果你的网关需要鉴权就填(可留空)
15
+ - `OPENAI_MODEL`:默认 `gemini-3-pro-image-preview`
16
+ - `OPENAI_IMAGE_SIZE`:可选,仅作为未传入 `size` 时的默认值;建议让客户端在调用 `generate_image` 时自己传 `size`
17
+ - `OPENAI_IMAGE_MODE`:`chat|images|auto`,默认 `chat`(CLIProxyAPI 这类网关通常用 `/v1/chat/completions` 出图;若你的网关支持 `/v1/images/generations` 可设为 `images`)
18
+ - `OPENAI_IMAGE_RETURN`:`path|image`,默认 `path`(`path` 会把图片保存到本地并返回文件路径,避免 base64 导致 token 暴涨;`image` 返回 MCP `image` content)
19
+ - `OPENAI_IMAGE_OUT_DIR`:保存目录(默认 `debug-output/`;相对路径以项目根目录为基准)
20
+ - `OPENAI_DEBUG`:设为 `1` 时会在 stderr 打印上游请求信息(不打印 key)
21
+ - `OPENAI_TIMEOUT_MS`:默认 `120000`
22
+
23
+ 可参考 `.env.example`。
24
+
25
+ ## 3) 本地调试(不用放进 Claude Code)
26
+
27
+ 推荐把 `.env.example` 复制成 `.env`,然后在 `.env` 里填好 `OPENAI_API_KEY`(`.gitignore` 已忽略 `.env`)。
28
+
29
+ - 直连上游调试(确认你的 `http://127.0.0.1:8317` 是否能出图):
30
+ - `npm run debug:upstream -- --prompt "A beautiful sunset over mountains" --size 1024x1024`
31
+ - 走 MCP 工具调试(等价于 Claude Code 调用 `generate_image`):
32
+ - `npm run debug:mcp -- --prompt "A beautiful sunset over mountains" --n 1 --size 1024x1024`
33
+
34
+ 图片会输出到 `debug-output/`。
35
+
36
+ ## 4) 作为 MCP Server 使用(stdio)
37
+
38
+ 该项目是 **stdio** 传输方式的 MCP Server,不建议直接在终端手动运行(会等待客户端请求)。
39
+
40
+ 在 Claude Code / Claude Desktop 的 MCP 配置里添加类似如下(按你的实际路径修改):
41
+
42
+ ```json
43
+ {
44
+ "mcpServers": {
45
+ "gemini-image": {
46
+ "command": "node",
47
+ "args": ["d:/task/myself/nodejs/geminiimagemcp/src/index.js"],
48
+ "env": {
49
+ "OPENAI_BASE_URL": "http://127.0.0.1:8317",
50
+ "OPENAI_API_KEY": "<YOUR_KEY>",
51
+ "OPENAI_MODEL": "gemini-3-pro-image-preview"
52
+ }
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ 也可以直接参考 `mcp.example.json`。
59
+
60
+ ## 5) 可用工具
61
+
62
+ - `generate_image`
63
+ - 入参:`prompt`(必填), `size`(可选), `n`(可选,1-4), `output`(可选:`path|image`), `outDir`(可选)
64
+ - 返回:默认返回保存后的图片文件路径(多行);`output=image` 时返回 MCP `image` content(base64 + mimeType)
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@laomeifun/gemini-image-mcp",
3
+ "version": "0.0.1",
4
+ "description": "MCP server: generate images via an OpenAI-compatible Gemini endpoint",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "gemini-image-mcp": "src/index.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node src/index.js",
12
+ "debug:mcp": "node scripts/debug-mcp.js",
13
+ "debug:upstream": "node scripts/debug-upstream.js"
14
+ },
15
+ "keywords": [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "gemini",
19
+ "image-generation",
20
+ "ai",
21
+ "openai-compatible"
22
+ ],
23
+ "author": "",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/laomeifun/geminiimagemcp_public.git"
28
+ },
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "files": [
33
+ "src/",
34
+ "README.md"
35
+ ],
36
+ "dependencies": {
37
+ "@modelcontextprotocol/sdk": "^1.25.1",
38
+ "dotenv": "^17.2.3"
39
+ }
40
+ }
package/src/index.js ADDED
@@ -0,0 +1,629 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config";
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import os from "node:os";
10
+ import {
11
+ CallToolRequestSchema,
12
+ ListToolsRequestSchema,
13
+ } from "@modelcontextprotocol/sdk/types.js";
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = path.dirname(__filename);
17
+ const PROJECT_ROOT = path.resolve(__dirname, "..");
18
+
19
+ const DEFAULT_MODEL = "gemini-3-pro-image-preview";
20
+ const DEFAULT_SIZE = "1024x1024";
21
+ const DEFAULT_TIMEOUT_MS = 120_000;
22
+ const DEFAULT_OUTPUT = "path"; // path|image
23
+
24
+ const server = new Server(
25
+ { name: "gemini-image-mcp", version: "0.1.0" },
26
+ { capabilities: { tools: {}, logging: {} } },
27
+ );
28
+
29
+ // 发送 MCP 日志消息
30
+ function sendLog(level, data) {
31
+ const message = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
32
+ // 同时也打印到 stderr 以便终端调试
33
+ console.error(`[${level}] ${message}`);
34
+
35
+ // 尝试通过 MCP 协议发送日志(如果 server 已连接)
36
+ try {
37
+ if (server && server.transport) {
38
+ server.sendLoggingMessage({
39
+ level: level,
40
+ data: message,
41
+ }).catch(() => {}); // 忽略发送失败(可能是连接未就绪)
42
+ }
43
+ } catch (e) {
44
+ // 忽略错误
45
+ }
46
+ }
47
+
48
+ function debugLog(...args) {
49
+ if (isDebugEnabled()) {
50
+ sendLog("debug", args.join(" "));
51
+ }
52
+ }
53
+
54
+ function normalizeBaseUrl(raw) {
55
+ const trimmed = String(raw ?? "").trim();
56
+ if (!trimmed) return "http://127.0.0.1:8317";
57
+ return trimmed.replace(/\/+$/, "");
58
+ }
59
+
60
+ function toV1BaseUrl(baseUrl) {
61
+ const normalized = normalizeBaseUrl(baseUrl);
62
+ if (normalized.endsWith("/v1")) return normalized;
63
+ return `${normalized}/v1`;
64
+ }
65
+
66
+ function parseIntOr(value, fallback) {
67
+ const n = Number.parseInt(String(value ?? ""), 10);
68
+ return Number.isFinite(n) ? n : fallback;
69
+ }
70
+
71
+ function clampInt(value, min, max) {
72
+ const n = Number.isFinite(value) ? value : min;
73
+ return Math.max(min, Math.min(max, n));
74
+ }
75
+
76
+ function extFromMime(mimeType) {
77
+ switch (String(mimeType || "").toLowerCase()) {
78
+ case "image/jpeg":
79
+ case "image/jpg":
80
+ return "jpg";
81
+ case "image/webp":
82
+ return "webp";
83
+ case "image/gif":
84
+ return "gif";
85
+ case "image/png":
86
+ default:
87
+ return "png";
88
+ }
89
+ }
90
+
91
+ function resolveOutDir(rawOutDir) {
92
+ let outDir = String(rawOutDir ?? "").trim();
93
+
94
+ // 不再提供默认路径,返回空让调用方处理
95
+ if (!outDir) return "";
96
+
97
+ // 处理 ~ 路径 (Home 目录)
98
+ if (outDir.startsWith("~")) {
99
+ outDir = path.join(os.homedir(), outDir.slice(1));
100
+ }
101
+
102
+ if (path.isAbsolute(outDir)) return outDir;
103
+ return path.resolve(PROJECT_ROOT, outDir);
104
+ }
105
+
106
+ function toDisplayPath(filePath) {
107
+ return String(filePath ?? "").replaceAll("\\", "/");
108
+ }
109
+
110
+ function formatDateForFilename(date) {
111
+ const d = date instanceof Date ? date : new Date();
112
+ const pad2 = (n) => String(n).padStart(2, "0");
113
+ return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
114
+ }
115
+
116
+ function isDebugEnabled() {
117
+ return process.env.OPENAI_DEBUG === "1" || process.env.DEBUG === "1";
118
+ }
119
+
120
+ function parseDataUrl(maybeDataUrl) {
121
+ const s = String(maybeDataUrl ?? "");
122
+ const match = /^data:([^;]+);base64,(.+)$/s.exec(s);
123
+ if (!match) return null;
124
+ return {
125
+ mimeType: match[1].trim() || "application/octet-stream",
126
+ base64: match[2],
127
+ };
128
+ }
129
+
130
+ function stripDataUrlPrefix(maybeDataUrl) {
131
+ const parsed = parseDataUrl(maybeDataUrl);
132
+ return parsed ? parsed.base64 : String(maybeDataUrl ?? "");
133
+ }
134
+
135
+ async function fetchWithTimeout(url, init, timeoutMs) {
136
+ const controller = new AbortController();
137
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
138
+ try {
139
+ const res = await fetch(url, { ...init, signal: controller.signal });
140
+ return res;
141
+ } catch (err) {
142
+ if (err.name === "AbortError") {
143
+ throw new Error(`请求超时(${Math.round(timeoutMs / 1000)}秒),请检查网络或增加 OPENAI_TIMEOUT_MS`);
144
+ }
145
+ throw new Error(`网络请求失败: ${err.message || err}`);
146
+ } finally {
147
+ clearTimeout(timeout);
148
+ }
149
+ }
150
+
151
+ function isValidBase64(str) {
152
+ if (typeof str !== "string" || !str.trim()) return false;
153
+ try {
154
+ const decoded = Buffer.from(str, "base64");
155
+ return decoded.length > 0 && Buffer.from(decoded).toString("base64") === str.replace(/\s/g, "");
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
160
+
161
+ async function fetchUrlAsBase64(url, timeoutMs) {
162
+ const res = await fetchWithTimeout(url, { method: "GET" }, timeoutMs);
163
+ if (!res.ok) {
164
+ const body = await res.text().catch(() => "");
165
+ throw new Error(`拉取图片失败: HTTP ${res.status} ${body}`);
166
+ }
167
+ const mimeTypeHeader = res.headers.get("content-type") ?? "image/png";
168
+ const mimeType = mimeTypeHeader.split(";")[0].trim() || "image/png";
169
+ const arrayBuffer = await res.arrayBuffer();
170
+ const base64 = Buffer.from(arrayBuffer).toString("base64");
171
+ return { base64, mimeType };
172
+ }
173
+
174
+ class HttpError extends Error {
175
+ constructor(message, { status, url, body }) {
176
+ super(message);
177
+ this.name = "HttpError";
178
+ this.status = status;
179
+ this.url = url;
180
+ this.body = body;
181
+ }
182
+ }
183
+
184
+ async function generateImagesViaImagesApi({
185
+ baseUrl,
186
+ apiKey,
187
+ model,
188
+ prompt,
189
+ size,
190
+ n,
191
+ timeoutMs,
192
+ }) {
193
+ const v1BaseUrl = toV1BaseUrl(baseUrl);
194
+ const url = `${v1BaseUrl}/images/generations`;
195
+
196
+ const headers = {
197
+ "content-type": "application/json",
198
+ };
199
+ if (apiKey) headers.authorization = `Bearer ${apiKey}`;
200
+
201
+ const body = {
202
+ model,
203
+ prompt,
204
+ size,
205
+ n,
206
+ response_format: "b64_json",
207
+ };
208
+
209
+ debugLog(
210
+ `[upstream] POST ${url} (images/generations) model=${model} size=${size} n=${n} hasApiKey=${Boolean(apiKey)}`,
211
+ );
212
+
213
+ const res = await fetchWithTimeout(
214
+ url,
215
+ { method: "POST", headers, body: JSON.stringify(body) },
216
+ timeoutMs,
217
+ );
218
+
219
+ if (!res.ok) {
220
+ const text = await res.text().catch(() => "");
221
+ const hint =
222
+ res.status === 401 ? "(看起来需要 API Key,请设置 OPENAI_API_KEY)" : "";
223
+ throw new HttpError(`图片生成失败: HTTP ${res.status}${hint} ${text}`, {
224
+ status: res.status,
225
+ url,
226
+ body: text,
227
+ });
228
+ }
229
+
230
+ /** @type {{ data?: Array<{ b64_json?: string; url?: string }>} } */
231
+ const json = await res.json();
232
+ const data = Array.isArray(json?.data) ? json.data : [];
233
+
234
+ /** @type {Array<{base64:string; mimeType:string}>} */
235
+ const images = [];
236
+ for (const item of data) {
237
+ if (typeof item?.b64_json === "string" && item.b64_json.trim()) {
238
+ const parsed = parseDataUrl(item.b64_json);
239
+ images.push({
240
+ base64: stripDataUrlPrefix(item.b64_json),
241
+ mimeType: parsed?.mimeType ?? "image/png",
242
+ });
243
+ continue;
244
+ }
245
+ if (typeof item?.url === "string" && item.url.trim()) {
246
+ images.push(await fetchUrlAsBase64(item.url, timeoutMs));
247
+ }
248
+ }
249
+
250
+ if (images.length === 0) throw new Error("接口未返回可用的图片数据");
251
+ return images;
252
+ }
253
+
254
+ async function generateImagesViaChatCompletions({
255
+ baseUrl,
256
+ apiKey,
257
+ model,
258
+ prompt,
259
+ size,
260
+ timeoutMs,
261
+ }) {
262
+ const v1BaseUrl = toV1BaseUrl(baseUrl);
263
+ const url = `${v1BaseUrl}/chat/completions`;
264
+
265
+ const headers = {
266
+ "content-type": "application/json",
267
+ };
268
+ if (apiKey) headers.authorization = `Bearer ${apiKey}`;
269
+
270
+ const body = {
271
+ model,
272
+ messages: [{ role: "user", content: prompt }],
273
+ stream: false,
274
+ modalities: ["image"],
275
+ image_config: {
276
+ image_size: size,
277
+ },
278
+ };
279
+
280
+ debugLog(
281
+ `[upstream] POST ${url} (chat/completions) model=${model} image_config.image_size=${size} hasApiKey=${Boolean(apiKey)}`,
282
+ );
283
+
284
+ const res = await fetchWithTimeout(
285
+ url,
286
+ { method: "POST", headers, body: JSON.stringify(body) },
287
+ timeoutMs,
288
+ );
289
+
290
+ if (!res.ok) {
291
+ const text = await res.text().catch(() => "");
292
+ const hint =
293
+ res.status === 401 ? "(看起来需要 API Key,请设置 OPENAI_API_KEY)" : "";
294
+ throw new HttpError(`图片生成失败: HTTP ${res.status}${hint} ${text}`, {
295
+ status: res.status,
296
+ url,
297
+ body: text,
298
+ });
299
+ }
300
+
301
+ /** @type {{ choices?: Array<{ message?: { images?: Array<any> } }> }} */
302
+ const json = await res.json();
303
+ const choices = Array.isArray(json?.choices) ? json.choices : [];
304
+
305
+ /** @type {Array<{base64:string; mimeType:string}>} */
306
+ const images = [];
307
+
308
+ for (const choice of choices) {
309
+ const messageImages = choice?.message?.images;
310
+ if (!Array.isArray(messageImages)) continue;
311
+ for (const img of messageImages) {
312
+ const imageUrl =
313
+ img?.image_url?.url ?? img?.url ?? img?.imageUrl ?? img?.image_url ?? "";
314
+ if (typeof imageUrl !== "string" || !imageUrl.trim()) continue;
315
+
316
+ const parsed = parseDataUrl(imageUrl);
317
+ if (parsed) {
318
+ images.push({ base64: parsed.base64, mimeType: parsed.mimeType });
319
+ continue;
320
+ }
321
+ images.push(await fetchUrlAsBase64(imageUrl, timeoutMs));
322
+ }
323
+ }
324
+
325
+ if (images.length === 0) {
326
+ throw new Error(
327
+ "接口未返回可用的图片数据(chat/completions 未找到 choices[].message.images)",
328
+ );
329
+ }
330
+
331
+ return images;
332
+ }
333
+
334
+ async function generateImages(params) {
335
+ const mode = String(process.env.OPENAI_IMAGE_MODE ?? "chat")
336
+ .trim()
337
+ .toLowerCase();
338
+
339
+ if (mode === "images") {
340
+ return await generateImagesViaImagesApi(params);
341
+ }
342
+
343
+ const count = clampInt(parseIntOr(params?.n, 1), 1, 4);
344
+
345
+ if (mode === "auto") {
346
+ try {
347
+ return await generateImagesViaImagesApi(params);
348
+ } catch (err) {
349
+ if (err instanceof HttpError && err.status === 404) {
350
+ debugLog("[upstream] images/generations 返回 404,改用 chat/completions");
351
+ /** @type {Array<{base64:string; mimeType:string}>} */
352
+ const out = [];
353
+ for (let i = 0; i < count; i += 1) {
354
+ const batch = await generateImagesViaChatCompletions(params);
355
+ out.push(...batch);
356
+ if (out.length >= count) break;
357
+ }
358
+ return out.slice(0, count);
359
+ }
360
+ throw err;
361
+ }
362
+ }
363
+
364
+ // chat (default)
365
+ /** @type {Array<{base64:string; mimeType:string}>} */
366
+ const out = [];
367
+ for (let i = 0; i < count; i += 1) {
368
+ const batch = await generateImagesViaChatCompletions(params);
369
+ out.push(...batch);
370
+ if (out.length >= count) break;
371
+ }
372
+ return out.slice(0, count);
373
+ }
374
+
375
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
376
+ tools: [
377
+ {
378
+ name: "generate_image",
379
+ description: `生成 AI 图片。当用户需要创建、绘制、生成图片/图像/插图/照片时使用此工具。
380
+
381
+ 使用场景:
382
+ - 用户说"画一个..."、"生成一张..."、"创建图片..."
383
+ - 需要可视化某个概念或想法
384
+ - 制作插图、图标、艺术作品
385
+
386
+ 返回说明:
387
+ - 默认会保存图片到本地并返回文件路径,同时返回图片数据供直接展示
388
+ - 设置 output="image" 则只返回图片数据不保存文件
389
+
390
+ 提示词技巧:prompt 越详细效果越好,建议包含:主体、风格、颜色、构图、光线等`,
391
+ inputSchema: {
392
+ type: "object",
393
+ properties: {
394
+ prompt: {
395
+ oneOf: [
396
+ { type: "string" },
397
+ { type: "array", items: { type: "string" } },
398
+ ],
399
+ description: "图片描述(必填)。详细描述想要生成的图片内容,如:'一只橙色的猫咪坐在窗台上,阳光透过窗户照进来,水彩画风格'",
400
+ },
401
+ size: {
402
+ oneOf: [{ type: "string" }, { type: "number" }, { type: "integer" }],
403
+ description: "图片尺寸。默认 1024x1024。可选:512x512、1024x1024、1024x1792(竖版)、1792x1024(横版)。传数字如 512 会自动变成 512x512",
404
+ },
405
+ n: {
406
+ oneOf: [{ type: "integer" }, { type: "number" }, { type: "string" }],
407
+ description: "生成数量。默认 1,最多 4。生成多张可以挑选最满意的",
408
+ },
409
+ output: {
410
+ type: "string",
411
+ description: "返回格式。默认 'path'(保存文件+返回路径+展示图片)。设为 'image' 只返回图片数据不保存文件",
412
+ },
413
+ outDir: {
414
+ type: "string",
415
+ description: "保存目录(必填)。指定图片保存的目录路径,支持绝对路径、相对路径或 ~ 开头的用户目录路径",
416
+ },
417
+ },
418
+ },
419
+ },
420
+ ],
421
+ }));
422
+
423
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
424
+ const toolName = request.params?.name;
425
+ if (toolName !== "generate_image") {
426
+ return {
427
+ isError: true,
428
+ content: [{ type: "text", text: `未知工具: ${toolName}` }],
429
+ };
430
+ }
431
+
432
+ const args = request.params?.arguments ?? {};
433
+
434
+ // 宽松解析 prompt:支持 string、array、或其他类型
435
+ let prompt = "";
436
+ if (Array.isArray(args.prompt)) {
437
+ prompt = args.prompt.map((x) => String(x ?? "")).join(" ").trim();
438
+ } else {
439
+ prompt = String(args.prompt ?? "").trim();
440
+ }
441
+ if (!prompt) {
442
+ return { isError: true, content: [{ type: "text", text: "参数 prompt 不能为空" }] };
443
+ }
444
+
445
+ // 宽松解析 size:支持 string、number(如 1024 → "1024x1024")
446
+ let size = String(args.size ?? process.env.OPENAI_IMAGE_SIZE ?? DEFAULT_SIZE).trim();
447
+ if (/^\d+$/.test(size)) {
448
+ size = `${size}x${size}`;
449
+ }
450
+
451
+ // 宽松解析 n:支持 integer、number、string
452
+ const n = clampInt(parseIntOr(args.n, 1), 1, 4);
453
+
454
+ // 宽松解析 output:识别多种同义词
455
+ const outputRaw = String(args.output ?? process.env.OPENAI_IMAGE_RETURN ?? DEFAULT_OUTPUT)
456
+ .trim()
457
+ .toLowerCase();
458
+ const output = ["image", "base64", "b64", "data", "inline"].includes(outputRaw) ? "image" : "path";
459
+
460
+ // 宽松解析 outDir:支持多种参数命名风格
461
+ const outDir = resolveOutDir(
462
+ args.outDir ?? args.out_dir ?? args.outdir ?? args.output_dir ?? process.env.OPENAI_IMAGE_OUT_DIR
463
+ );
464
+
465
+ // output=path 模式下,outDir 是必填的
466
+ if (output === "path" && !outDir) {
467
+ return {
468
+ isError: true,
469
+ content: [{
470
+ type: "text",
471
+ text: "参数 outDir 不能为空。请指定图片保存目录,例如:\n- Windows: outDir: 'C:/Users/xxx/Pictures' 或 '~/Pictures'\n- macOS/Linux: outDir: '~/Pictures' 或 '/home/xxx/Pictures'\n注:~ 会自动解析为用户主目录"
472
+ }]
473
+ };
474
+ }
475
+
476
+ const baseUrl = process.env.OPENAI_BASE_URL ?? "http://127.0.0.1:8317";
477
+ const apiKey = process.env.OPENAI_API_KEY ?? process.env.GEMINI_API_KEY ?? "";
478
+
479
+ // 模型由环境变量控制,不在工具调用时指定
480
+ const model = process.env.OPENAI_MODEL ?? DEFAULT_MODEL;
481
+
482
+ const timeoutMs = clampInt(
483
+ parseIntOr(process.env.OPENAI_TIMEOUT_MS, DEFAULT_TIMEOUT_MS),
484
+ 5_000,
485
+ 600_000,
486
+ );
487
+
488
+ try {
489
+ const images = await generateImages({
490
+ baseUrl,
491
+ apiKey,
492
+ model,
493
+ prompt,
494
+ size,
495
+ n,
496
+ timeoutMs,
497
+ });
498
+
499
+ if (output === "image") {
500
+ return {
501
+ content: images.map((img) => ({
502
+ type: "image",
503
+ mimeType: img.mimeType,
504
+ data: img.base64,
505
+ })),
506
+ };
507
+ }
508
+
509
+ await fs.mkdir(outDir, { recursive: true });
510
+ const batchId = `${formatDateForFilename(new Date())}-${crypto.randomBytes(4).toString("hex")}`;
511
+ const saved = [];
512
+ const errors = [];
513
+
514
+ for (let i = 0; i < images.length; i += 1) {
515
+ const img = images[i];
516
+ const ext = extFromMime(img.mimeType);
517
+ const filePath = path.join(outDir, `image-${batchId}-${i + 1}.${ext}`);
518
+
519
+ try {
520
+ // 验证 base64 有效性
521
+ if (!img.base64 || typeof img.base64 !== "string") {
522
+ errors.push(`图片 ${i + 1}: 无效的图片数据`);
523
+ continue;
524
+ }
525
+ const buffer = Buffer.from(img.base64, "base64");
526
+ if (buffer.length === 0) {
527
+ errors.push(`图片 ${i + 1}: 图片数据为空`);
528
+ continue;
529
+ }
530
+ await fs.writeFile(filePath, buffer);
531
+ saved.push(filePath);
532
+ } catch (writeErr) {
533
+ errors.push(`图片 ${i + 1}: 保存失败 - ${writeErr.message}`);
534
+ }
535
+ }
536
+
537
+ debugLog(`[local] 已保存 ${saved.length} 张图片到 ${outDir}`);
538
+
539
+ // 构建结构化返回
540
+ const resultLines = [];
541
+ if (saved.length > 0) {
542
+ resultLines.push(`✅ 成功生成 ${saved.length} 张图片:\n`);
543
+ // 使用 Markdown 图片语法,让支持的客户端可以直接渲染
544
+ saved.forEach((p) => {
545
+ const displayPath = toDisplayPath(p);
546
+ // file:// URI 格式,兼容大多数 Markdown 渲染器
547
+ const fileUri = `file:///${displayPath.replace(/^\//, '')}`;
548
+ resultLines.push(`![${path.basename(p)}](${fileUri})`);
549
+ resultLines.push(`📁 ${displayPath}\n`);
550
+ });
551
+ }
552
+ if (errors.length > 0) {
553
+ resultLines.push(`⚠️ 部分失败:`);
554
+ errors.forEach((e) => resultLines.push(e));
555
+ }
556
+
557
+ // 构建返回内容
558
+ const content = [
559
+ {
560
+ type: "text",
561
+ text: resultLines.join("\n"),
562
+ },
563
+ ];
564
+
565
+ // 智能判断是否附带图片数据(作为备选,某些客户端可能不支持 file:// URI):
566
+ // - 小图片(< 阈值):附带图片数据,确保能展示
567
+ // - 大图片(≥ 阈值):只用 Markdown 路径,避免 token 爆炸
568
+ // 可通过环境变量 OPENAI_IMAGE_INLINE_MAX_SIZE 调整阈值(单位:字节,默认 512KB)
569
+ // 设为 0 可完全禁用 base64 内联,只使用 Markdown 路径
570
+ const inlineMaxSize = parseIntOr(process.env.OPENAI_IMAGE_INLINE_MAX_SIZE, 512 * 1024);
571
+
572
+ if (inlineMaxSize > 0) {
573
+ for (const img of images) {
574
+ if (img.base64 && typeof img.base64 === "string") {
575
+ const estimatedSize = img.base64.length * 0.75;
576
+ if (estimatedSize <= inlineMaxSize) {
577
+ content.push({
578
+ type: "image",
579
+ mimeType: img.mimeType || "image/png",
580
+ data: img.base64,
581
+ });
582
+ }
583
+ }
584
+ }
585
+ }
586
+
587
+ return { content };
588
+ } catch (err) {
589
+ const errMsg = err instanceof Error ? err.message : String(err);
590
+ // 提供更友好的错误信息和建议
591
+ let suggestion = "";
592
+ if (errMsg.includes("ECONNREFUSED") || errMsg.includes("ENOTFOUND")) {
593
+ suggestion = "\n💡 建议:检查 OPENAI_BASE_URL 是否正确,服务是否已启动";
594
+ } else if (errMsg.includes("401") || errMsg.includes("API Key")) {
595
+ suggestion = "\n💡 建议:设置 OPENAI_API_KEY 或 GEMINI_API_KEY 环境变量";
596
+ } else if (errMsg.includes("超时")) {
597
+ suggestion = "\n💡 建议:增加 OPENAI_TIMEOUT_MS 环境变量(当前默认 120 秒)";
598
+ } else if (errMsg.includes("ENOSPC")) {
599
+ suggestion = "\n💡 建议:磁盘空间不足,请清理后重试";
600
+ } else if (errMsg.includes("EACCES") || errMsg.includes("EPERM")) {
601
+ suggestion = "\n💡 建议:没有写入权限,请检查 outDir 目录权限";
602
+ }
603
+
604
+ return {
605
+ isError: true,
606
+ content: [
607
+ {
608
+ type: "text",
609
+ text: `❌ 生成失败: ${errMsg}${suggestion}`,
610
+ },
611
+ ],
612
+ };
613
+ }
614
+ });
615
+
616
+ const transport = new StdioServerTransport();
617
+
618
+ // 全局异常处理
619
+ process.on("uncaughtException", (err) => {
620
+ console.error(`[gemini-image-mcp] 未捕获异常: ${err.message}`);
621
+ debugLog(err.stack);
622
+ });
623
+
624
+ process.on("unhandledRejection", (reason) => {
625
+ console.error(`[gemini-image-mcp] 未处理的 Promise 拒绝: ${reason}`);
626
+ });
627
+
628
+ await server.connect(transport);
629
+ console.error("gemini-image-mcp 已启动(stdio)");