mcp-duojie-image 0.1.2 → 0.3.0

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.
package/README.md CHANGED
@@ -1,13 +1,15 @@
1
1
  # mcp-duojie-image
2
2
 
3
- MCP server for image generation via duojie.games API (Gemini 3.1 Flash Image).
3
+ MCP server for image generation via duojie.games API (Gemini Flash Image).
4
4
 
5
5
  ## Features
6
6
 
7
- - Text-to-image generation
8
- - Image-to-image with reference images
9
- - Automatic file saving with conflict handling
10
- - Configurable output format and size
7
+ - **Text-to-image generation** — generate images from prompts with configurable size and aspect ratio
8
+ - **Reference image support** — provide local image files as visual references (auto-compressed to 1024px long-side)
9
+ - **Async mode** — for long-running generations (especially with reference images), use `async_mode: true` to get a `task_id` immediately and poll for completion; prevents MCP client timeout
10
+ - **Sync mode** — for simple text-to-image generation without reference images, the default sync mode returns the result directly
11
+ - **File saving** — automatic output directory creation, conflict handling (`overwrite` flag, auto-incrementing filename suffix)
12
+ - **Configurable output** — size (512px/1K/2K/4K), aspect ratio, format
11
13
 
12
14
  ## Installation
13
15
 
@@ -15,22 +17,28 @@ MCP server for image generation via duojie.games API (Gemini 3.1 Flash Image).
15
17
  npx mcp-duojie-image
16
18
  ```
17
19
 
20
+ Or with a specific version:
21
+
22
+ ```bash
23
+ npx mcp-duojie-image@0.1.3
24
+ ```
25
+
18
26
  ## Configuration
19
27
 
20
- Set environment variable:
28
+ Set the `DUOJIE_API_KEY` environment variable:
21
29
 
22
30
  ```bash
23
31
  export DUOJIE_API_KEY=sk-your-key-here
24
32
  ```
25
33
 
26
- Or configure in MCP client (e.g., Claude Desktop):
34
+ Or configure in your MCP client (e.g., Claude Desktop, Factory):
27
35
 
28
36
  ```json
