mcp-duojie-image 0.6.0 → 0.8.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 +5 -1
- package/dist/index.js +4 -1
- package/dist/prompt.js +1 -0
- package/dist/provider.js +44 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,9 @@ MCP server for image generation via duojie.games GPT image API.
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- Text-to-image generation with `gpt-image-2`
|
|
8
|
-
- Reference image
|
|
8
|
+
- Reference image support with role-based endpoint routing:
|
|
9
|
+
- `reference` / `style_reference` / `composition_reference` / `insert_source` → `/v1/images/generations` (JSON body + base64 data URIs; stable endpoint)
|
|
10
|
+
- `edit_target` → `/v1/images/edits` (multipart form; for modifying an existing image)
|
|
9
11
|
- Prompt structuring helpers for use case, asset type, constraints, and effective prompt echo
|
|
10
12
|
- Optional chroma-key transparent background post-processing
|
|
11
13
|
- Async submission with task status lookup
|
|
@@ -61,6 +63,8 @@ Supported `input_images.role` values:
|
|
|
61
63
|
- `composition_reference`
|
|
62
64
|
- `insert_source`
|
|
63
65
|
|
|
66
|
+
**Endpoint routing:** Images with the `edit_target` role are sent to `/v1/images/edits` (multipart form). All other roles (`reference`, `style_reference`, `composition_reference`, `insert_source`) are sent to `/v1/images/generations` as base64 data URIs in a JSON body. This mirrors Codex's imagegen skill, which treats style/composition/mood references as "generate" rather than "edit". If a request mixes `edit_target` with other roles, the edits endpoint is used.
|
|
67
|
+
|
|
64
68
|
When both `input_images` and `reference_images` are provided, `input_images` takes precedence. `generate_image` returns `effective_prompt` so callers can diagnose the exact prompt sent upstream.
|
|
65
69
|
|
|
66
70
|
Response:
|
package/dist/index.js
CHANGED
|
@@ -34,7 +34,7 @@ const server = new McpServer({
|
|
|
34
34
|
version: "0.1.0",
|
|
35
35
|
});
|
|
36
36
|
server.registerTool("generate_image", {
|
|
37
|
-
description: "Generate image async with gpt-image-2. Return after tool call; don't wait/check.",
|
|
37
|
+
description: "Generate image async with gpt-image-2. Reference/style/composition images route through /v1/images/generations (stable); edit_target role routes through /v1/images/edits. Return after tool call; don't wait/check.",
|
|
38
38
|
inputSchema: z.object({
|
|
39
39
|
prompt: z.string().min(1).describe("Prompt"),
|
|
40
40
|
output_dir: z.string().describe("Absolute output dir"),
|
|
@@ -105,6 +105,8 @@ server.registerTool("generate_image", {
|
|
|
105
105
|
}
|
|
106
106
|
const task = createTask();
|
|
107
107
|
updateTask(task.id, { effectivePrompt: preparedPrompt.prompt });
|
|
108
|
+
// 只有 edit_target 角色才走 edits 端点;reference/style/composition 走 generations
|
|
109
|
+
const hasEditTarget = preparedPrompt.imageInputs?.some((img) => img.role === "edit_target") ?? false;
|
|
108
110
|
// Start background generation (don't await)
|
|
109
111
|
(async () => {
|
|
110
112
|
try {
|
|
@@ -112,6 +114,7 @@ server.registerTool("generate_image", {
|
|
|
112
114
|
let imageBuffer = await generateImage(config, {
|
|
113
115
|
prompt: preparedPrompt.prompt,
|
|
114
116
|
referenceImages: preparedPrompt.imagePaths,
|
|
117
|
+
useEditsEndpoint: hasEditTarget,
|
|
115
118
|
size: args.size ?? "1K",
|
|
116
119
|
aspectRatio: args.aspect_ratio,
|
|
117
120
|
referenceMaxSide: args.preserve_reference_detail ? false : args.reference_max_side,
|
package/dist/prompt.js
CHANGED
package/dist/provider.js
CHANGED
|
@@ -10,11 +10,28 @@ async function fetchWithRetry(url, init, timeoutMs, retries = 0) {
|
|
|
10
10
|
try {
|
|
11
11
|
const res = await fetch(url, { ...init, signal: controller.signal });
|
|
12
12
|
clearTimeout(timer);
|
|
13
|
+
// 5xx: 上游随机故障(EOF等),立刻 abort body 连接并重试,不等 chunked 终止
|
|
14
|
+
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));
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
13
23
|
return res;
|
|
14
24
|
}
|
|
15
25
|
catch (err) {
|
|
16
26
|
clearTimeout(timer);
|
|
17
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;
|
|
34
|
+
}
|
|
18
35
|
if (isAbort) {
|
|
19
36
|
throw new AppError("E_PROVIDER_TIMEOUT", "Request timed out");
|
|
20
37
|
}
|
|
@@ -77,6 +94,10 @@ async function handleProviderError(res) {
|
|
|
77
94
|
if (res.status === 429) {
|
|
78
95
|
throw new AppError("E_PROVIDER_RATE_LIMIT", "Rate limit exceeded");
|
|
79
96
|
}
|
|
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
|
+
}
|
|
80
101
|
const text = await res.text().catch(() => "");
|
|
81
102
|
throw new AppError("E_PROVIDER_BAD_REQUEST", `Upstream error ${res.status}: ${text.slice(0, 200)}`);
|
|
82
103
|
}
|
|
@@ -84,12 +105,16 @@ async function generateOpenAIImage(config, opts) {
|
|
|
84
105
|
const prompt = opts.prompt;
|
|
85
106
|
const size = mapOpenAISize(opts.size, opts.aspectRatio);
|
|
86
107
|
const hasReferenceImages = Boolean(opts.referenceImages?.length);
|
|
87
|
-
|
|
108
|
+
// edit_target → /v1/images/edits (multipart form)
|
|
109
|
+
// reference/style/composition → /v1/images/generations (JSON body + base64 data URIs)
|
|
110
|
+
const useEdits = hasReferenceImages && opts.useEditsEndpoint === true;
|
|
111
|
+
const url = `${config.baseUrl}${useEdits ? "/v1/images/edits" : "/v1/images/generations"}`;
|
|
88
112
|
let body;
|
|
89
113
|
let headers = {
|
|
90
114
|
Authorization: `Bearer ${config.apiKey}`,
|
|
91
115
|
};
|
|
92
|
-
if (
|
|
116
|
+
if (useEdits) {
|
|
117
|
+
// edit_target: multipart form(修改已有图片)
|
|
93
118
|
const form = new FormData();
|
|
94
119
|
form.set("model", GPT_IMAGE_MODEL);
|
|
95
120
|
form.set("prompt", prompt);
|
|
@@ -104,7 +129,24 @@ async function generateOpenAIImage(config, opts) {
|
|
|
104
129
|
}
|
|
105
130
|
body = form;
|
|
106
131
|
}
|
|
132
|
+
else if (hasReferenceImages) {
|
|
133
|
+
// reference/style/composition: generations + base64 data URIs in JSON body
|
|
134
|
+
const imageUrls = [];
|
|
135
|
+
for (const imgPath of opts.referenceImages) {
|
|
136
|
+
const data = await resizeIfNeeded(imgPath, opts.referenceMaxSide);
|
|
137
|
+
imageUrls.push(`data:image/webp;base64,${data.toString("base64")}`);
|
|
138
|
+
}
|
|
139
|
+
headers = { ...headers, "Content-Type": "application/json" };
|
|
140
|
+
body = JSON.stringify({
|
|
141
|
+
model: GPT_IMAGE_MODEL,
|
|
142
|
+
prompt,
|
|
143
|
+
size,
|
|
144
|
+
image: imageUrls,
|
|
145
|
+
...(opts.aspectRatio ? { aspect_ratio: opts.aspectRatio } : {}),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
107
148
|
else {
|
|
149
|
+
// 纯文本生成
|
|
108
150
|
headers = { ...headers, "Content-Type": "application/json" };
|
|
109
151
|
body = JSON.stringify({
|
|
110
152
|
model: GPT_IMAGE_MODEL,
|