mcp-duojie-image 0.1.0 → 0.1.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 +62 -0
- package/dist/config.js +5 -10
- package/dist/saver.js +5 -2
- package/package.json +16 -7
- package/.env.example +0 -6
- package/AGENTS.md +0 -66
- package/src/config.ts +0 -32
- package/src/errors.ts +0 -19
- package/src/index.ts +0 -79
- package/src/provider.ts +0 -123
- package/src/saver.ts +0 -38
- package/tests/provider.test.ts +0 -60
- package/tests/saver.test.ts +0 -48
- package/tsconfig.json +0 -14
- package//346/226/207/346/241/243/345/214/272/MCP-/351/246/231/350/225/211/346/250/241/345/236/213/347/224/237/345/233/276-/351/234/200/346/261/202/346/226/207/346/241/243.md +0 -190
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# mcp-duojie-image
|
|
2
|
+
|
|
3
|
+
MCP server for image generation via duojie.games API (Gemini 3.1 Flash Image).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
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
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx mcp-duojie-image
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Configuration
|
|
19
|
+
|
|
20
|
+
Set environment variable:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
export DUOJIE_API_KEY=sk-your-key-here
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or configure in MCP client (e.g., Claude Desktop):
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"mcpServers": {
|
|
31
|
+
"duojie-image": {
|
|
32
|
+
"command": "npx",
|
|
33
|
+
"args": ["-y", "mcp-duojie-image"],
|
|
34
|
+
"env": {
|
|
35
|
+
"DUOJIE_API_KEY": "sk-your-key-here"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
The server provides one tool: `generate_image`
|
|
45
|
+
|
|
46
|
+
**Required parameters:**
|
|
47
|
+
- `prompt`: Image description
|
|
48
|
+
- `output_dir`: Output directory path
|
|
49
|
+
- `output_filename`: Output filename (extension optional)
|
|
50
|
+
|
|
51
|
+
**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
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
package/dist/config.js
CHANGED
|
@@ -4,20 +4,15 @@ export function maskKey(key) {
|
|
|
4
4
|
return "***";
|
|
5
5
|
return `${key.slice(0, 6)}...${key.slice(-4)}`;
|
|
6
6
|
}
|
|
7
|
-
function
|
|
8
|
-
const
|
|
9
|
-
const arg = process.argv.find((a) => a.startsWith(flag));
|
|
10
|
-
return arg ? arg.slice(flag.length) : undefined;
|
|
11
|
-
}
|
|
12
|
-
export function loadConfig() {
|
|
13
|
-
const apiKey = getArg("api-key") ?? process.env.DUOJIE_API_KEY ?? "";
|
|
7
|
+
export function loadConfig(overrides) {
|
|
8
|
+
const apiKey = overrides?.apiKey ?? process.env.DUOJIE_API_KEY ?? "";
|
|
14
9
|
if (!apiKey) {
|
|
15
|
-
throw new AppError("E_CONFIG_MISSING_KEY", "
|
|
10
|
+
throw new AppError("E_CONFIG_MISSING_KEY", "DUOJIE_API_KEY is not set");
|
|
16
11
|
}
|
|
17
12
|
return {
|
|
18
13
|
apiKey,
|
|
19
|
-
baseUrl:
|
|
20
|
-
model:
|
|
14
|
+
baseUrl: overrides?.baseUrl ?? process.env.DUOJIE_BASE_URL ?? "https://api.duojie.games",
|
|
15
|
+
model: overrides?.model ?? process.env.DUOJIE_IMAGE_MODEL ?? "gemini-3.1-flash-image",
|
|
21
16
|
timeoutMs: 60_000,
|
|
22
17
|
};
|
|
23
18
|
}
|
package/dist/saver.js
CHANGED
|
@@ -14,8 +14,11 @@ export function saveImage(imageBuffer, opts) {
|
|
|
14
14
|
const base = extname(outputFilename) ? basename(outputFilename, ext) : outputFilename;
|
|
15
15
|
let finalPath = join(absDir, `${base}${ext}`);
|
|
16
16
|
if (!overwrite && existsSync(finalPath)) {
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
let counter = 1;
|
|
18
|
+
do {
|
|
19
|
+
finalPath = join(absDir, `${base}_${counter}${ext}`);
|
|
20
|
+
counter++;
|
|
21
|
+
} while (existsSync(finalPath));
|
|
19
22
|
}
|
|
20
23
|
try {
|
|
21
24
|
writeFileSync(finalPath, imageBuffer);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-duojie-image",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "MCP server for image generation via duojie.games",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -12,14 +12,17 @@
|
|
|
12
12
|
"build": "tsc",
|
|
13
13
|
"start": "node dist/index.js",
|
|
14
14
|
"test": "vitest run",
|
|
15
|
-
"lint": "tsc --noEmit"
|
|
15
|
+
"lint": "tsc --noEmit",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
16
17
|
},
|
|
17
|
-
"keywords": [
|
|
18
|
+
"keywords": [
|
|
19
|
+
"mcp",
|
|
20
|
+
"image-generation",
|
|
21
|
+
"gemini",
|
|
22
|
+
"ai"
|
|
23
|
+
],
|
|
18
24
|
"author": "",
|
|
19
25
|
"license": "MIT",
|
|
20
|
-
"engines": {
|
|
21
|
-
"node": ">=18.0.0"
|
|
22
|
-
},
|
|
23
26
|
"dependencies": {
|
|
24
27
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
25
28
|
"zod": "^3.23.8"
|
|
@@ -29,5 +32,11 @@
|
|
|
29
32
|
"tsx": "^4.19.0",
|
|
30
33
|
"typescript": "^5.5.0",
|
|
31
34
|
"vitest": "^2.0.0"
|
|
32
|
-
}
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18.0.0"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist"
|
|
41
|
+
]
|
|
33
42
|
}
|
package/.env.example
DELETED
package/AGENTS.md
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
# Repository Guidelines
|
|
2
|
-
|
|
3
|
-
## 基础规则
|
|
4
|
-
- 说中文
|
|
5
|
-
|
|
6
|
-
## Project Structure & Module Organization
|
|
7
|
-
This repository is currently requirements-first. Keep that pattern clear:
|
|
8
|
-
|
|
9
|
-
- Top-level requirement Markdown in the documentation folder: source-of-truth for MVP scope, tool schema expectations, error codes, and acceptance criteria.
|
|
10
|
-
- `.codex/config.toml`: local Codex/MCP runtime config (tooling only, not product logic).
|
|
11
|
-
|
|
12
|
-
When adding implementation, use a predictable layout:
|
|
13
|
-
|
|
14
|
-
- `src/` for MCP service/tool code.
|
|
15
|
-
- `tests/` for automated tests.
|
|
16
|
-
- `examples/` for runnable request samples.
|
|
17
|
-
|
|
18
|
-
## Build, Test, and Development Commands
|
|
19
|
-
No build pipeline is committed yet. Until code is added, use:
|
|
20
|
-
|
|
21
|
-
- `rg --files -uu` to inspect the full tree quickly.
|
|
22
|
-
- `Get-ChildItem -Recurse -File *.md` to find and review the current requirement/spec document.
|
|
23
|
-
|
|
24
|
-
For new runtime modules, add and document standard scripts:
|
|
25
|
-
|
|
26
|
-
- `npm run dev` (local run),
|
|
27
|
-
- `npm run build` (production build),
|
|
28
|
-
- `npm test` (unit/integration),
|
|
29
|
-
- `npm run lint` (style checks).
|
|
30
|
-
|
|
31
|
-
## Coding Style & Naming Conventions
|
|
32
|
-
- Use UTF-8 and keep Markdown/docs readable in both Chinese and English where needed.
|
|
33
|
-
- Keep MCP tool names in `snake_case` (example: `generate_image`).
|
|
34
|
-
- Keep error codes in `UPPER_SNAKE_CASE` with `E_` prefix (example: `E_PROVIDER_TIMEOUT`).
|
|
35
|
-
- Environment variables must be uppercase (example: `DUOJIE_API_KEY`).
|
|
36
|
-
- Prefer small, composable modules; avoid mixing HTTP adapter, validation, and file-save logic in one file.
|
|
37
|
-
|
|
38
|
-
## Testing Guidelines
|
|
39
|
-
A testing framework is not defined yet; add one in the same PR as runtime code. Minimum coverage should map to Section 9 acceptance criteria in the requirement doc:
|
|
40
|
-
|
|
41
|
-
- text-to-image success path,
|
|
42
|
-
- optional `reference_images` path,
|
|
43
|
-
- output directory/file conflict handling (`overwrite` true/false),
|
|
44
|
-
- missing/invalid key behavior,
|
|
45
|
-
- upstream timeout/auth error mapping.
|
|
46
|
-
|
|
47
|
-
Use `*.test.*` naming and keep provider calls mocked for deterministic tests.
|
|
48
|
-
|
|
49
|
-
## Commit & Pull Request Guidelines
|
|
50
|
-
Git history is not available in this workspace snapshot, so use Conventional Commits by default:
|
|
51
|
-
|
|
52
|
-
- `feat(mcp): add generate_image schema`
|
|
53
|
-
- `fix(save): handle overwrite=false filename collision`
|
|
54
|
-
|
|
55
|
-
PRs should include:
|
|
56
|
-
|
|
57
|
-
- clear summary and scope,
|
|
58
|
-
- linked requirement sections from the current requirement/spec Markdown,
|
|
59
|
-
- test evidence (command + result),
|
|
60
|
-
- sample input/output (including saved file path),
|
|
61
|
-
- confirmation that no API keys or Authorization values are exposed.
|
|
62
|
-
|
|
63
|
-
## Security & Configuration Tips
|
|
64
|
-
- Never hardcode or commit real API keys.
|
|
65
|
-
- Mask secrets in logs (show only partial key, e.g., first 6 + last 4).
|
|
66
|
-
- Keep `DUOJIE_BASE_URL` and model value configurable; do not bury them in code constants without override support.
|
package/src/config.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { AppError } from "./errors.js";
|
|
2
|
-
|
|
3
|
-
export interface Config {
|
|
4
|
-
apiKey: string;
|
|
5
|
-
baseUrl: string;
|
|
6
|
-
model: string;
|
|
7
|
-
timeoutMs: number;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function maskKey(key: string): string {
|
|
11
|
-
if (key.length <= 10) return "***";
|
|
12
|
-
return `${key.slice(0, 6)}...${key.slice(-4)}`;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function getArg(name: string): string | undefined {
|
|
16
|
-
const flag = `--${name}=`;
|
|
17
|
-
const arg = process.argv.find((a) => a.startsWith(flag));
|
|
18
|
-
return arg ? arg.slice(flag.length) : undefined;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function loadConfig(): Config {
|
|
22
|
-
const apiKey = getArg("api-key") ?? process.env.DUOJIE_API_KEY ?? "";
|
|
23
|
-
if (!apiKey) {
|
|
24
|
-
throw new AppError("E_CONFIG_MISSING_KEY", "--api-key is required");
|
|
25
|
-
}
|
|
26
|
-
return {
|
|
27
|
-
apiKey,
|
|
28
|
-
baseUrl: getArg("base-url") ?? process.env.DUOJIE_BASE_URL ?? "https://api.duojie.games",
|
|
29
|
-
model: getArg("model") ?? process.env.DUOJIE_IMAGE_MODEL ?? "gemini-3.1-flash-image",
|
|
30
|
-
timeoutMs: 60_000,
|
|
31
|
-
};
|
|
32
|
-
}
|
package/src/errors.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
export type ErrorCode =
|
|
2
|
-
| "E_CONFIG_MISSING_KEY"
|
|
3
|
-
| "E_INPUT_INVALID"
|
|
4
|
-
| "E_REFERENCE_NOT_FOUND"
|
|
5
|
-
| "E_PROVIDER_AUTH"
|
|
6
|
-
| "E_PROVIDER_RATE_LIMIT"
|
|
7
|
-
| "E_PROVIDER_BAD_REQUEST"
|
|
8
|
-
| "E_PROVIDER_TIMEOUT"
|
|
9
|
-
| "E_SAVE_FAILED";
|
|
10
|
-
|
|
11
|
-
export class AppError extends Error {
|
|
12
|
-
constructor(
|
|
13
|
-
public readonly code: ErrorCode,
|
|
14
|
-
message: string,
|
|
15
|
-
) {
|
|
16
|
-
super(message);
|
|
17
|
-
this.name = "AppError";
|
|
18
|
-
}
|
|
19
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
-
import { z } from "zod";
|
|
5
|
-
import { loadConfig } from "./config.js";
|
|
6
|
-
import { generateImage } from "./provider.js";
|
|
7
|
-
import { saveImage } from "./saver.js";
|
|
8
|
-
import { AppError } from "./errors.js";
|
|
9
|
-
|
|
10
|
-
const server = new McpServer({
|
|
11
|
-
name: "duojie-image",
|
|
12
|
-
version: "0.1.0",
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
server.registerTool(
|
|
16
|
-
"generate_image",
|
|
17
|
-
{
|
|
18
|
-
description: "Generate an image and save to disk.",
|
|
19
|
-
inputSchema: z.object({
|
|
20
|
-
prompt: z.string().min(1).describe("Image prompt"),
|
|
21
|
-
output_dir: z.string().describe("Output directory (absolute path)"),
|
|
22
|
-
output_filename: z.string().describe("Output filename (no extension needed)"),
|
|
23
|
-
reference_images: z.array(z.string()).optional().describe("Local image paths for reference"),
|
|
24
|
-
size: z.enum(["512px", "1K", "2K", "4K"]).optional().describe("Image size"),
|
|
25
|
-
aspect_ratio: z.string().optional().describe("e.g. 16:9"),
|
|
26
|
-
format: z.string().optional().describe("Output format, default png"),
|
|
27
|
-
model: z.string().optional().describe("Model override"),
|
|
28
|
-
overwrite: z.boolean().optional().describe("Overwrite existing file, default false"),
|
|
29
|
-
negative_prompt: z.string().optional().describe("What to avoid"),
|
|
30
|
-
}),
|
|
31
|
-
},
|
|
32
|
-
async (args) => {
|
|
33
|
-
let config;
|
|
34
|
-
try {
|
|
35
|
-
config = loadConfig();
|
|
36
|
-
} catch (err: unknown) {
|
|
37
|
-
const code = err instanceof AppError ? err.code : "E_CONFIG_MISSING_KEY";
|
|
38
|
-
return { content: [{ type: "text", text: JSON.stringify({ success: false, error: code, message: String(err instanceof Error ? err.message : err) }) }], isError: true };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Enrich prompt with size/aspect hints
|
|
42
|
-
let fullPrompt = args.prompt;
|
|
43
|
-
if (args.size) fullPrompt += ` [size: ${args.size}]`;
|
|
44
|
-
if (args.aspect_ratio) fullPrompt += ` [aspect_ratio: ${args.aspect_ratio}]`;
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
const imageBuffer = await generateImage(config, {
|
|
48
|
-
prompt: fullPrompt,
|
|
49
|
-
referenceImages: args.reference_images,
|
|
50
|
-
model: args.model,
|
|
51
|
-
negativePrompt: args.negative_prompt,
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
const savedPath = saveImage(imageBuffer, {
|
|
55
|
-
outputDir: args.output_dir,
|
|
56
|
-
outputFilename: args.output_filename,
|
|
57
|
-
format: args.format ?? "png",
|
|
58
|
-
overwrite: args.overwrite ?? false,
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
return {
|
|
62
|
-
content: [{
|
|
63
|
-
type: "text",
|
|
64
|
-
text: JSON.stringify({ success: true, saved_path: savedPath, model: args.model ?? config.model }),
|
|
65
|
-
}],
|
|
66
|
-
};
|
|
67
|
-
} catch (err) {
|
|
68
|
-
const code = err instanceof AppError ? err.code : "E_PROVIDER_BAD_REQUEST";
|
|
69
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
70
|
-
return {
|
|
71
|
-
content: [{ type: "text", text: JSON.stringify({ success: false, error: code, message: msg }) }],
|
|
72
|
-
isError: true,
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
},
|
|
76
|
-
);
|
|
77
|
-
|
|
78
|
-
const transport = new StdioServerTransport();
|
|
79
|
-
await server.connect(transport);
|
package/src/provider.ts
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
|
-
import { AppError } from "./errors.js";
|
|
3
|
-
import type { Config } from "./config.js";
|
|
4
|
-
|
|
5
|
-
export interface GenerateOptions {
|
|
6
|
-
prompt: string;
|
|
7
|
-
referenceImages?: string[];
|
|
8
|
-
model?: string;
|
|
9
|
-
negativePrompt?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface ApiResponse {
|
|
13
|
-
choices: Array<{
|
|
14
|
-
message: {
|
|
15
|
-
images?: Array<{
|
|
16
|
-
image_url: { url: string };
|
|
17
|
-
}>;
|
|
18
|
-
};
|
|
19
|
-
}>;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async function fetchWithRetry(
|
|
23
|
-
url: string,
|
|
24
|
-
init: RequestInit,
|
|
25
|
-
timeoutMs: number,
|
|
26
|
-
retries = 2,
|
|
27
|
-
): Promise<Response> {
|
|
28
|
-
let lastErr: unknown;
|
|
29
|
-
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
30
|
-
const controller = new AbortController();
|
|
31
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
32
|
-
try {
|
|
33
|
-
const res = await fetch(url, { ...init, signal: controller.signal });
|
|
34
|
-
clearTimeout(timer);
|
|
35
|
-
return res;
|
|
36
|
-
} catch (err: unknown) {
|
|
37
|
-
clearTimeout(timer);
|
|
38
|
-
const isAbort = err instanceof Error && err.name === "AbortError";
|
|
39
|
-
if (isAbort) {
|
|
40
|
-
throw new AppError("E_PROVIDER_TIMEOUT", "Request timed out");
|
|
41
|
-
}
|
|
42
|
-
lastErr = err;
|
|
43
|
-
if (attempt < retries) {
|
|
44
|
-
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
throw new AppError("E_PROVIDER_BAD_REQUEST", `Network error: ${lastErr}`);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function buildMessages(opts: GenerateOptions): unknown[] {
|
|
52
|
-
const { prompt, referenceImages, negativePrompt } = opts;
|
|
53
|
-
const fullPrompt = negativePrompt ? `${prompt}\n\nAvoid: ${negativePrompt}` : prompt;
|
|
54
|
-
|
|
55
|
-
if (!referenceImages || referenceImages.length === 0) {
|
|
56
|
-
return [{ role: "user", content: fullPrompt }];
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const parts: unknown[] = referenceImages.map((imgPath) => {
|
|
60
|
-
let data: Buffer;
|
|
61
|
-
try {
|
|
62
|
-
data = readFileSync(imgPath);
|
|
63
|
-
} catch {
|
|
64
|
-
throw new AppError("E_REFERENCE_NOT_FOUND", `Cannot read reference image: ${imgPath}`);
|
|
65
|
-
}
|
|
66
|
-
const b64 = data.toString("base64");
|
|
67
|
-
const mime = imgPath.toLowerCase().endsWith(".jpg") || imgPath.toLowerCase().endsWith(".jpeg")
|
|
68
|
-
? "image/jpeg"
|
|
69
|
-
: "image/png";
|
|
70
|
-
return { type: "image_url", image_url: { url: `data:${mime};base64,${b64}` } };
|
|
71
|
-
});
|
|
72
|
-
parts.push({ type: "text", text: fullPrompt });
|
|
73
|
-
|
|
74
|
-
return [{ role: "user", content: parts }];
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export async function generateImage(
|
|
78
|
-
config: Config,
|
|
79
|
-
opts: GenerateOptions,
|
|
80
|
-
): Promise<Buffer> {
|
|
81
|
-
const messages = buildMessages(opts);
|
|
82
|
-
const model = opts.model ?? config.model;
|
|
83
|
-
|
|
84
|
-
const body = JSON.stringify({ model, messages });
|
|
85
|
-
const url = `${config.baseUrl}/v1/chat/completions`;
|
|
86
|
-
|
|
87
|
-
const res = await fetchWithRetry(
|
|
88
|
-
url,
|
|
89
|
-
{
|
|
90
|
-
method: "POST",
|
|
91
|
-
headers: {
|
|
92
|
-
"Authorization": `Bearer ${config.apiKey}`,
|
|
93
|
-
"Content-Type": "application/json",
|
|
94
|
-
},
|
|
95
|
-
body,
|
|
96
|
-
},
|
|
97
|
-
config.timeoutMs,
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
if (res.status === 401 || res.status === 403) {
|
|
101
|
-
throw new AppError("E_PROVIDER_AUTH", `Auth failed (HTTP ${res.status})`);
|
|
102
|
-
}
|
|
103
|
-
if (res.status === 429) {
|
|
104
|
-
throw new AppError("E_PROVIDER_RATE_LIMIT", "Rate limit exceeded");
|
|
105
|
-
}
|
|
106
|
-
if (!res.ok) {
|
|
107
|
-
const text = await res.text().catch(() => "");
|
|
108
|
-
throw new AppError("E_PROVIDER_BAD_REQUEST", `Upstream error ${res.status}: ${text.slice(0, 200)}`);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const data = (await res.json()) as ApiResponse;
|
|
112
|
-
const imageUrl = data.choices?.[0]?.message?.images?.[0]?.image_url?.url;
|
|
113
|
-
if (!imageUrl) {
|
|
114
|
-
throw new AppError("E_PROVIDER_BAD_REQUEST", "No image in response");
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// data:image/png;base64,<data>
|
|
118
|
-
const comma = imageUrl.indexOf(",");
|
|
119
|
-
if (comma === -1) {
|
|
120
|
-
throw new AppError("E_PROVIDER_BAD_REQUEST", "Unexpected image_url format");
|
|
121
|
-
}
|
|
122
|
-
return Buffer.from(imageUrl.slice(comma + 1), "base64");
|
|
123
|
-
}
|
package/src/saver.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, existsSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { resolve, join, extname, basename } from "node:path";
|
|
3
|
-
import { AppError } from "./errors.js";
|
|
4
|
-
|
|
5
|
-
export interface SaveOptions {
|
|
6
|
-
outputDir: string;
|
|
7
|
-
outputFilename: string;
|
|
8
|
-
format?: string;
|
|
9
|
-
overwrite?: boolean;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function saveImage(imageBuffer: Buffer, opts: SaveOptions): string {
|
|
13
|
-
const { outputDir, outputFilename, format = "png", overwrite = false } = opts;
|
|
14
|
-
|
|
15
|
-
const absDir = resolve(outputDir);
|
|
16
|
-
try {
|
|
17
|
-
mkdirSync(absDir, { recursive: true });
|
|
18
|
-
} catch (err) {
|
|
19
|
-
throw new AppError("E_SAVE_FAILED", `Cannot create directory: ${absDir} - ${err}`);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const ext = extname(outputFilename) || `.${format}`;
|
|
23
|
-
const base = extname(outputFilename) ? basename(outputFilename, ext) : outputFilename;
|
|
24
|
-
let finalPath = join(absDir, `${base}${ext}`);
|
|
25
|
-
|
|
26
|
-
if (!overwrite && existsSync(finalPath)) {
|
|
27
|
-
const ts = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 15);
|
|
28
|
-
finalPath = join(absDir, `${base}_${ts}${ext}`);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
writeFileSync(finalPath, imageBuffer);
|
|
33
|
-
} catch (err) {
|
|
34
|
-
throw new AppError("E_SAVE_FAILED", `Cannot write file: ${finalPath} - ${err}`);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
return finalPath;
|
|
38
|
-
}
|
package/tests/provider.test.ts
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { generateImage } from "../src/provider.js";
|
|
3
|
-
import type { Config } from "../src/config.js";
|
|
4
|
-
import { AppError } from "../src/errors.js";
|
|
5
|
-
|
|
6
|
-
const config: Config = {
|
|
7
|
-
apiKey: "sk-test",
|
|
8
|
-
baseUrl: "https://api.duojie.games",
|
|
9
|
-
model: "gemini-3.1-flash-image",
|
|
10
|
-
timeoutMs: 5000,
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
const fakeB64 = Buffer.from("fake-image").toString("base64");
|
|
14
|
-
const fakeDataUri = `data:image/png;base64,${fakeB64}`;
|
|
15
|
-
|
|
16
|
-
function makeOkResponse() {
|
|
17
|
-
return new Response(
|
|
18
|
-
JSON.stringify({
|
|
19
|
-
choices: [{ message: { images: [{ image_url: { url: fakeDataUri } }] } }],
|
|
20
|
-
}),
|
|
21
|
-
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
22
|
-
);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
describe("provider.generateImage", () => {
|
|
26
|
-
beforeEach(() => {
|
|
27
|
-
vi.restoreAllMocks();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("returns image buffer on success", async () => {
|
|
31
|
-
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(makeOkResponse()));
|
|
32
|
-
const buf = await generateImage(config, { prompt: "a cat" });
|
|
33
|
-
expect(buf).toBeInstanceOf(Buffer);
|
|
34
|
-
expect(buf.toString()).toBe("fake-image");
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("throws E_PROVIDER_AUTH on 401", async () => {
|
|
38
|
-
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 401 })));
|
|
39
|
-
await expect(generateImage(config, { prompt: "x" })).rejects.toMatchObject({ code: "E_PROVIDER_AUTH" });
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("throws E_PROVIDER_RATE_LIMIT on 429", async () => {
|
|
43
|
-
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 429 })));
|
|
44
|
-
await expect(generateImage(config, { prompt: "x" })).rejects.toMatchObject({ code: "E_PROVIDER_RATE_LIMIT" });
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("throws E_PROVIDER_BAD_REQUEST when no image in response", async () => {
|
|
48
|
-
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(
|
|
49
|
-
new Response(JSON.stringify({ choices: [{ message: {} }] }), { status: 200 }),
|
|
50
|
-
));
|
|
51
|
-
await expect(generateImage(config, { prompt: "x" })).rejects.toMatchObject({ code: "E_PROVIDER_BAD_REQUEST" });
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("throws E_REFERENCE_NOT_FOUND for missing reference image", async () => {
|
|
55
|
-
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(makeOkResponse()));
|
|
56
|
-
await expect(
|
|
57
|
-
generateImage(config, { prompt: "x", referenceImages: ["/nonexistent/image.png"] }),
|
|
58
|
-
).rejects.toMatchObject({ code: "E_REFERENCE_NOT_FOUND" });
|
|
59
|
-
});
|
|
60
|
-
});
|
package/tests/saver.test.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
-
import { mkdirSync, rmSync, existsSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { saveImage } from "../src/saver.js";
|
|
6
|
-
|
|
7
|
-
const testDir = join(tmpdir(), "mcp-duojie-test-" + Date.now());
|
|
8
|
-
|
|
9
|
-
afterEach(() => {
|
|
10
|
-
rmSync(testDir, { recursive: true, force: true });
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
describe("saver.saveImage", () => {
|
|
14
|
-
it("creates directory and saves file", () => {
|
|
15
|
-
const buf = Buffer.from("imgdata");
|
|
16
|
-
const path = saveImage(buf, { outputDir: testDir, outputFilename: "out" });
|
|
17
|
-
expect(existsSync(path)).toBe(true);
|
|
18
|
-
expect(path.endsWith(".png")).toBe(true);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("respects format extension", () => {
|
|
22
|
-
const buf = Buffer.from("imgdata");
|
|
23
|
-
const path = saveImage(buf, { outputDir: testDir, outputFilename: "out", format: "jpg" });
|
|
24
|
-
expect(path.endsWith(".jpg")).toBe(true);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("does not overwrite when overwrite=false", () => {
|
|
28
|
-
const buf = Buffer.from("imgdata");
|
|
29
|
-
const first = saveImage(buf, { outputDir: testDir, outputFilename: "out", overwrite: false });
|
|
30
|
-
const second = saveImage(buf, { outputDir: testDir, outputFilename: "out", overwrite: false });
|
|
31
|
-
expect(first).not.toBe(second);
|
|
32
|
-
expect(existsSync(first)).toBe(true);
|
|
33
|
-
expect(existsSync(second)).toBe(true);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("overwrites when overwrite=true", () => {
|
|
37
|
-
const buf = Buffer.from("imgdata");
|
|
38
|
-
const first = saveImage(buf, { outputDir: testDir, outputFilename: "out", overwrite: true });
|
|
39
|
-
const second = saveImage(buf, { outputDir: testDir, outputFilename: "out", overwrite: true });
|
|
40
|
-
expect(first).toBe(second);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("preserves extension in filename", () => {
|
|
44
|
-
const buf = Buffer.from("imgdata");
|
|
45
|
-
const path = saveImage(buf, { outputDir: testDir, outputFilename: "poster.webp" });
|
|
46
|
-
expect(path.endsWith(".webp")).toBe(true);
|
|
47
|
-
});
|
|
48
|
-
});
|
package/tsconfig.json
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "Node16",
|
|
5
|
-
"moduleResolution": "Node16",
|
|
6
|
-
"outDir": "dist",
|
|
7
|
-
"rootDir": "src",
|
|
8
|
-
"strict": true,
|
|
9
|
-
"esModuleInterop": true,
|
|
10
|
-
"skipLibCheck": true
|
|
11
|
-
},
|
|
12
|
-
"include": ["src"],
|
|
13
|
-
"exclude": ["node_modules", "dist", "tests"]
|
|
14
|
-
}
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
# MCP 香蕉模型生图需求文档(v0.1)
|
|
2
|
-
|
|
3
|
-
更新时间:2026-03-03
|
|
4
|
-
|
|
5
|
-
## 1. 背景与目标
|
|
6
|
-
|
|
7
|
-
目标是实现一个 MCP 服务,让用户通过自然语言一句话完成生图任务:
|
|
8
|
-
|
|
9
|
-
- 说清楚要生成什么图(提示词)
|
|
10
|
-
- 说清楚保存到哪里(目录)
|
|
11
|
-
- 说清楚文件叫什么(文件名)
|
|
12
|
-
|
|
13
|
-
MCP 收到调用后,使用固定服务地址 `https://api.duojie.games` 和模型 `gemini-3.1-flash-image` 生成图片,并保存到指定目录。
|
|
14
|
-
|
|
15
|
-
## 2. 设计范围(MVP)
|
|
16
|
-
|
|
17
|
-
MVP 只做一件事:`生成并落盘图片`,不做图库管理、任务队列面板、鉴权后台。
|
|
18
|
-
|
|
19
|
-
必须支持:
|
|
20
|
-
|
|
21
|
-
- 文生图(无参考图)
|
|
22
|
-
- 图生图(有 1~N 张参考图,参考图可选)
|
|
23
|
-
- 指定输出目录 + 文件名
|
|
24
|
-
- 可配置图片大小
|
|
25
|
-
- 用户自行配置 API Key(不能写死)
|
|
26
|
-
|
|
27
|
-
## 3. 对“香蕉模型常用生图姿势”的理解
|
|
28
|
-
|
|
29
|
-
结合 Gemini/Nano Banana 官方能力,优先支持以下 4 种高频姿势:
|
|
30
|
-
|
|
31
|
-
1. 文生图(Text-to-Image)
|
|
32
|
-
- 输入:`prompt`
|
|
33
|
-
- 输出:1 张图片(默认)
|
|
34
|
-
- 场景:海报、封面、插画草图
|
|
35
|
-
|
|
36
|
-
2. 单图参考编辑(Image + Text -> Image)
|
|
37
|
-
- 输入:`prompt + 1 张参考图`
|
|
38
|
-
- 输出:1 张改造后的图
|
|
39
|
-
- 场景:换背景、换风格、加元素
|
|
40
|
-
|
|
41
|
-
3. 多图融合(Multi-reference composition)
|
|
42
|
-
- 输入:`prompt + 多张参考图`
|
|
43
|
-
- 输出:1 张融合图
|
|
44
|
-
- 场景:商品合成、角色一致性、素材拼合
|
|
45
|
-
- 说明:Gemini 3.1 Image 能力文档提到可混合多张参考图(上限能力较高),MVP 先做保守上限以控风险
|
|
46
|
-
|
|
47
|
-
4. 多轮迭代编辑(Multi-turn editing)
|
|
48
|
-
- 输入:上一轮结果 + 新 prompt
|
|
49
|
-
- 输出:迭代后的新图
|
|
50
|
-
- 场景:持续改图直到满意
|
|
51
|
-
- MVP 策略:先不做服务端会话态,只支持“用户显式把上一轮图作为参考图再提交”
|
|
52
|
-
|
|
53
|
-
## 4. 用户流程(你预期的简单流程)
|
|
54
|
-
|
|
55
|
-
1. 用户对 AI 说:`生成什么图 + 存哪里 + 叫什么`
|
|
56
|
-
2. AI 触发 MCP 工具 `generate_image`
|
|
57
|
-
3. MCP 读取 key,调用 `https://api.duojie.games`
|
|
58
|
-
4. MCP 拿到图片数据(base64 或 URL)
|
|
59
|
-
5. MCP 在本地创建目录并保存文件
|
|
60
|
-
6. MCP 返回:成功/失败、最终保存路径、请求摘要
|
|
61
|
-
|
|
62
|
-
## 5. MCP 工具定义(建议)
|
|
63
|
-
|
|
64
|
-
工具名建议:`generate_image`
|
|
65
|
-
|
|
66
|
-
### 5.1 入参(需求版)
|
|
67
|
-
|
|
68
|
-
| 字段 | 类型 | 必填 | 说明 |
|
|
69
|
-
|---|---|---|---|
|
|
70
|
-
| `prompt` | `string` | 是 | 生图提示词 |
|
|
71
|
-
| `reference_images` | `string[]` | 否 | 参考图路径列表(本地绝对路径) |
|
|
72
|
-
| `output_dir` | `string` | 是 | 输出目录(绝对路径或工作区相对路径) |
|
|
73
|
-
| `output_filename` | `string` | 是 | 导出文件名,建议不含扩展名 |
|
|
74
|
-
| `size` | `string` | 否 | 大小档位,建议:`512px`/`1K`/`2K`/`4K` |
|
|
75
|
-
|
|
76
|
-
### 5.2 建议扩展参数(预留)
|
|
77
|
-
|
|
78
|
-
| 字段 | 类型 | 必填 | 说明 |
|
|
79
|
-
|---|---|---|---|
|
|
80
|
-
| `aspect_ratio` | `string` | 否 | 如 `1:1`、`16:9` |
|
|
81
|
-
| `format` | `string` | 否 | 输出格式,默认 `png` |
|
|
82
|
-
| `model` | `string` | 否 | 默认 `gemini-3.1-flash-image` |
|
|
83
|
-
| `overwrite` | `boolean` | 否 | 文件存在时是否覆盖,默认 `false` |
|
|
84
|
-
| `negative_prompt` | `string` | 否 | 若网关支持则透传 |
|
|
85
|
-
|
|
86
|
-
## 6. 配置需求(Key 与网关)
|
|
87
|
-
|
|
88
|
-
### 6.1 必需配置
|
|
89
|
-
|
|
90
|
-
- `DUOJIE_BASE_URL=https://api.duojie.games`
|
|
91
|
-
- `DUOJIE_API_KEY=用户自行配置`
|
|
92
|
-
- `DUOJIE_IMAGE_MODEL=gemini-3.1-flash-image`(默认值)
|
|
93
|
-
|
|
94
|
-
### 6.2 配置优先级
|
|
95
|
-
|
|
96
|
-
1. MCP 启动参数
|
|
97
|
-
2. 环境变量
|
|
98
|
-
3. 配置文件
|
|
99
|
-
|
|
100
|
-
### 6.3 安全约束
|
|
101
|
-
|
|
102
|
-
- 严禁把 key 写入代码仓库
|
|
103
|
-
- 日志中 key 必须脱敏(只显示前 6 后 4)
|
|
104
|
-
- 报错信息中不回显 Authorization 原文
|
|
105
|
-
|
|
106
|
-
## 7. 接口与落盘行为
|
|
107
|
-
|
|
108
|
-
### 7.1 调用行为
|
|
109
|
-
|
|
110
|
-
- HTTP Header:`Authorization: Bearer <KEY>`
|
|
111
|
-
- 请求超时:默认 60s(可配)
|
|
112
|
-
- 重试策略:仅网络抖动/5xx 重试,最多 2 次,指数退避
|
|
113
|
-
|
|
114
|
-
### 7.2 文件保存规则
|
|
115
|
-
|
|
116
|
-
- 若目录不存在:自动创建
|
|
117
|
-
- 默认扩展名:`.png`
|
|
118
|
-
- 文件冲突:
|
|
119
|
-
- `overwrite=false` 时自动追加时间戳或序号
|
|
120
|
-
- `overwrite=true` 时覆盖
|
|
121
|
-
- 返回绝对路径,方便上游 AI 直接引用
|
|
122
|
-
|
|
123
|
-
## 8. 错误处理(MVP)
|
|
124
|
-
|
|
125
|
-
统一错误码建议:
|
|
126
|
-
|
|
127
|
-
- `E_CONFIG_MISSING_KEY`:未配置 key
|
|
128
|
-
- `E_INPUT_INVALID`:参数不合法(空 prompt、非法路径等)
|
|
129
|
-
- `E_REFERENCE_NOT_FOUND`:参考图不存在或不可读
|
|
130
|
-
- `E_PROVIDER_AUTH`:上游鉴权失败(401/403)
|
|
131
|
-
- `E_PROVIDER_RATE_LIMIT`:限流
|
|
132
|
-
- `E_PROVIDER_BAD_REQUEST`:上游参数错误
|
|
133
|
-
- `E_PROVIDER_TIMEOUT`:上游超时
|
|
134
|
-
- `E_SAVE_FAILED`:落盘失败(权限/磁盘空间)
|
|
135
|
-
|
|
136
|
-
## 9. 验收标准(第一版)
|
|
137
|
-
|
|
138
|
-
1. 文生图成功
|
|
139
|
-
- 输入:仅 `prompt + output_dir + output_filename`
|
|
140
|
-
- 结果:指定目录出现图片文件,MCP 返回完整路径
|
|
141
|
-
|
|
142
|
-
2. 参考图可选
|
|
143
|
-
- 不传 `reference_images`:可正常生图
|
|
144
|
-
- 传 1~N 张有效参考图:可正常生图
|
|
145
|
-
|
|
146
|
-
3. 指定名称与目录生效
|
|
147
|
-
- 文件名与目录按用户要求保存
|
|
148
|
-
- 冲突策略符合 `overwrite` 规则
|
|
149
|
-
|
|
150
|
-
4. key 配置生效
|
|
151
|
-
- 未配置 key 时给出明确报错
|
|
152
|
-
- 配置错误 key 时能识别并返回鉴权错误
|
|
153
|
-
|
|
154
|
-
5. 基本稳定性
|
|
155
|
-
- 连续 20 次调用无进程崩溃
|
|
156
|
-
|
|
157
|
-
## 10. 当前假设与待确认项
|
|
158
|
-
|
|
159
|
-
以下内容需要你确认后再进入开发实现:
|
|
160
|
-
|
|
161
|
-
1. `https://api.duojie.games` 对 `gemini-3.1-flash-image` 的具体协议
|
|
162
|
-
- 是 OpenAI 兼容(如 `/v1/images/generations`)还是 Gemini `generateContent` 风格
|
|
163
|
-
|
|
164
|
-
2. `size` 参数真实字段名
|
|
165
|
-
- 可能是 `image_size`(如 `1K/2K`)或 `size`(如 `1024x1024`)
|
|
166
|
-
|
|
167
|
-
3. 参考图上传方式
|
|
168
|
-
- base64 内联
|
|
169
|
-
- URL 引用
|
|
170
|
-
- multipart 文件上传
|
|
171
|
-
|
|
172
|
-
4. 返回图片格式
|
|
173
|
-
- `b64_json`、`url`、或混合
|
|
174
|
-
|
|
175
|
-
5. 模型名是否固定为 `gemini-3.1-flash-image`
|
|
176
|
-
- 官方文档常见命名为 `gemini-3.1-flash-image-preview`,你的网关可能做了别名映射
|
|
177
|
-
|
|
178
|
-
## 11. 建议的下一步
|
|
179
|
-
|
|
180
|
-
确认完第 10 节后,下一步直接输出:
|
|
181
|
-
|
|
182
|
-
- MCP 工具 schema(JSON Schema)
|
|
183
|
-
- 请求适配层(兼容两类上游协议)
|
|
184
|
-
- 本地落盘实现与错误码实现
|
|
185
|
-
- 最小可跑 demo(CLI + MCP)
|
|
186
|
-
|
|
187
|
-
## 12. 参考资料
|
|
188
|
-
|
|
189
|
-
- Gemini Image Generation 文档:<https://ai.google.dev/gemini-api/docs/image-generation>
|
|
190
|
-
- Gemini OpenAI Compatibility 文档:<https://ai.google.dev/gemini-api/docs/openai>
|