29
37
  {
30
38
  "mcpServers": {
31
39
  "duojie-image": {
32
- "command": "npx",
33
- "args": ["-y", "mcp-duojie-image"],
40
+ "command": "node",
41
+ "args": ["/path/to/mcp-duojie-image/dist/index.js"],
34
42
  "env": {
35
43
  "DUOJIE_API_KEY": "sk-your-key-here"
36
44
  }
@@ -39,23 +47,122 @@ Or configure in MCP client (e.g., Claude Desktop):
39
47
  }
40
48
  ```
41
49
 
42
- ## Usage
50
+ Optional environment variables:
51
+
52
+ | Variable | Default | Description |
53
+ |----------|---------|-------------|
54
+ | `DUOJIE_API_KEY` | (required) | API key |
55
+ | `DUOJIE_BASE_URL` | `https://api.duojie.ai/v1` | API endpoint |
56
+ | `DUOJIE_MODEL` | `gemini-2.0-flash-exp-image-generation` | Model to use |
43
57
 
44
- The server provides one tool: `generate_image`
58
+ ## Tools
59
+
60
+ ### `generate_image`
61
+
62
+ Generate an image and save it to disk.
45
63
 
46
64
  **Required parameters:**
47
- - `prompt`: Image description
48
- - `output_dir`: Output directory path
49
- - `output_filename`: Output filename (extension optional)
65
+ - `prompt` (string) — Image description
66
+ - `output_dir` (string) — Output directory (absolute path)
67
+ - `output_filename` (string) — Output filename (no extension needed)
50
68
 
51
69
  **Optional parameters:**
52
- - `reference_images`: Array of local image paths for reference
53
- - `size`: "512px" | "1K" | "2K" | "4K"
54
- - `aspect_ratio`: e.g., "16:9"
55
- - `format`: Output format (default: "png")
56
- - `model`: Model override
57
- - `overwrite`: Overwrite existing file (default: false)
58
- - `negative_prompt`: What to avoid in the image
70
+ - `reference_images` (string[]) — Local image paths for reference. Images are auto-compressed to max 1024px long-side before sending to API.
71
+ - `size` ("512px" | "1K" | "2K" | "4K") — Output image size. Default: `1K`
72
+ - `aspect_ratio` (string) — e.g., `"16:9"`, `"1:1"`
73
+ - `format` (string) — Output format. Default: `"png"`
74
+ - `model` (string) — Model override
75
+ - `overwrite` (boolean) — Overwrite existing file. Default: `false` (auto-increments filename)
76
+ - `negative_prompt` (string) — What to avoid in the image
77
+ - `async_mode` (boolean) — When `true`, returns `task_id` immediately and generates in background. **Recommended when using `reference_images`** to avoid MCP client timeout. Default: `false`
78
+
79
+ **Response (sync mode):**
80
+ ```json
81
+ { "success": true, "saved_path": "/absolute/path/to/image.png", "model": "gemini-..." }
82
+ ```
83
+
84
+ **Response (async mode):**
85
+ ```json
86
+ { "task_id": "uuid", "status": "pending" }
87
+ ```
88
+
89
+ ### `check_image_task`
90
+
91
+ Check the status of an async image generation task.
92
+
93
+ **Required parameters:**
94
+ - `task_id` (string) — Task ID returned by `generate_image` with `async_mode: true`
95
+
96
+ **Polling workflow:**
97
+ 1. Call `generate_image` with `async_mode: true` → get `task_id`
98
+ 2. Call `check_image_task` with `task_id` every few seconds
99
+ 3. When `status` is `"done"`, retrieve `saved_path` from the response
100
+ 4. If `status` is `"failed"`, check `error` and `message` fields
101
+
102
+ **Response:**
103
+ ```json
104
+ { "task_id": "uuid", "status": "done", "saved_path": "/path/to/image.png", "model": "gemini-..." }
105
+ ```
106
+
107
+ ## Usage Examples
108
+
109
+ ### Simple text-to-image (sync)
110
+
111
+ ```
112
+ generate_image(
113
+ prompt="A sunset over the ocean, photorealistic",
114
+ output_dir="/tmp/images",
115
+ output_filename="sunset"
116
+ )
117
+ ```
118
+
119
+ ### With reference image (use async_mode to avoid timeout)
120
+
121
+ ```
122
+ generate_image(
123
+ prompt="Same style as reference, but with mountains instead",
124
+ reference_images=["/path/to/reference.png"],
125
+ output_dir="/tmp/images",
126
+ output_filename="mountain_scene",
127
+ async_mode=true
128
+ )
129
+ → { "task_id": "abc-123", "status": "pending" }
130
+
131
+ check_image_task(task_id="abc-123")
132
+ → { "status": "running" } # poll again
133
+
134
+ check_image_task(task_id="abc-123")
135
+ → { "status": "done", "saved_path": "/tmp/images/mountain_scene.png" }
136
+ ```
137
+
138
+ ## Development
139
+
140
+ ```bash
141
+ # Install dependencies
142
+ npm install
143
+
144
+ # Build
145
+ npm run build
146
+
147
+ # Run tests
148
+ npm test
149
+
150
+ # Type check / lint
151
+ npm run lint
152
+ ```
153
+
154
+ ## Architecture
155
+
156
+ ```
157
+ src/
158
+ index.ts — MCP server, tool registration (generate_image, check_image_task)
159
+ provider.ts — HTTP client for image generation API
160
+ resizer.ts — Reference image compression (jimp, max 1024px long-side)
161
+ task-queue.ts — In-memory async task state machine (pending→running→done/failed, 30min TTL)
162
+ config.ts — Environment variable loading
163
+ saver.ts — File saving with conflict handling
164
+ errors.ts — Error codes (E_CONFIG_MISSING_KEY, E_PROVIDER_AUTH, etc.)
165
+ ```
59
166
 
60
167
  ## License
61
168
 
package/dist/config.js CHANGED
@@ -13,6 +13,6 @@ export function loadConfig(overrides) {
13
13
  apiKey,
14
14
  baseUrl: overrides?.baseUrl ?? process.env.DUOJIE_BASE_URL ?? "https://api.duojie.games",
15
15
  model: overrides?.model ?? process.env.DUOJIE_IMAGE_MODEL ?? "gemini-3.1-flash-image",
16
- timeoutMs: 60_000,
16
+ timeoutMs: 300_000,
17
17
  };
18
18
  }
package/dist/index.js CHANGED
@@ -2,27 +2,40 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
+ import { createRequire } from "node:module";
5
6
  import { loadConfig } from "./config.js";
6
7
  import { generateImage } from "./provider.js";
7
8
  import { saveImage } from "./saver.js";
8
9
  import { AppError } from "./errors.js";
10
+ const require = createRequire(import.meta.url);
11
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
+ const notifier = require("node-notifier");
13
+ function notify(title, message) {
14
+ try {
15
+ notifier.notify({ title, message, sound: false });
16
+ }
17
+ catch {
18
+ // 系统通知失败时静默忽略
19
+ }
20
+ }
9
21
  const server = new McpServer({
10
22
  name: "duojie-image",
11
23
  version: "0.1.0",
12
24
  });
13
25
  server.registerTool("generate_image", {
14
- description: "Generate an image and save to disk.",
26
+ description: "Generate an image and save to disk. Returns immediately; generation runs in background (60~120s) with system notification on completion.",
15
27
  inputSchema: z.object({
16
28
  prompt: z.string().min(1).describe("Image prompt"),
17
29
  output_dir: z.string().describe("Output directory (absolute path)"),
18
30
  output_filename: z.string().describe("Output filename (no extension needed)"),
19
31
  reference_images: z.array(z.string()).optional().describe("Local image paths for reference"),
20
- size: z.enum(["512px", "1K", "2K", "4K"]).optional().describe("Image size"),
32
+ size: z.enum(["512px", "1K", "2K", "4K"]).optional().describe("Image size, default 1K. Use 512px for thumbnails, 2K/4K only if explicitly requested."),
21
33
  aspect_ratio: z.string().optional().describe("e.g. 16:9"),
22
34
  format: z.string().optional().describe("Output format, default png"),
23
35
  model: z.string().optional().describe("Model override"),
24
36
  overwrite: z.boolean().optional().describe("Overwrite existing file, default false"),
25
37
  negative_prompt: z.string().optional().describe("What to avoid"),
38
+ silent: z.boolean().optional().describe("Suppress system notification on completion. Default false."),
26
39
  }),
27
40
  }, async (args) => {
28
41
  let config;
@@ -33,40 +46,57 @@ server.registerTool("generate_image", {
33
46
  const code = err instanceof AppError ? err.code : "E_CONFIG_MISSING_KEY";
34
47
  return { content: [{ type: "text", text: JSON.stringify({ success: false, error: code, message: String(err instanceof Error ? err.message : err) }) }], isError: true };
35
48
  }
36
- // Enrich prompt with size/aspect hints
37
- let fullPrompt = args.prompt;
38
- if (args.size)
39
- fullPrompt += ` [size: ${args.size}]`;
40
- if (args.aspect_ratio)
41
- fullPrompt += ` [aspect_ratio: ${args.aspect_ratio}]`;
42
- try {
43
- const imageBuffer = await generateImage(config, {
44
- prompt: fullPrompt,
45
- referenceImages: args.reference_images,
46
- model: args.model,
47
- negativePrompt: args.negative_prompt,
48
- });
49
- const savedPath = saveImage(imageBuffer, {
50
- outputDir: args.output_dir,
51
- outputFilename: args.output_filename,
52
- format: args.format ?? "png",
53
- overwrite: args.overwrite ?? false,
54
- });
55
- return {
56
- content: [{
57
- type: "text",
58
- text: JSON.stringify({ success: true, saved_path: savedPath, model: args.model ?? config.model }),
59
- }],
60
- };
61
- }
62
- catch (err) {
63
- const code = err instanceof AppError ? err.code : "E_PROVIDER_BAD_REQUEST";
64
- const msg = err instanceof Error ? err.message : String(err);
65
- return {
66
- content: [{ type: "text", text: JSON.stringify({ success: false, error: code, message: msg }) }],
67
- isError: true,
68
- };
69
- }
49
+ const silent = args.silent ?? false;
50
+ // Start background generation (don't await)
51
+ (async () => {
52
+ try {
53
+ const imageBuffer = await generateImage(config, {
54
+ prompt: args.prompt,
55
+ referenceImages: args.reference_images,
56
+ model: args.model,
57
+ negativePrompt: args.negative_prompt,
58
+ size: args.size ?? "1K",
59
+ aspectRatio: args.aspect_ratio,
60
+ });
61
+ const savedPath = saveImage(imageBuffer, {
62
+ outputDir: args.output_dir,
63
+ outputFilename: args.output_filename,
64
+ format: args.format ?? "png",
65
+ overwrite: args.overwrite ?? false,
66
+ });
67
+ await server.sendLoggingMessage({
68
+ level: "info",
69
+ data: `图片生成完成:${savedPath}`,
70
+ });
71
+ if (!silent)
72
+ notify("图片生成完成", savedPath);
73
+ }
74
+ catch (err) {
75
+ const code = err instanceof AppError ? err.code : "E_INTERNAL";
76
+ const msg = err instanceof Error ? err.message : String(err);
77
+ await server.sendLoggingMessage({
78
+ level: "error",
79
+ data: `图片生成失败 [${code}]: ${msg}`,
80
+ }).catch(() => { });
81
+ if (!silent) {
82
+ if (code === "E_PROVIDER_TIMEOUT") {
83
+ notify("图片生成超时", `${args.output_filename} 生成超过 300 秒,请重试`);
84
+ }
85
+ else {
86
+ notify("图片生成失败", `[${code}] ${msg}`);
87
+ }
88
+ }
89
+ }
90
+ })();
91
+ return {
92
+ content: [{
93
+ type: "text",
94
+ text: JSON.stringify({
95
+ status: "pending",
96
+ message: `开始异步生成 ${args.output_filename},通常需要等待 60~120s。请耐心等待。任务完成后会有通知。`,
97
+ }),
98
+ }],
99
+ };
70
100
  });
71
101
  const transport = new StdioServerTransport();
72
102
  await server.connect(transport);
package/dist/provider.js CHANGED
@@ -1,6 +1,6 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { AppError } from "./errors.js";
3
- async function fetchWithRetry(url, init, timeoutMs, retries = 2) {
2
+ import { resizeIfNeeded } from "./resizer.js";
3
+ async function fetchWithRetry(url, init, timeoutMs, retries = 0) {
4
4
  let lastErr;
5
5
  for (let attempt = 0; attempt <= retries; attempt++) {
6
6
  const controller = new AbortController();
@@ -24,33 +24,30 @@ async function fetchWithRetry(url, init, timeoutMs, retries = 2) {
24
24
  }
25
25
  throw new AppError("E_PROVIDER_BAD_REQUEST", `Network error: ${lastErr}`);
26
26
  }
27
- function buildMessages(opts) {
27
+ async function buildMessages(opts) {
28
28
  const { prompt, referenceImages, negativePrompt } = opts;
29
29
  const fullPrompt = negativePrompt ? `${prompt}\n\nAvoid: ${negativePrompt}` : prompt;
30
30
  if (!referenceImages || referenceImages.length === 0) {
31
31
  return [{ role: "user", content: fullPrompt }];
32
32
  }
33
- const parts = referenceImages.map((imgPath) => {
34
- let data;
35
- try {
36
- data = readFileSync(imgPath);
37
- }
38
- catch {
39
- throw new AppError("E_REFERENCE_NOT_FOUND", `Cannot read reference image: ${imgPath}`);
40
- }
33
+ const parts = await Promise.all(referenceImages.map(async (imgPath) => {
34
+ const data = await resizeIfNeeded(imgPath);
41
35
  const b64 = data.toString("base64");
42
- const mime = imgPath.toLowerCase().endsWith(".jpg") || imgPath.toLowerCase().endsWith(".jpeg")
43
- ? "image/jpeg"
44
- : "image/png";
45
- return { type: "image_url", image_url: { url: `data:${mime};base64,${b64}` } };
46
- });
36
+ return { type: "image_url", image_url: { url: `data:image/png;base64,${b64}` } };
37
+ }));
47
38
  parts.push({ type: "text", text: fullPrompt });
48
39
  return [{ role: "user", content: parts }];
49
40
  }
50
41
  export async function generateImage(config, opts) {
51
- const messages = buildMessages(opts);
42
+ const messages = await buildMessages(opts);
52
43
  const model = opts.model ?? config.model;
53
- const body = JSON.stringify({ model, messages });
44
+ const imageConfig = {
45
+ image_size: opts.size ?? "1K",
46
+ };
47
+ if (opts.aspectRatio) {
48
+ imageConfig.aspect_ratio = opts.aspectRatio;
49
+ }
50
+ const body = JSON.stringify({ model, messages, image_config: imageConfig });
54
51
  const url = `${config.baseUrl}/v1/chat/completions`;
55
52
  const res = await fetchWithRetry(url, {
56
53
  method: "POST",
@@ -0,0 +1,17 @@
1
+ import { Jimp, JimpMime } from "jimp";
2
+ import { AppError } from "./errors.js";
3
+ const MAX_SIDE = 1024;
4
+ export async function resizeIfNeeded(imgPath) {
5
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
+ let image;
7
+ try {
8
+ image = await Jimp.read(imgPath);
9
+ }
10
+ catch {
11
+ throw new AppError("E_REFERENCE_NOT_FOUND", `Cannot read reference image: ${imgPath}`);
12
+ }
13
+ if (image.width > MAX_SIDE || image.height > MAX_SIDE) {
14
+ image.scaleToFit({ w: MAX_SIDE, h: MAX_SIDE });
15
+ }
16
+ return image.getBuffer(JimpMime.png);
17
+ }
@@ -0,0 +1,37 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const tasks = new Map();
3
+ // 清理超过 30 分钟的已完成/失败任务
4
+ const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
5
+ const TASK_TTL_MS = 30 * 60 * 1000;
6
+ // Cleanup function exposed for testing
7
+ export function cleanupExpiredTasks() {
8
+ const now = Date.now();
9
+ for (const [id, task] of tasks) {
10
+ if ((task.status === "done" || task.status === "failed") &&
11
+ task.completedAt &&
12
+ now - task.completedAt > TASK_TTL_MS) {
13
+ tasks.delete(id);
14
+ }
15
+ }
16
+ }
17
+ setInterval(() => {
18
+ cleanupExpiredTasks();
19
+ }, CLEANUP_INTERVAL_MS).unref();
20
+ export function createTask() {
21
+ const task = {
22
+ id: randomUUID(),
23
+ status: "pending",
24
+ createdAt: Date.now(),
25
+ };
26
+ tasks.set(task.id, task);
27
+ return task;
28
+ }
29
+ export function getTask(id) {
30
+ return tasks.get(id);
31
+ }
32
+ export function updateTask(id, updates) {
33
+ const task = tasks.get(id);
34
+ if (task) {
35
+ Object.assign(task, updates);
36
+ }
37
+ }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "mcp-duojie-image",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for image generation via duojie.games",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
8
- "mcp-duojie-image": "./dist/index.js"
8
+ "mcp-duojie-image": "dist/index.js"
9
9
  },
10
10
  "scripts": {
11
11
  "dev": "tsx src/index.ts",
@@ -25,10 +25,13 @@
25
25
  "license": "MIT",
26
26
  "dependencies": {
27
27
  "@modelcontextprotocol/sdk": "^1.12.0",
28
+ "jimp": "^1.6.0",
29
+ "node-notifier": "^10.0.1",
28
30
  "zod": "^3.23.8"
29
31
  },
30
32
  "devDependencies": {
31
33
  "@types/node": "^22.0.0",
34
+ "@types/node-notifier": "^8.0.5",
32
35
  "tsx": "^4.19.0",
33
36
  "typescript": "^5.5.0",
34
37
  "vitest": "^2.0.0"