mcp-duojie-image 0.9.0 → 0.9.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.
package/README.md CHANGED
@@ -8,7 +8,7 @@ MCP server for image generation via duojie.games GPT image API.
8
8
  - Reference image support: requests with reference images route through `/v1/images/edits` (multipart form); text-only requests route through `/v1/images/generations` (JSON body)
9
9
  - Prompt structuring helpers for use case, asset type, constraints, and effective prompt echo
10
10
  - Optional chroma-key transparent background post-processing
11
- - Async submission with task status lookup
11
+ - Async submission with explicit accepted/terminal semantics and required task polling
12
12
  - File saving with overwrite/conflict handling
13
13
  - Verified size templates for `1K`, `2K`, and `4K`
14
14
 
@@ -23,7 +23,11 @@ Required:
23
23
 
24
24
  ## Tool: `generate_image`
25
25
 
26
- Generates an image asynchronously and saves it to disk.
26
+ Accepts an asynchronous image task and saves the image to disk when the task
27
+ finishes. A submitted response is not a successful generation. Call
28
+ `check_image_task` until it returns `status: "done"` or `status: "failed"`.
29
+ Non-terminal responses intentionally omit `success`; only terminal responses use
30
+ `success: true` or `success: false`.
27
31
 
28
32
  Required parameters:
29
33
 
@@ -69,11 +73,19 @@ Response:
69
73
 
70
74
  ```json
71
75
  {
72
- "success": true,
76
+ "accepted": true,
77
+ "terminal": false,
73
78
  "status": "submitted",
74
79
  "task_id": "uuid",
80
+ "poll_required": true,
81
+ "retry_after_ms": 1000,
75
82
  "effective_prompt": "Primary request: ...",
76
- "message": "已提交异步生成;如需诊断失败原因,可调用 check_image_task"
83
+ "prompt_stats": {
84
+ "characters": 123,
85
+ "utf8Bytes": 145,
86
+ "lines": 4
87
+ },
88
+ "message": "已接受异步生成任务;这不代表图片已生成,必须轮询 check_image_task"
77
89
  }
78
90
  ```
79
91
 
@@ -90,6 +102,7 @@ Done response:
90
102
  ```json
91
103
  {
92
104
  "success": true,
105
+ "terminal": true,
93
106
  "task_id": "uuid",
94
107
  "status": "done",
95
108
  "saved_path": "D:/images/output.png",
@@ -102,13 +115,32 @@ Failed response:
102
115
  ```json
103
116
  {
104
117
  "success": false,
118
+ "terminal": true,
105
119
  "task_id": "uuid",
106
120
  "status": "failed",
107
- "error": "E_PROVIDER_BAD_REQUEST",
108
- "message": "Upstream error details"
121
+ "error": "E_PROVIDER_EMPTY_RESPONSE",
122
+ "message": "Provider returned HTTP 200 but data contained no image",
123
+ "details": {
124
+ "request_id": "provider-request-id",
125
+ "response_summary": {
126
+ "status": 200,
127
+ "content_type": "application/json",
128
+ "body": {
129
+ "kind": "json_object",
130
+ "keys": ["data"],
131
+ "data_length": 1,
132
+ "data_0_keys": []
133
+ }
134
+ }
135
+ }
109
136
  }
