mcp-duojie-image 0.5.0 → 0.5.2

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,169 +1,169 @@
1
- # mcp-duojie-image
2
-
3
- MCP server for image generation via duojie.games API (Gemini Flash Image).
4
-
5
- ## Features
6
-
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
13
-
14
- ## Installation
15
-
16
- ```bash
17
- npx mcp-duojie-image
18
- ```
19
-
20
- Or with a specific version:
21
-
22
- ```bash
23
- npx mcp-duojie-image@0.1.3
24
- ```
25
-
26
- ## Configuration
27
-
28
- Set the `DUOJIE_API_KEY` environment variable:
29
-
30
- ```bash
31
- export DUOJIE_API_KEY=sk-your-key-here
32
- ```
33
-
34
- Or configure in your MCP client (e.g., Claude Desktop, Factory):
35
-
36
- ```json
37
- {
38
- "mcpServers": {
39
- "duojie-image": {
40
- "command": "node",
41
- "args": ["/path/to/mcp-duojie-image/dist/index.js"],
42
- "env": {
43
- "DUOJIE_API_KEY": "sk-your-key-here"
44
- }
45
- }
46
- }
47
- }
48
- ```
49
-
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 |
57
-
58
- ## Tools
59
-
60
- ### `generate_image`
61
-
62
- Generate an image and save it to disk.
63
-
64
- **Required parameters:**
65
- - `prompt` (string) — Image description
66
- - `output_dir` (string) — Output directory (absolute path)
67
- - `output_filename` (string) — Output filename (no extension needed)
68
-
69
- **Optional parameters:**
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
- ```
166
-
167
- ## License
168
-
169
- MIT
1
+ # mcp-duojie-image
2
+
3
+ MCP server for image generation via duojie.games API (Gemini Flash Image).
4
+
5
+ ## Features
6
+
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
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npx mcp-duojie-image
18
+ ```
19
+
20
+ Or with a specific version:
21
+
22
+ ```bash
23
+ npx mcp-duojie-image@0.1.3
24
+ ```
25
+
26
+ ## Configuration
27
+
28
+ Set the `DUOJIE_API_KEY` environment variable:
29
+
30
+ ```bash
31
+ export DUOJIE_API_KEY=sk-your-key-here
32
+ ```
33
+
34
+ Or configure in your MCP client (e.g., Claude Desktop, Factory):
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "duojie-image": {
40
+ "command": "node",
41
+ "args": ["/path/to/mcp-duojie-image/dist/index.js"],
42
+ "env": {
43
+ "DUOJIE_API_KEY": "sk-your-key-here"
44
+ }
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
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 |
57
+
58
+ ## Tools
59
+
60
+ ### `generate_image`
61
+
62
+ Generate an image and save it to disk.
63
+
64
+ **Required parameters:**
65
+ - `prompt` (string) — Image description
66
+ - `output_dir` (string) — Output directory (absolute path)
67
+ - `output_filename` (string) — Output filename (no extension needed)
68
+
69
+ **Optional parameters:**
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
+ ```
166
+
167
+ ## License
168
+
169
+ MIT
package/dist/index.js CHANGED
@@ -23,19 +23,19 @@ const server = new McpServer({
23
23
  version: "0.1.0",
24
24
  });
25
25
  server.registerTool("generate_image", {
26
- description: "Generate an image and save to disk. Returns immediately; generation runs in background (60~120s) with system notification on completion.",
26
+ description: "Generate image async. Ask user to choose model before calling.",
27
27
  inputSchema: z.object({
28
- prompt: z.string().min(1).describe("Image prompt"),
29
- output_dir: z.string().describe("Output directory (absolute path)"),
30
- output_filename: z.string().describe("Output filename (no extension needed)"),
31
- reference_images: z.array(z.string()).optional().describe("Local image paths for reference"),
32
- size: z.enum(["512px", "1K", "2K", "4K"]).optional().describe("Image size, default 1K. Use 512px for thumbnails, 2K/4K only if explicitly requested."),
28
+ prompt: z.string().min(1).describe("Prompt"),
29
+ output_dir: z.string().describe("Absolute output dir"),
30
+ output_filename: z.string().describe("Filename without ext"),
31
+ reference_images: z.array(z.string()).optional().describe("Reference image paths"),
32
+ size: z.enum(["512px", "1K", "2K", "4K"]).optional().describe("Default 1K"),
33
33
  aspect_ratio: z.string().optional().describe("e.g. 16:9"),
34
- format: z.string().optional().describe("Output format, default png"),
35
- model: z.string().optional().describe("Model override"),
36
- overwrite: z.boolean().optional().describe("Overwrite existing file, default false"),
37
- negative_prompt: z.string().optional().describe("What to avoid"),
38
- silent: z.boolean().optional().describe("Suppress system notification on completion. Default false."),
34
+ format: z.string().optional().describe("Default png"),
35
+ model: z.enum(["gemini-3.1-flash-image", "gpt-image-2"]).describe("Required"),
36
+ overwrite: z.boolean().optional().describe("Default false"),
37
+ negative_prompt: z.string().optional().describe("Avoid"),
38
+ silent: z.boolean().optional().describe("No notification"),
39
39
  }),
40
40
  }, async (args) => {
41
41
  let config;
@@ -93,7 +93,7 @@ server.registerTool("generate_image", {
93
93
  type: "text",
94
94
  text: JSON.stringify({
95
95
  status: "pending",
96
- message: `开始异步生成 ${args.output_filename},通常需要等待 60~120s。请耐心等待。任务完成后会有通知。`,
96
+ message: `已开始:${args.output_filename}`,
97
97
  }),
98
98
  }],
99
99
  };
package/dist/provider.js CHANGED
@@ -43,7 +43,50 @@ function mapSize(size) {
43
43
  return "512";
44
44
  return size; // "1K", "2K", "4K" are used as-is
45
45
  }
46
- export async function generateImage(config, opts) {
46
+ function isOpenAIImageModel(model) {
47
+ return model === "gpt-image-2";
48
+ }
49
+ function mapOpenAISize(size, aspectRatio) {
50
+ const squareBySize = {
51
+ "512px": "512x512",
52
+ "1K": "1024x1024",
53
+ "2K": "2048x2048",
54
+ "4K": "4096x4096",
55
+ };
56
+ if (aspectRatio === "16:9" || aspectRatio === "3:2" || aspectRatio === "4:3") {
57
+ return size === "2K" ? "2048x1536" : size === "4K" ? "4096x3072" : "1536x1024";
58
+ }
59
+ if (aspectRatio === "9:16" || aspectRatio === "2:3" || aspectRatio === "3:4") {
60
+ return size === "2K" ? "1536x2048" : size === "4K" ? "3072x4096" : "1024x1536";
61
+ }
62
+ return squareBySize[size] ?? "1024x1024";
63
+ }
64
+ async function imageFromOpenAIResponse(res) {
65
+ const data = (await res.json());
66
+ const image = data.data?.[0];
67
+ if (image?.b64_json) {
68
+ return Buffer.from(image.b64_json, "base64");
69
+ }
70
+ if (image?.url) {
71
+ const imgRes = await fetch(image.url);
72
+ if (!imgRes.ok) {
73
+ throw new AppError("E_PROVIDER_BAD_REQUEST", `Image URL fetch failed: ${imgRes.status}`);
74
+ }
75
+ return Buffer.from(await imgRes.arrayBuffer());
76
+ }
77
+ throw new AppError("E_PROVIDER_BAD_REQUEST", "No image in response");
78
+ }
79
+ async function handleProviderError(res) {
80
+ if (res.status === 401 || res.status === 403) {
81
+ throw new AppError("E_PROVIDER_AUTH", `Auth failed (HTTP ${res.status})`);
82
+ }
83
+ if (res.status === 429) {
84
+ throw new AppError("E_PROVIDER_RATE_LIMIT", "Rate limit exceeded");
85
+ }
86
+ const text = await res.text().catch(() => "");
87
+ throw new AppError("E_PROVIDER_BAD_REQUEST", `Upstream error ${res.status}: ${text.slice(0, 200)}`);
88
+ }
89
+ async function generateGeminiImage(config, opts) {
47
90
  const parts = await buildParts(opts);
48
91
  const model = opts.model ?? config.model;
49
92
  const imageConfig = {
@@ -68,15 +111,8 @@ export async function generateImage(config, opts) {
68
111
  },
69
112
  body,
70
113
  }, config.timeoutMs);
71
- if (res.status === 401 || res.status === 403) {
72
- throw new AppError("E_PROVIDER_AUTH", `Auth failed (HTTP ${res.status})`);
73
- }
74
- if (res.status === 429) {
75
- throw new AppError("E_PROVIDER_RATE_LIMIT", "Rate limit exceeded");
76
- }
77
114
  if (!res.ok) {
78
- const text = await res.text().catch(() => "");
79
- throw new AppError("E_PROVIDER_BAD_REQUEST", `Upstream error ${res.status}: ${text.slice(0, 200)}`);
115
+ await handleProviderError(res);
80
116
  }
81
117
  const data = (await res.json());
82
118
  const imagePart = data.candidates?.[0]?.content?.parts?.find((p) => p.inlineData);
@@ -85,3 +121,46 @@ export async function generateImage(config, opts) {
85
121
  }
86
122
  return Buffer.from(imagePart.inlineData.data, "base64");
87
123
  }
124
+ async function generateOpenAIImage(config, opts) {
125
+ const model = opts.model ?? config.model;
126
+ const prompt = opts.negativePrompt ? `${opts.prompt}\n\nAvoid: ${opts.negativePrompt}` : opts.prompt;
127
+ const size = mapOpenAISize(opts.size ?? "1K", opts.aspectRatio);
128
+ const hasReferenceImages = Boolean(opts.referenceImages?.length);
129
+ const url = `${config.baseUrl}${hasReferenceImages ? "/v1/images/edits" : "/v1/images/generations"}`;
130
+ let body;
131
+ let headers = {
132
+ Authorization: `Bearer ${config.apiKey}`,
133
+ };
134
+ if (hasReferenceImages) {
135
+ const form = new FormData();
136
+ form.set("model", model);
137
+ form.set("prompt", prompt);
138
+ form.set("size", size);
139
+ for (const [index, imgPath] of opts.referenceImages.entries()) {
140
+ const data = await resizeIfNeeded(imgPath);
141
+ const bytes = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
142
+ form.append("image", new Blob([bytes], { type: "image/png" }), `reference-${index}.png`);
143
+ }
144
+ body = form;
145
+ }
146
+ else {
147
+ headers = { ...headers, "Content-Type": "application/json" };
148
+ body = JSON.stringify({ model, prompt, size });
149
+ }
150
+ const res = await fetchWithRetry(url, {
151
+ method: "POST",
152
+ headers,
153
+ body,
154
+ }, config.timeoutMs);
155
+ if (!res.ok) {
156
+ await handleProviderError(res);
157
+ }
158
+ return imageFromOpenAIResponse(res);
159
+ }
160
+ export async function generateImage(config, opts) {
161
+ const model = opts.model ?? config.model;
162
+ if (isOpenAIImageModel(model)) {
163
+ return generateOpenAIImage(config, opts);
164
+ }
165
+ return generateGeminiImage(config, opts);
166
+ }
package/package.json CHANGED
@@ -1,45 +1,45 @@
1
- {
2
- "name": "mcp-duojie-image",
3
- "version": "0.5.0",
4
- "description": "MCP server for image generation via duojie.games",
5
- "type": "module",
6
- "main": "dist/index.js",
7
- "bin": {
8
- "mcp-duojie-image": "dist/index.js"
9
- },
10
- "scripts": {
11
- "dev": "tsx src/index.ts",
12
- "build": "tsc",
13
- "start": "node dist/index.js",
14
- "test": "vitest run",
15
- "lint": "tsc --noEmit",
16
- "prepublishOnly": "npm run build"
17
- },
18
- "keywords": [
19
- "mcp",
20
- "image-generation",
21
- "gemini",
22
- "ai"
23
- ],
24
- "author": "",
25
- "license": "MIT",
26
- "dependencies": {
27
- "@modelcontextprotocol/sdk": "^1.12.0",
28
- "jimp": "^1.6.0",
29
- "node-notifier": "^10.0.1",
30
- "zod": "^3.23.8"
31
- },
32
- "devDependencies": {
33
- "@types/node": "^22.0.0",
34
- "@types/node-notifier": "^8.0.5",
35
- "tsx": "^4.19.0",
36
- "typescript": "^5.5.0",
37
- "vitest": "^2.0.0"
38
- },
39
- "engines": {
40
- "node": ">=18.0.0"
41
- },
42
- "files": [
43
- "dist"
44
- ]
45
- }
1
+ {
2
+ "name": "mcp-duojie-image",
3
+ "version": "0.5.2",
4
+ "description": "MCP server for image generation via duojie.games",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "mcp-duojie-image": "dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "dev": "tsx src/index.ts",
12
+ "build": "tsc",
13
+ "start": "node dist/index.js",
14
+ "test": "vitest run",
15
+ "lint": "tsc --noEmit",
16
+ "prepublishOnly": "npm run build"
17
+ },
18
+ "keywords": [
19
+ "mcp",
20
+ "image-generation",
21
+ "gemini",
22
+ "ai"
23
+ ],
24
+ "author": "",
25
+ "license": "MIT",
26
+ "dependencies": {
27
+ "@modelcontextprotocol/sdk": "^1.12.0",
28
+ "jimp": "^1.6.0",
29
+ "node-notifier": "^10.0.1",
30
+ "zod": "^3.23.8"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^22.0.0",
34
+ "@types/node-notifier": "^8.0.5",
35
+ "tsx": "^4.19.0",
36
+ "typescript": "^5.5.0",
37
+ "vitest": "^2.0.0"
38
+ },
39
+ "engines": {
40
+ "node": ">=18.0.0"
41
+ },
42
+ "files": [
43
+ "dist"
44
+ ]
45
+ }