110
137
  ```
111
138
 
139
+ Provider diagnostics contain only response metadata and JSON shape/key summaries;
140
+ prompt text, image payloads, authorization values, and arbitrary upstream body
141
+ content are not echoed. The configured provider timeout covers receiving headers,
142
+ reading the response body, and downloading a returned image URL.
143
+
112
144
  ## Verified GPT size templates
113
145
 
114
146
  These templates were tested directly against `/v1/images/generations`; all returned the requested dimensions.
package/dist/errors.js CHANGED
@@ -1,8 +1,10 @@
1
1
  export class AppError extends Error {
2
2
  code;
3
- constructor(code, message) {
3
+ details;
4
+ constructor(code, message, details) {
4
5
  super(message);
5
6
  this.code = code;
7
+ this.details = details;
6
8
  this.name = "AppError";
7
9
  }
8
10
  }
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { ASPECT_RATIOS, IMAGE_SIZES, generateImage } from "./provider.js";
8
8
  import { saveImage } from "./saver.js";
9
9
  import { AppError } from "./errors.js";
10
10
  import { createTask, getTask, updateTask } from "./task-queue.js";
11
+ import { taskStatusResponse, taskSubmissionResponse } from "./task-response.js";
11
12
  import { IMAGE_INPUT_ROLES, PROMPT_USE_CASES, preparePrompt } from "./prompt.js";
12
13
  import { OUTPUT_FORMATS, removeChromaKey } from "./image-processing.js";
13
14
  const require = createRequire(import.meta.url);
@@ -31,10 +32,10 @@ function toolText(data) {
31
32
  }
32
33
  const server = new McpServer({
33
34
  name: "duojie-image",
34
- version: "0.1.0",
35
+ version: "0.9.1",
35
36
  });
36
37
  server.registerTool("generate_image", {
37
- description: "Generate image async with gpt-image-2. Reference images route through /v1/images/edits (multipart form); text-only requests route through /v1/images/generations. Return after tool call; don't wait/check.",
38
+ description: "Accept an asynchronous gpt-image-2 task. This submission is not generation success: after receiving task_id, you MUST call check_image_task until status is done or failed. Reference images use /v1/images/edits; text-only requests use /v1/images/generations.",
38
39
  inputSchema: z.object({
39
40
  prompt: z.string().min(1).describe("Prompt"),
40
41
  output_dir: z.string().describe("Absolute output dir"),
@@ -104,7 +105,10 @@ server.registerTool("generate_image", {
104
105
  return { content: [{ type: "text", text: JSON.stringify({ success: false, error: code, message: String(err instanceof Error ? err.message : err) }) }], isError: true };
105
106
  }
106
107
  const task = createTask();
107
- updateTask(task.id, { effectivePrompt: preparedPrompt.prompt });
108
+ updateTask(task.id, {
109
+ effectivePrompt: preparedPrompt.prompt,
110
+ promptStats: preparedPrompt.stats,
111
+ });
108
112
  // Start background generation (don't await)
109
113
  (async () => {
110
114
  try {
@@ -146,6 +150,7 @@ server.registerTool("generate_image", {
146
150
  completedAt: Date.now(),
147
151
  errorCode: code,
148
152
  error: msg,
153
+ errorDetails: err instanceof AppError ? err.details : undefined,
149
154
  });
150
155
  await server.sendLoggingMessage({
151
156
  level: "error",
@@ -161,21 +166,10 @@ server.registerTool("generate_image", {
161
166
  }
162
167
  }
163
168
  })();
164
- return {
165
- content: [{
166
- type: "text",
167
- text: JSON.stringify({
168
- success: true,
169
- status: "submitted",
170
- task_id: task.id,
171
- effective_prompt: preparedPrompt.prompt,
172
- message: `已提交异步生成:${args.output_filename};如需诊断失败原因,可调用 check_image_task`,
173
- }),
174
- }],
175
- };
169
+ return toolText(taskSubmissionResponse(getTask(task.id) ?? task, args.output_filename));
176
170
  });
177
171
  server.registerTool("check_image_task", {
178
- description: "Check an async image generation task status and failure details.",
172
+ description: "Poll an asynchronous image task. Continue polling while poll_required=true. Only status=done with success=true means an image was generated and saved; status=failed is terminal and includes the provider error/details.",
179
173
  inputSchema: z.object({
180
174
  task_id: z.string().min(1).describe("Task id returned by generate_image"),
181
175
  }),
@@ -191,22 +185,8 @@ server.registerTool("check_image_task", {
191
185
  isError: true,
192
186
  };
193
187
  }
194
- const response = {
195
- success: task.status !== "failed",
196
- task_id: task.id,
197
- status: task.status,
198
- };
199
- if (task.status === "done") {
200
- response.saved_path = task.savedPath;
201
- }
202
- if (task.effectivePrompt) {
203
- response.effective_prompt = task.effectivePrompt;
204
- }
205
- if (task.status === "failed") {
206
- response.error = task.errorCode;
207
- response.message = task.error;
208
- }
209
- return toolText(response);
188
+ const response = toolText(taskStatusResponse(task));
189
+ return task.status === "failed" ? { ...response, isError: true } : response;
210
190
  });
211
191
  const transport = new StdioServerTransport();
212
192
  await server.connect(transport);
package/dist/prompt.js CHANGED
@@ -84,5 +84,10 @@ export function preparePrompt(opts) {
84
84
  prompt,
85
85
  imagePaths: inputImages?.map((image) => image.path) ?? [],
86
86
  imageInputs: inputImages,
87
+ stats: {
88
+ characters: Array.from(prompt).length,
89
+ utf8Bytes: Buffer.byteLength(prompt, "utf8"),
90
+ lines: prompt ? prompt.split("\n").length : 0,
91
+ },
87
92
  };
88
93
  }
package/dist/provider.js CHANGED
@@ -2,42 +2,102 @@ import { AppError } from "./errors.js";
2
2
  import { resizeIfNeeded } from "./resizer.js";
3
3
  export const ASPECT_RATIOS = ["1:1", "3:2", "2:3", "16:9", "9:16"];
4
4
  export const IMAGE_SIZES = ["1K", "2K", "4K"];
5
- async function fetchWithRetry(url, init, timeoutMs, retries = 0) {
5
+ function getRequestId(res) {
6
+ return res.headers.get("x-request-id")
7
+ ?? res.headers.get("request-id")
8
+ ?? res.headers.get("x-trace-id")
9
+ ?? res.headers.get("cf-ray");
10
+ }
11
+ function summarizeJson(value) {
12
+ if (Array.isArray(value)) {
13
+ return { kind: "json_array", length: value.length };
14
+ }
15
+ if (!value || typeof value !== "object") {
16
+ return { kind: "json_scalar", type: value === null ? "null" : typeof value };
17
+ }
18
+ const record = value;
19
+ const summary = {
20
+ kind: "json_object",
21
+ keys: Object.keys(record).slice(0, 20),
22
+ };
23
+ if (Array.isArray(record.data)) {
24
+ summary.data_length = record.data.length;
25
+ const first = record.data[0];
26
+ if (first && typeof first === "object" && !Array.isArray(first)) {
27
+ summary.data_0_keys = Object.keys(first).slice(0, 20);
28
+ }
29
+ }
30
+ if (record.error && typeof record.error === "object" && !Array.isArray(record.error)) {
31
+ const error = record.error;
32
+ summary.error_keys = Object.keys(error).slice(0, 20);
33
+ if (typeof error.code === "string" && /^[a-zA-Z0-9_.:-]{1,80}$/.test(error.code)) {
34
+ summary.error_code = error.code;
35
+ }
36
+ if (typeof error.type === "string" && /^[a-zA-Z0-9_.:-]{1,80}$/.test(error.type)) {
37
+ summary.error_type = error.type;
38
+ }
39
+ }
40
+ return summary;
41
+ }
42
+ function responseDetails(res, body) {
43
+ const summary = {
44
+ status: res.status,
45
+ content_type: res.headers.get("content-type"),
46
+ content_length: res.headers.get("content-length"),
47
+ ...(body ? { body } : {}),
48
+ };
49
+ return {
50
+ request_id: getRequestId(res),
51
+ response_summary: summary,
52
+ };
53
+ }
54
+ function isAbortError(err) {
55
+ return err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
56
+ }
57
+ async function delayForRetry(attempt) {
58
+ const delayMs = 1_000 * Math.pow(2, attempt) + Math.random() * 500;
59
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
60
+ }
61
+ async function fetchWithRetry(url, init, timeoutMs, consume, retries = 0) {
6
62
  let lastErr;
7
63
  for (let attempt = 0; attempt <= retries; attempt++) {
8
64
  const controller = new AbortController();
9
65
  const timer = setTimeout(() => controller.abort(), timeoutMs);
66
+ let receivedResponse;
10
67
  try {
11
68
  const res = await fetch(url, { ...init, signal: controller.signal });
12
- clearTimeout(timer);
69
+ receivedResponse = res;
13
70
  // 5xx: 上游随机故障(EOF等),立刻 abort body 连接并重试,不等 chunked 终止
14
71
  if (res.status >= 500 && attempt < retries) {
15
- try {
16
- controller.abort();
17
- }
18
- catch { }
19
- const delayMs = 1000 * Math.pow(2, attempt) + Math.random() * 500;
20
- await new Promise((r) => setTimeout(r, delayMs));
72
+ controller.abort();
73
+ clearTimeout(timer);
74
+ await delayForRetry(attempt);
21
75
  continue;
22
76
  }
23
- return res;
77
+ const result = await consume(res, controller.signal);
78
+ clearTimeout(timer);
79
+ return result;
24
80
  }
25
81
  catch (err) {
26
82
  clearTimeout(timer);
27
- const isAbort = err instanceof Error && err.name === "AbortError";
28
- // AbortError from our manual abort above is not a real timeout
29
- if (isAbort && attempt < retries) {
30
- lastErr = err;
31
- const delayMs = 1000 * Math.pow(2, attempt) + Math.random() * 500;
32
- await new Promise((r) => setTimeout(r, delayMs));
33
- continue;
83
+ if (err instanceof AppError) {
84
+ throw err;
85
+ }
86
+ if (isAbortError(err)) {
87
+ throw new AppError("E_PROVIDER_TIMEOUT", `Provider request timed out after ${timeoutMs}ms`, {
88
+ timeout_ms: timeoutMs,
89
+ phase: receivedResponse ? "response_body_or_image_download" : "connection_or_headers",
90
+ ...(receivedResponse ? responseDetails(receivedResponse) : {}),
91
+ });
34
92
  }
35
- if (isAbort) {
36
- throw new AppError("E_PROVIDER_TIMEOUT", "Request timed out");
93
+ // Once response headers have arrived, the generation may already be billable.
94
+ // Do not replay the POST for body/CDN failures and risk duplicate generation.
95
+ if (receivedResponse) {
96
+ throw new AppError("E_PROVIDER_BAD_REQUEST", "Provider response could not be read completely", responseDetails(receivedResponse));
37
97
  }
38
98
  lastErr = err;
39
99
  if (attempt < retries) {
40
- await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
100
+ await delayForRetry(attempt);
41
101
  }
42
102
  }
43
103
  }
@@ -72,34 +132,72 @@ function mapOpenAISize(size, aspectRatio) {
72
132
  const ratio = aspectRatio ?? "1:1";
73
133
  return SIZE_TEMPLATES[imageSize][ratio];
74
134
  }
75
- async function imageFromOpenAIResponse(res) {
76
- const data = (await res.json());
135
+ async function imageFromOpenAIResponse(res, signal) {
136
+ let data;
137
+ try {
138
+ data = (await res.json());
139
+ }
140
+ catch (err) {
141
+ if (isAbortError(err))
142
+ throw err;
143
+ if (!(err instanceof SyntaxError)) {
144
+ throw new AppError("E_PROVIDER_BAD_REQUEST", "Provider response body could not be read", responseDetails(res));
145
+ }
146
+ throw new AppError("E_PROVIDER_EMPTY_RESPONSE", "Provider returned HTTP 200 but the response body was not valid image JSON", responseDetails(res, { kind: "invalid_json" }));
147
+ }
77
148
  const image = data.data?.[0];
78
149
  if (image?.b64_json) {
79
- return Buffer.from(image.b64_json, "base64");
150
+ const imageBuffer = Buffer.from(image.b64_json, "base64");
151
+ if (imageBuffer.length > 0) {
152
+ return imageBuffer;
153
+ }
80
154
  }
81
155
  if (image?.url) {
82
- const imgRes = await fetch(image.url);
83
- if (!imgRes.ok) {
84
- throw new AppError("E_PROVIDER_BAD_REQUEST", `Image URL fetch failed: ${imgRes.status}`);
156
+ try {
157
+ const imgRes = await fetch(image.url, { signal });
158
+ if (!imgRes.ok) {
159
+ const summary = await readBodySummary(imgRes);
160
+ throw new AppError("E_PROVIDER_BAD_REQUEST", `Image URL fetch failed (HTTP ${imgRes.status})`, responseDetails(imgRes, summary));
161
+ }
162
+ const imageBuffer = Buffer.from(await imgRes.arrayBuffer());
163
+ if (imageBuffer.length > 0) {
164
+ return imageBuffer;
165
+ }
166
+ throw new AppError("E_PROVIDER_EMPTY_RESPONSE", "Provider image URL returned an empty body", responseDetails(imgRes, { kind: "empty", length: 0 }));
167
+ }
168
+ catch (err) {
169
+ if (isAbortError(err) || err instanceof AppError)
170
+ throw err;
171
+ throw new AppError("E_PROVIDER_BAD_REQUEST", "Image URL download failed", responseDetails(res));
85
172
  }
86
- return Buffer.from(await imgRes.arrayBuffer());
87
173
  }
88
- throw new AppError("E_PROVIDER_BAD_REQUEST", "No image in response");
174
+ throw new AppError("E_PROVIDER_EMPTY_RESPONSE", "Provider returned HTTP 200 but data contained no image", responseDetails(res, summarizeJson(data)));
175
+ }
176
+ async function readBodySummary(res) {
177
+ const text = await res.text();
178
+ if (!text)
179
+ return { kind: "empty", length: 0 };
180
+ try {
181
+ return summarizeJson(JSON.parse(text));
182
+ }
183
+ catch {
184
+ return { kind: "text", length: text.length };
185
+ }
89
186
  }
90
187
  async function handleProviderError(res) {
188
+ // 5xx: 上游随机故障,不读 body(chunked 连接可能挂起),直接返回安全的响应头摘要。
189
+ if (res.status >= 500) {
190
+ throw new AppError("E_PROVIDER_BAD_REQUEST", `Upstream error (HTTP ${res.status})`, responseDetails(res));
191
+ }
192
+ const summary = await readBodySummary(res);
193
+ const details = responseDetails(res, summary);
91
194
  if (res.status === 401 || res.status === 403) {
92
- throw new AppError("E_PROVIDER_AUTH", `Auth failed (HTTP ${res.status})`);
195
+ throw new AppError("E_PROVIDER_AUTH", `Auth failed (HTTP ${res.status})`, details);
93
196
  }
94
197
  if (res.status === 429) {
95
- throw new AppError("E_PROVIDER_RATE_LIMIT", "Rate limit exceeded");
198
+ throw new AppError("E_PROVIDER_RATE_LIMIT", "Rate limit exceeded", details);
96
199
  }
97
- // 5xx: 上游随机故障,不读 body(chunked 连接挂 120s),直接秒回
98
- if (res.status >= 500) {
99
- throw new AppError("E_PROVIDER_BAD_REQUEST", `Upstream error ${res.status} (body skipped to avoid chunked hang)`);
100
- }
101
- const text = await res.text().catch(() => "");
102
- throw new AppError("E_PROVIDER_BAD_REQUEST", `Upstream error ${res.status}: ${text.slice(0, 200)}`);
200
+ throw new AppError("E_PROVIDER_BAD_REQUEST", `Upstream error (HTTP ${res.status})`, details);
103
201
  }
104
202
  async function generateOpenAIImage(config, opts) {
105
203
  const prompt = opts.prompt;
@@ -141,15 +239,16 @@ async function generateOpenAIImage(config, opts) {
141
239
  }
142
240
  // 启用 2 次重试,应对上游偶发的 EOF / 524(Cloudflare 100s 超时)。
143
241
  // retries=0 时所有偶发失败一次过即死,指数退避代码是死代码。
144
- const res = await fetchWithRetry(url, {
242
+ return fetchWithRetry(url, {
145
243
  method: "POST",
146
244
  headers,
147
245
  body,
148
- }, config.timeoutMs, 2);
149
- if (!res.ok) {
150
- await handleProviderError(res);
151
- }
152
- return imageFromOpenAIResponse(res);
246
+ }, config.timeoutMs, async (res, signal) => {
247
+ if (!res.ok) {
248
+ await handleProviderError(res);
249
+ }
250
+ return imageFromOpenAIResponse(res, signal);
251
+ }, 2);
153
252
  }
154
253
  export async function generateImage(config, opts) {
155
254
  return generateOpenAIImage(config, opts);
@@ -0,0 +1,45 @@
1
+ const POLL_RETRY_AFTER_MS = 1_000;
2
+ export function taskSubmissionResponse(task, outputFilename) {
3
+ return {
4
+ accepted: true,
5
+ terminal: false,
6
+ status: "submitted",
7
+ task_id: task.id,
8
+ poll_required: true,
9
+ retry_after_ms: POLL_RETRY_AFTER_MS,
10
+ effective_prompt: task.effectivePrompt,
11
+ prompt_stats: task.promptStats,
12
+ message: `已接受异步生成任务:${outputFilename}。这不代表图片已生成;必须调用 check_image_task,直到 status 为 done 或 failed。`,
13
+ };
14
+ }
15
+ export function taskStatusResponse(task) {
16
+ const response = {
17
+ task_id: task.id,
18
+ status: task.status,
19
+ terminal: task.status === "done" || task.status === "failed",
20
+ };
21
+ if (task.effectivePrompt) {
22
+ response.effective_prompt = task.effectivePrompt;
23
+ }
24
+ if (task.promptStats) {
25
+ response.prompt_stats = task.promptStats;
26
+ }
27
+ if (task.status === "done") {
28
+ response.success = true;
29
+ response.saved_path = task.savedPath;
30
+ }
31
+ else if (task.status === "failed") {
32
+ response.success = false;
33
+ response.error = task.errorCode;
34
+ response.message = task.error;
35
+ if (task.errorDetails) {
36
+ response.details = task.errorDetails;
37
+ }
38
+ }
39
+ else {
40
+ response.accepted = true;
41
+ response.poll_required = true;
42
+ response.retry_after_ms = POLL_RETRY_AFTER_MS;
43
+ }
44
+ return response;
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-duojie-image",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "MCP server for image generation via duojie.games",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",