@gracefullight/modelscope-image-mcp 0.1.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.ko.md ADDED
@@ -0,0 +1,113 @@
1
+ # @gracefullight/modelscope-image-mcp
2
+
3
+ > ModelScope API-Inference 이미지 생성을 위한 MCP 서버
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@gracefullight/modelscope-image-mcp.svg)](https://www.npmjs.com/package/@gracefullight/modelscope-image-mcp)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ [English](./README.md) | **한국어**
9
+
10
+ ## 기능
11
+
12
+ - **ModelScope 비동기 이미지 API** - 작업 제출 후 `/v1/tasks/{id}`를 폴링하고 결과를 다운로드
13
+ - **모든 API-Inference 이미지 모델** - 커뮤니티 LoRA 저장소(예: `owner/lora-name`) 포함
14
+ - **필수 환경변수 1개** - 나머지는 모두 기본값 제공
15
+ - **로컬 파일 저장** - 이미지를 저장하고 절대 경로를 반환
16
+ - **모든 stdio MCP 클라이언트** - Claude Desktop, Claude Code, Qwen Code, Cursor 등
17
+
18
+ ## 요구 사항
19
+
20
+ - Node.js 20+
21
+ - ModelScope 액세스 토큰 ([modelscope.ai](https://modelscope.ai) 또는 [modelscope.cn](https://modelscope.cn) 계정 설정에서 발급)
22
+
23
+ ## 빠른 시작
24
+
25
+ MCP 클라이언트 설정에 서버를 추가합니다.
26
+
27
+ ```json
28
+ {
29
+ "mcpServers": {
30
+ "modelscope-image": {
31
+ "command": "npx",
32
+ "args": ["-y", "@gracefullight/modelscope-image-mcp"],
33
+ "env": {
34
+ "MODELSCOPE_API_TOKEN": "ms-..."
35
+ }
36
+ }
37
+ }
38
+ }
39
+ ```
40
+
41
+ Claude Code:
42
+
43
+ ```bash
44
+ claude mcp add modelscope-image -e MODELSCOPE_API_TOKEN=ms-... -- npx -y @gracefullight/modelscope-image-mcp
45
+ ```
46
+
47
+ ## 설정
48
+
49
+ 필수 환경변수는 `MODELSCOPE_API_TOKEN` 하나입니다(`MODELSCOPE_SDK_TOKEN`도 인식). 선택 CLI 옵션은 다음과 같습니다.
50
+
51
+ | 옵션 | 기본값 | 설명 |
52
+ |------|--------|------|
53
+ | `--model` | `Qwen/Qwen-Image` | 도구 호출에 `model`이 없을 때 사용할 모델 |
54
+ | `--output-dir` | 프로젝트의 `generated-images` | 이미지 저장 디렉터리. 지정하지 않으면 클라이언트가 알려준 워크스페이스 루트([MCP roots](https://modelcontextprotocol.io/specification/2025-06-18/client/roots)) 아래 `generated-images`에 저장하고, 루트 정보가 없으면 서버 작업 디렉터리 아래에 저장 |
55
+ | `--base-url` | `https://api-inference.modelscope.ai` | API 기본 URL. ModelScope 중국 사이트는 `https://api-inference.modelscope.cn` |
56
+
57
+ LoRA 모델과 고정 저장 경로를 쓰는 예시:
58
+
59
+ ```json
60
+ {
61
+ "mcpServers": {
62
+ "modelscope-image": {
63
+ "command": "npx",
64
+ "args": [
65
+ "-y",
66
+ "@gracefullight/modelscope-image-mcp",
67
+ "--model",
68
+ "owner/lora-name",
69
+ "--output-dir",
70
+ "/Users/me/Pictures/modelscope"
71
+ ],
72
+ "env": {
73
+ "MODELSCOPE_API_TOKEN": "ms-..."
74
+ }
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ ## 도구: `generate_image`
81
+
82
+ | 파라미터 | 필수 | 설명 |
83
+ |----------|------|------|
84
+ | `prompt` | 예 | 생성할 이미지에 대한 상세 설명 |
85
+ | `model` | 아니요 | ModelScope 모델 ID(LoRA 저장소 지원). 기본값은 `--model` |
86
+ | `size` | 아니요 | `WIDTHxHEIGHT` 형식(예: `1024x1024`, `768x1344`). 지원 크기는 모델마다 다름 |
87
+ | `output_filename` | 아니요 | 디렉터리를 제외한 파일 이름. 확장자는 반환된 이미지 형식을 따름 |
88
+
89
+ 성공하면 저장 경로, 모델, 작업 ID, 이미지 URL을 반환합니다. HTTP 오류, 작업 실패, 시간 초과는 도구 오류로 반환합니다.
90
+
91
+ ## 참고
92
+
93
+ - 생성은 보통 수십 초가 걸립니다. 서버는 5초 간격으로 최대 10분간 폴링합니다.
94
+ - 요청은 ModelScope API-Inference 사용량에 포함되며, ModelScope가 콘텐츠 검수로 프롬프트를 거부할 수 있습니다.
95
+ - 모델은 사용하는 사이트의 API-Inference에서 제공되어야 합니다. modelscope.ai의 모델이 modelscope.cn에는 없을 수 있습니다.
96
+ - 토큰은 API 기본 URL에만 전송되며 이미지 다운로드 호스트에는 전송하지 않습니다.
97
+
98
+ ## 코드에서 사용
99
+
100
+ ```ts
101
+ import { generateImage, saveImage } from "@gracefullight/modelscope-image-mcp";
102
+
103
+ const image = await generateImage(
104
+ { apiKey: process.env.MODELSCOPE_API_TOKEN ?? "", baseUrl: "https://api-inference.modelscope.ai" },
105
+ { prompt: "a red apple on a white table", model: "Qwen/Qwen-Image", size: "1024x1024" },
106
+ );
107
+
108
+ const path = await saveImage("./generated-images", image.bytes, image.contentType, "apple");
109
+ ```
110
+
111
+ ## 라이선스
112
+
113
+ MIT
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # @gracefullight/modelscope-image-mcp
2
+
3
+ > MCP server for ModelScope API-Inference image generation
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@gracefullight/modelscope-image-mcp.svg)](https://www.npmjs.com/package/@gracefullight/modelscope-image-mcp)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ **English** | [한국어](./README.ko.md)
9
+
10
+ ## Features
11
+
12
+ - **Async ModelScope image API** - Submits a task, polls `/v1/tasks/{id}`, and downloads the result
13
+ - **Any API-Inference image model** - Including community LoRA repositories (e.g. `owner/lora-name`)
14
+ - **One required environment variable** - Everything else has sensible defaults
15
+ - **Local files** - Saves the image and returns its absolute path
16
+ - **Any stdio MCP client** - Claude Desktop, Claude Code, Qwen Code, Cursor, and more
17
+
18
+ ## Requirements
19
+
20
+ - Node.js 20+
21
+ - A ModelScope access token (from your account settings on [modelscope.ai](https://modelscope.ai) or [modelscope.cn](https://modelscope.cn))
22
+
23
+ ## Quick Start
24
+
25
+ Add the server to your MCP client configuration:
26
+
27
+ ```json
28
+ {
29
+ "mcpServers": {
30
+ "modelscope-image": {
31
+ "command": "npx",
32
+ "args": ["-y", "@gracefullight/modelscope-image-mcp"],
33
+ "env": {
34
+ "MODELSCOPE_API_TOKEN": "ms-..."
35
+ }
36
+ }
37
+ }
38
+ }
39
+ ```
40
+
41
+ Claude Code:
42
+
43
+ ```bash
44
+ claude mcp add modelscope-image -e MODELSCOPE_API_TOKEN=ms-... -- npx -y @gracefullight/modelscope-image-mcp
45
+ ```
46
+
47
+ ## Configuration
48
+
49
+ The only required environment variable is `MODELSCOPE_API_TOKEN` (`MODELSCOPE_SDK_TOKEN` is also accepted). Optional CLI flags:
50
+
51
+ | Flag | Default | Description |
52
+ |------|---------|-------------|
53
+ | `--model` | `Qwen/Qwen-Image` | Model used when a tool call omits `model` |
54
+ | `--output-dir` | `generated-images` in the project | Directory for saved images. Without it, images go to `generated-images` under the client's workspace root ([MCP roots](https://modelcontextprotocol.io/specification/2025-06-18/client/roots)), or under the server's working directory if the client reports no roots |
55
+ | `--base-url` | `https://api-inference.modelscope.ai` | API base URL. Use `https://api-inference.modelscope.cn` for ModelScope China |
56
+
57
+ Example with a LoRA model and a fixed output directory:
58
+
59
+ ```json
60
+ {
61
+ "mcpServers": {
62
+ "modelscope-image": {
63
+ "command": "npx",
64
+ "args": [
65
+ "-y",
66
+ "@gracefullight/modelscope-image-mcp",
67
+ "--model",
68
+ "owner/lora-name",
69
+ "--output-dir",
70
+ "/Users/me/Pictures/modelscope"
71
+ ],
72
+ "env": {
73
+ "MODELSCOPE_API_TOKEN": "ms-..."
74
+ }
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ ## Tool: `generate_image`
81
+
82
+ | Parameter | Required | Description |
83
+ |-----------|----------|-------------|
84
+ | `prompt` | Yes | Detailed description of the image |
85
+ | `model` | No | ModelScope model id (LoRA repositories supported). Defaults to `--model` |
86
+ | `size` | No | `WIDTHxHEIGHT`, e.g. `1024x1024` or `768x1344`. Supported sizes depend on the model |
87
+ | `output_filename` | No | File name without directories. The extension follows the returned image type |
88
+
89
+ On success the tool returns the saved path, model, task id, and image URL. Failures (HTTP errors, failed tasks, timeouts) are returned as tool errors.
90
+
91
+ ## Notes
92
+
93
+ - Generation usually takes tens of seconds. The server polls every 5 seconds for up to 10 minutes.
94
+ - Requests count against your ModelScope API-Inference quota, and ModelScope may reject prompts through content moderation.
95
+ - A model must be served by the API-Inference endpoint of the site you use; a model on modelscope.ai may not exist on modelscope.cn.
96
+ - The token is sent only to the API base URL, never to the image download host.
97
+
98
+ ## Programmatic Usage
99
+
100
+ ```ts
101
+ import { generateImage, saveImage } from "@gracefullight/modelscope-image-mcp";
102
+
103
+ const image = await generateImage(
104
+ { apiKey: process.env.MODELSCOPE_API_TOKEN ?? "", baseUrl: "https://api-inference.modelscope.ai" },
105
+ { prompt: "a red apple on a white table", model: "Qwen/Qwen-Image", size: "1024x1024" },
106
+ );
107
+
108
+ const path = await saveImage("./generated-images", image.bytes, image.contentType, "apple");
109
+ ```
110
+
111
+ ## License
112
+
113
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/cli.js ADDED
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
5
+
6
+ // src/config.ts
7
+ import { resolve } from "path";
8
+ import { parseArgs } from "util";
9
+ var DEFAULT_BASE_URL = "https://api-inference.modelscope.ai";
10
+ var DEFAULT_MODEL = "Qwen/Qwen-Image";
11
+ var DEFAULT_OUTPUT_DIR = "generated-images";
12
+ var TOKEN_ENV_KEYS = ["MODELSCOPE_API_TOKEN", "MODELSCOPE_SDK_TOKEN"];
13
+ function resolveConfig(argv, env = process.env, cwd = process.cwd()) {
14
+ const { values } = parseArgs({
15
+ args: argv,
16
+ options: {
17
+ model: { type: "string" },
18
+ "output-dir": { type: "string" },
19
+ "base-url": { type: "string" }
20
+ },
21
+ strict: true,
22
+ allowPositionals: false
23
+ });
24
+ const apiKey = TOKEN_ENV_KEYS.map((key) => env[key]?.trim()).find(Boolean);
25
+ if (!apiKey) {
26
+ throw new Error(`Set ${TOKEN_ENV_KEYS[0]} to your ModelScope access token.`);
27
+ }
28
+ const outputDir = values["output-dir"];
29
+ return {
30
+ apiKey,
31
+ baseUrl: normalizeBaseUrl(values["base-url"] ?? DEFAULT_BASE_URL),
32
+ defaultModel: values.model ?? DEFAULT_MODEL,
33
+ ...outputDir ? { outputDir: resolve(cwd, outputDir) } : {},
34
+ cwd
35
+ };
36
+ }
37
+ function resolveOutputDir(config, rootDirectories2 = []) {
38
+ return config.outputDir ?? resolve(rootDirectories2[0] ?? config.cwd, DEFAULT_OUTPUT_DIR);
39
+ }
40
+ function normalizeBaseUrl(value) {
41
+ let url;
42
+ try {
43
+ url = new URL(value);
44
+ } catch {
45
+ throw new Error(`--base-url must be a valid URL: ${value}`);
46
+ }
47
+ if (url.protocol !== "https:") {
48
+ throw new Error(`--base-url must use HTTPS: ${value}`);
49
+ }
50
+ return url.toString().replace(/\/+$/, "");
51
+ }
52
+
53
+ // src/server.ts
54
+ import { McpServer } from "@modelcontextprotocol/server";
55
+ import * as z from "zod";
56
+
57
+ // src/modelscope-client.ts
58
+ var DEFAULT_POLL_INTERVAL_MS = 5e3;
59
+ var DEFAULT_TIMEOUT_MS = 10 * 6e4;
60
+ var MAX_ERROR_BODY_CHARS = 500;
61
+ var ModelScopeError = class extends Error {
62
+ status;
63
+ constructor(message, status) {
64
+ super(message);
65
+ this.name = "ModelScopeError";
66
+ this.status = status;
67
+ }
68
+ };
69
+ var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
70
+ async function generateImage(options, request, signal) {
71
+ const fetchFn = options.fetch ?? fetch;
72
+ const baseUrl = options.baseUrl.replace(/\/+$/, "");
73
+ const auth = { Authorization: `Bearer ${options.apiKey}` };
74
+ const body = { model: request.model, prompt: request.prompt };
75
+ if (request.size) {
76
+ body.size = request.size;
77
+ }
78
+ const submitResponse = await fetchFn(`${baseUrl}/v1/images/generations`, {
79
+ method: "POST",
80
+ headers: { ...auth, "Content-Type": "application/json", "X-ModelScope-Async-Mode": "true" },
81
+ body: JSON.stringify(body),
82
+ signal
83
+ });
84
+ const submitted = await readJson(submitResponse, "submit");
85
+ const taskId = submitted.task_id;
86
+ if (!taskId) {
87
+ throw new ModelScopeError(
88
+ `ModelScope submit response did not include task_id: ${truncate(JSON.stringify(submitted))}`
89
+ );
90
+ }
91
+ const imageUrl = await waitForImageUrl(options, baseUrl, auth, taskId, signal);
92
+ const { contentType, bytes } = await downloadImage(fetchFn, imageUrl, signal);
93
+ return { taskId, imageUrl, contentType, bytes };
94
+ }
95
+ async function waitForImageUrl(options, baseUrl, auth, taskId, signal) {
96
+ const fetchFn = options.fetch ?? fetch;
97
+ const sleep = options.sleep ?? defaultSleep;
98
+ const now = options.now ?? Date.now;
99
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
100
+ const deadline = now() + timeoutMs;
101
+ for (; ; ) {
102
+ await sleep(options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
103
+ signal?.throwIfAborted();
104
+ const response = await fetchFn(`${baseUrl}/v1/tasks/${encodeURIComponent(taskId)}`, {
105
+ headers: { ...auth, "X-ModelScope-Task-Type": "image_generation" },
106
+ signal
107
+ });
108
+ const task = await readJson(response, "poll");
109
+ const imageUrl = imageUrlFromTask(task, taskId);
110
+ if (imageUrl) {
111
+ return imageUrl;
112
+ }
113
+ if (now() >= deadline) {
114
+ throw new ModelScopeError(
115
+ `ModelScope task ${taskId} did not finish within ${Math.round(timeoutMs / 1e3)}s (last status: ${task.task_status ?? "unknown"})`
116
+ );
117
+ }
118
+ }
119
+ }
120
+ function imageUrlFromTask(task, taskId) {
121
+ if (task.task_status === "FAILED") {
122
+ throw new ModelScopeError(
123
+ `ModelScope task ${taskId} failed: ${truncate(task.message ?? JSON.stringify(task))}`
124
+ );
125
+ }
126
+ if (task.task_status !== "SUCCEED") {
127
+ return void 0;
128
+ }
129
+ const imageUrl = task.output_images?.[0];
130
+ if (!imageUrl) {
131
+ throw new ModelScopeError(`ModelScope task ${taskId} succeeded without output_images`);
132
+ }
133
+ return imageUrl;
134
+ }
135
+ async function downloadImage(fetchFn, imageUrl, signal) {
136
+ let url;
137
+ try {
138
+ url = new URL(imageUrl);
139
+ } catch {
140
+ throw new ModelScopeError(`ModelScope returned an invalid image URL: ${truncate(imageUrl)}`);
141
+ }
142
+ if (url.protocol !== "https:") {
143
+ throw new ModelScopeError(`ModelScope returned a non-HTTPS image URL: ${truncate(imageUrl)}`);
144
+ }
145
+ const response = await fetchFn(url, { signal });
146
+ if (!response.ok) {
147
+ throw new ModelScopeError(
148
+ `Image download failed with HTTP ${response.status}`,
149
+ response.status
150
+ );
151
+ }
152
+ const contentType = (response.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
153
+ if (!contentType.startsWith("image/")) {
154
+ throw new ModelScopeError(
155
+ `Downloaded content is not an image (${contentType || "unknown content type"})`
156
+ );
157
+ }
158
+ return { contentType, bytes: new Uint8Array(await response.arrayBuffer()) };
159
+ }
160
+ async function readJson(response, stage) {
161
+ const text = await response.text();
162
+ if (!response.ok) {
163
+ throw new ModelScopeError(
164
+ `ModelScope ${stage} failed with HTTP ${response.status}: ${truncate(text)}`,
165
+ response.status
166
+ );
167
+ }
168
+ try {
169
+ return JSON.parse(text);
170
+ } catch {
171
+ throw new ModelScopeError(
172
+ `ModelScope ${stage} returned invalid JSON (HTTP ${response.status})`,
173
+ response.status
174
+ );
175
+ }
176
+ }
177
+ function truncate(value) {
178
+ return value.length > MAX_ERROR_BODY_CHARS ? `${value.slice(0, MAX_ERROR_BODY_CHARS)}\u2026` : value;
179
+ }
180
+
181
+ // src/roots.ts
182
+ import { fileURLToPath } from "url";
183
+ function rootDirectories(roots) {
184
+ return roots.flatMap((root) => {
185
+ if (!root.uri.startsWith("file://")) {
186
+ return [];
187
+ }
188
+ try {
189
+ return [fileURLToPath(root.uri)];
190
+ } catch {
191
+ return [];
192
+ }
193
+ });
194
+ }
195
+
196
+ // src/save-image.ts
197
+ import { randomUUID } from "crypto";
198
+ import { mkdir, writeFile } from "fs/promises";
199
+ import { basename, extname, join, resolve as resolve2 } from "path";
200
+ var EXTENSION_BY_CONTENT_TYPE = {
201
+ "image/png": ".png",
202
+ "image/jpeg": ".jpg",
203
+ "image/webp": ".webp",
204
+ "image/gif": ".gif"
205
+ };
206
+ function toFileStem(name, date = /* @__PURE__ */ new Date(), id = randomUUID().slice(0, 8)) {
207
+ const raw = name ? basename(name, extname(name)) : "";
208
+ const stem = raw.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^[._]+|[._]+$/g, "");
209
+ if (stem) {
210
+ return stem;
211
+ }
212
+ const stamp = date.toISOString().replace(/[-:]/g, "").replace(/\..+$/, "").replace("T", "-");
213
+ return `${stamp}-${id}`;
214
+ }
215
+ async function saveImage(outputDir, bytes, contentType, filename) {
216
+ const dir = resolve2(outputDir);
217
+ await mkdir(dir, { recursive: true });
218
+ const path = join(
219
+ dir,
220
+ `${toFileStem(filename)}${EXTENSION_BY_CONTENT_TYPE[contentType] ?? ".png"}`
221
+ );
222
+ await writeFile(path, bytes);
223
+ return path;
224
+ }
225
+
226
+ // src/version.ts
227
+ var VERSION = "0.1.1";
228
+
229
+ // src/server.ts
230
+ var SIZE_PATTERN = /^\d{2,4}x\d{2,4}$/;
231
+ var ROOTS_TIMEOUT_MS = 5e3;
232
+ var generateImageInputSchema = z.object({
233
+ prompt: z.string().trim().min(1).describe("Detailed description of the image to generate."),
234
+ model: z.string().trim().min(1).optional().describe(
235
+ "ModelScope model id, including LoRA repositories. Defaults to the server's configured model."
236
+ ),
237
+ size: z.string().regex(SIZE_PATTERN, "size must look like 1024x1024").optional().describe("Output size as WIDTHxHEIGHT, e.g. 1024x1024 or 768x1344."),
238
+ output_filename: z.string().optional().describe("File name without directories. The extension follows the returned image type.")
239
+ });
240
+ async function runGenerateImage(config, input, deps = {}) {
241
+ const generate = deps.generate ?? generateImage;
242
+ const save = deps.save ?? saveImage;
243
+ const model = input.model ?? config.defaultModel;
244
+ try {
245
+ const roots = config.outputDir || !deps.listRootDirectories ? [] : await deps.listRootDirectories();
246
+ const outputDir = resolveOutputDir(config, roots);
247
+ const image = await generate(
248
+ { apiKey: config.apiKey, baseUrl: config.baseUrl },
249
+ { prompt: input.prompt, model, size: input.size }
250
+ );
251
+ const path = await save(outputDir, image.bytes, image.contentType, input.output_filename);
252
+ const lines = [
253
+ `Image saved to ${path}`,
254
+ `model: ${model}`,
255
+ ...input.size ? [`size: ${input.size}`] : [],
256
+ `task_id: ${image.taskId}`,
257
+ `url: ${image.imageUrl}`
258
+ ];
259
+ return { content: [{ type: "text", text: lines.join("\n") }] };
260
+ } catch (error) {
261
+ const message = error instanceof Error ? error.message : String(error);
262
+ return {
263
+ isError: true,
264
+ content: [{ type: "text", text: `Image generation failed: ${message}` }]
265
+ };
266
+ }
267
+ }
268
+ function createServer(config, deps = {}) {
269
+ const server = new McpServer({ name: "modelscope-image-mcp", version: VERSION });
270
+ const toolDeps = {
271
+ listRootDirectories: () => listClientRootDirectories(server),
272
+ ...deps
273
+ };
274
+ server.registerTool(
275
+ "generate_image",
276
+ {
277
+ title: "Generate image",
278
+ description: "Generate an image with a ModelScope API-Inference model (LoRA repository ids supported) and save it locally. Returns the saved file path.",
279
+ inputSchema: generateImageInputSchema
280
+ },
281
+ (input) => runGenerateImage(config, input, toolDeps)
282
+ );
283
+ return server;
284
+ }
285
+ async function listClientRootDirectories(server) {
286
+ if (!server.server.getClientCapabilities()?.roots) {
287
+ return [];
288
+ }
289
+ try {
290
+ const { roots } = await server.server.listRoots(void 0, { timeout: ROOTS_TIMEOUT_MS });
291
+ return rootDirectories(roots);
292
+ } catch {
293
+ return [];
294
+ }
295
+ }
296
+
297
+ // src/cli.ts
298
+ async function main() {
299
+ const config = resolveConfig(process.argv.slice(2));
300
+ const server = createServer(config);
301
+ await server.connect(new StdioServerTransport());
302
+ }
303
+ main().catch((error) => {
304
+ console.error(`modelscope-image-mcp: ${error instanceof Error ? error.message : String(error)}`);
305
+ process.exit(1);
306
+ });
@@ -0,0 +1,82 @@
1
+ import { McpServer, CallToolResult } from '@modelcontextprotocol/server';
2
+ import * as z from 'zod';
3
+
4
+ declare const DEFAULT_BASE_URL = "https://api-inference.modelscope.ai";
5
+ declare const DEFAULT_MODEL = "Qwen/Qwen-Image";
6
+ declare const DEFAULT_OUTPUT_DIR = "generated-images";
7
+ /** The first key is canonical; the second keeps compatibility with the Python modelscope-image-mcp. */
8
+ declare const TOKEN_ENV_KEYS: readonly ["MODELSCOPE_API_TOKEN", "MODELSCOPE_SDK_TOKEN"];
9
+ interface ServerConfig {
10
+ apiKey: string;
11
+ baseUrl: string;
12
+ defaultModel: string;
13
+ /** Absolute `--output-dir`. When unset, images go to the client's workspace root. */
14
+ outputDir?: string;
15
+ cwd: string;
16
+ }
17
+ declare function resolveConfig(argv: string[], env?: NodeJS.ProcessEnv, cwd?: string): ServerConfig;
18
+ /**
19
+ * Chooses where images are saved: the explicit `--output-dir`, otherwise `generated-images`
20
+ * under the first workspace root reported by the client, otherwise under the working directory.
21
+ */
22
+ declare function resolveOutputDir(config: ServerConfig, rootDirectories?: string[]): string;
23
+
24
+ interface ModelScopeClientOptions {
25
+ apiKey: string;
26
+ baseUrl: string;
27
+ fetch?: typeof fetch;
28
+ sleep?: (ms: number) => Promise<void>;
29
+ now?: () => number;
30
+ pollIntervalMs?: number;
31
+ timeoutMs?: number;
32
+ }
33
+ interface ImageGenerationRequest {
34
+ prompt: string;
35
+ model: string;
36
+ size?: string;
37
+ }
38
+ interface GeneratedImage {
39
+ taskId: string;
40
+ imageUrl: string;
41
+ contentType: string;
42
+ bytes: Uint8Array;
43
+ }
44
+ declare class ModelScopeError extends Error {
45
+ readonly status?: number;
46
+ constructor(message: string, status?: number);
47
+ }
48
+ /**
49
+ * Generates an image through the ModelScope async API:
50
+ * submit with X-ModelScope-Async-Mode, poll /v1/tasks/{id}, then download the first output image.
51
+ */
52
+ declare function generateImage(options: ModelScopeClientOptions, request: ImageGenerationRequest, signal?: AbortSignal): Promise<GeneratedImage>;
53
+
54
+ interface RootLike {
55
+ uri: string;
56
+ }
57
+ /** Converts MCP roots to local directory paths, skipping non-file and malformed URIs. */
58
+ declare function rootDirectories(roots: RootLike[]): string[];
59
+
60
+ /** Returns a filesystem-safe file stem; directories and extensions in `name` are discarded. */
61
+ declare function toFileStem(name?: string, date?: Date, id?: string): string;
62
+ declare function saveImage(outputDir: string, bytes: Uint8Array, contentType: string, filename?: string): Promise<string>;
63
+
64
+ declare const generateImageInputSchema: z.ZodObject<{
65
+ prompt: z.ZodString;
66
+ model: z.ZodOptional<z.ZodString>;
67
+ size: z.ZodOptional<z.ZodString>;
68
+ output_filename: z.ZodOptional<z.ZodString>;
69
+ }, z.core.$strip>;
70
+ type GenerateImageInput = z.infer<typeof generateImageInputSchema>;
71
+ interface GenerateImageDeps {
72
+ generate?: typeof generateImage;
73
+ save?: typeof saveImage;
74
+ /** Workspace root directories reported by the client; consulted only when `--output-dir` is unset. */
75
+ listRootDirectories?: () => Promise<string[]>;
76
+ }
77
+ declare function runGenerateImage(config: ServerConfig, input: GenerateImageInput, deps?: GenerateImageDeps): Promise<CallToolResult>;
78
+ declare function createServer(config: ServerConfig, deps?: GenerateImageDeps): McpServer;
79
+
80
+ declare const VERSION = "0.1.1";
81
+
82
+ export { DEFAULT_BASE_URL, DEFAULT_MODEL, DEFAULT_OUTPUT_DIR, type GenerateImageDeps, type GenerateImageInput, type GeneratedImage, type ImageGenerationRequest, type ModelScopeClientOptions, ModelScopeError, type RootLike, type ServerConfig, TOKEN_ENV_KEYS, VERSION, createServer, generateImage, generateImageInputSchema, resolveConfig, resolveOutputDir, rootDirectories, runGenerateImage, saveImage, toFileStem };
package/dist/index.js ADDED
@@ -0,0 +1,307 @@
1
+ // src/config.ts
2
+ import { resolve } from "path";
3
+ import { parseArgs } from "util";
4
+ var DEFAULT_BASE_URL = "https://api-inference.modelscope.ai";
5
+ var DEFAULT_MODEL = "Qwen/Qwen-Image";
6
+ var DEFAULT_OUTPUT_DIR = "generated-images";
7
+ var TOKEN_ENV_KEYS = ["MODELSCOPE_API_TOKEN", "MODELSCOPE_SDK_TOKEN"];
8
+ function resolveConfig(argv, env = process.env, cwd = process.cwd()) {
9
+ const { values } = parseArgs({
10
+ args: argv,
11
+ options: {
12
+ model: { type: "string" },
13
+ "output-dir": { type: "string" },
14
+ "base-url": { type: "string" }
15
+ },
16
+ strict: true,
17
+ allowPositionals: false
18
+ });
19
+ const apiKey = TOKEN_ENV_KEYS.map((key) => env[key]?.trim()).find(Boolean);
20
+ if (!apiKey) {
21
+ throw new Error(`Set ${TOKEN_ENV_KEYS[0]} to your ModelScope access token.`);
22
+ }
23
+ const outputDir = values["output-dir"];
24
+ return {
25
+ apiKey,
26
+ baseUrl: normalizeBaseUrl(values["base-url"] ?? DEFAULT_BASE_URL),
27
+ defaultModel: values.model ?? DEFAULT_MODEL,
28
+ ...outputDir ? { outputDir: resolve(cwd, outputDir) } : {},
29
+ cwd
30
+ };
31
+ }
32
+ function resolveOutputDir(config, rootDirectories2 = []) {
33
+ return config.outputDir ?? resolve(rootDirectories2[0] ?? config.cwd, DEFAULT_OUTPUT_DIR);
34
+ }
35
+ function normalizeBaseUrl(value) {
36
+ let url;
37
+ try {
38
+ url = new URL(value);
39
+ } catch {
40
+ throw new Error(`--base-url must be a valid URL: ${value}`);
41
+ }
42
+ if (url.protocol !== "https:") {
43
+ throw new Error(`--base-url must use HTTPS: ${value}`);
44
+ }
45
+ return url.toString().replace(/\/+$/, "");
46
+ }
47
+
48
+ // src/modelscope-client.ts
49
+ var DEFAULT_POLL_INTERVAL_MS = 5e3;
50
+ var DEFAULT_TIMEOUT_MS = 10 * 6e4;
51
+ var MAX_ERROR_BODY_CHARS = 500;
52
+ var ModelScopeError = class extends Error {
53
+ status;
54
+ constructor(message, status) {
55
+ super(message);
56
+ this.name = "ModelScopeError";
57
+ this.status = status;
58
+ }
59
+ };
60
+ var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
61
+ async function generateImage(options, request, signal) {
62
+ const fetchFn = options.fetch ?? fetch;
63
+ const baseUrl = options.baseUrl.replace(/\/+$/, "");
64
+ const auth = { Authorization: `Bearer ${options.apiKey}` };
65
+ const body = { model: request.model, prompt: request.prompt };
66
+ if (request.size) {
67
+ body.size = request.size;
68
+ }
69
+ const submitResponse = await fetchFn(`${baseUrl}/v1/images/generations`, {
70
+ method: "POST",
71
+ headers: { ...auth, "Content-Type": "application/json", "X-ModelScope-Async-Mode": "true" },
72
+ body: JSON.stringify(body),
73
+ signal
74
+ });
75
+ const submitted = await readJson(submitResponse, "submit");
76
+ const taskId = submitted.task_id;
77
+ if (!taskId) {
78
+ throw new ModelScopeError(
79
+ `ModelScope submit response did not include task_id: ${truncate(JSON.stringify(submitted))}`
80
+ );
81
+ }
82
+ const imageUrl = await waitForImageUrl(options, baseUrl, auth, taskId, signal);
83
+ const { contentType, bytes } = await downloadImage(fetchFn, imageUrl, signal);
84
+ return { taskId, imageUrl, contentType, bytes };
85
+ }
86
+ async function waitForImageUrl(options, baseUrl, auth, taskId, signal) {
87
+ const fetchFn = options.fetch ?? fetch;
88
+ const sleep = options.sleep ?? defaultSleep;
89
+ const now = options.now ?? Date.now;
90
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
91
+ const deadline = now() + timeoutMs;
92
+ for (; ; ) {
93
+ await sleep(options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
94
+ signal?.throwIfAborted();
95
+ const response = await fetchFn(`${baseUrl}/v1/tasks/${encodeURIComponent(taskId)}`, {
96
+ headers: { ...auth, "X-ModelScope-Task-Type": "image_generation" },
97
+ signal
98
+ });
99
+ const task = await readJson(response, "poll");
100
+ const imageUrl = imageUrlFromTask(task, taskId);
101
+ if (imageUrl) {
102
+ return imageUrl;
103
+ }
104
+ if (now() >= deadline) {
105
+ throw new ModelScopeError(
106
+ `ModelScope task ${taskId} did not finish within ${Math.round(timeoutMs / 1e3)}s (last status: ${task.task_status ?? "unknown"})`
107
+ );
108
+ }
109
+ }
110
+ }
111
+ function imageUrlFromTask(task, taskId) {
112
+ if (task.task_status === "FAILED") {
113
+ throw new ModelScopeError(
114
+ `ModelScope task ${taskId} failed: ${truncate(task.message ?? JSON.stringify(task))}`
115
+ );
116
+ }
117
+ if (task.task_status !== "SUCCEED") {
118
+ return void 0;
119
+ }
120
+ const imageUrl = task.output_images?.[0];
121
+ if (!imageUrl) {
122
+ throw new ModelScopeError(`ModelScope task ${taskId} succeeded without output_images`);
123
+ }
124
+ return imageUrl;
125
+ }
126
+ async function downloadImage(fetchFn, imageUrl, signal) {
127
+ let url;
128
+ try {
129
+ url = new URL(imageUrl);
130
+ } catch {
131
+ throw new ModelScopeError(`ModelScope returned an invalid image URL: ${truncate(imageUrl)}`);
132
+ }
133
+ if (url.protocol !== "https:") {
134
+ throw new ModelScopeError(`ModelScope returned a non-HTTPS image URL: ${truncate(imageUrl)}`);
135
+ }
136
+ const response = await fetchFn(url, { signal });
137
+ if (!response.ok) {
138
+ throw new ModelScopeError(
139
+ `Image download failed with HTTP ${response.status}`,
140
+ response.status
141
+ );
142
+ }
143
+ const contentType = (response.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
144
+ if (!contentType.startsWith("image/")) {
145
+ throw new ModelScopeError(
146
+ `Downloaded content is not an image (${contentType || "unknown content type"})`
147
+ );
148
+ }
149
+ return { contentType, bytes: new Uint8Array(await response.arrayBuffer()) };
150
+ }
151
+ async function readJson(response, stage) {
152
+ const text = await response.text();
153
+ if (!response.ok) {
154
+ throw new ModelScopeError(
155
+ `ModelScope ${stage} failed with HTTP ${response.status}: ${truncate(text)}`,
156
+ response.status
157
+ );
158
+ }
159
+ try {
160
+ return JSON.parse(text);
161
+ } catch {
162
+ throw new ModelScopeError(
163
+ `ModelScope ${stage} returned invalid JSON (HTTP ${response.status})`,
164
+ response.status
165
+ );
166
+ }
167
+ }
168
+ function truncate(value) {
169
+ return value.length > MAX_ERROR_BODY_CHARS ? `${value.slice(0, MAX_ERROR_BODY_CHARS)}\u2026` : value;
170
+ }
171
+
172
+ // src/roots.ts
173
+ import { fileURLToPath } from "url";
174
+ function rootDirectories(roots) {
175
+ return roots.flatMap((root) => {
176
+ if (!root.uri.startsWith("file://")) {
177
+ return [];
178
+ }
179
+ try {
180
+ return [fileURLToPath(root.uri)];
181
+ } catch {
182
+ return [];
183
+ }
184
+ });
185
+ }
186
+
187
+ // src/save-image.ts
188
+ import { randomUUID } from "crypto";
189
+ import { mkdir, writeFile } from "fs/promises";
190
+ import { basename, extname, join, resolve as resolve2 } from "path";
191
+ var EXTENSION_BY_CONTENT_TYPE = {
192
+ "image/png": ".png",
193
+ "image/jpeg": ".jpg",
194
+ "image/webp": ".webp",
195
+ "image/gif": ".gif"
196
+ };
197
+ function toFileStem(name, date = /* @__PURE__ */ new Date(), id = randomUUID().slice(0, 8)) {
198
+ const raw = name ? basename(name, extname(name)) : "";
199
+ const stem = raw.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^[._]+|[._]+$/g, "");
200
+ if (stem) {
201
+ return stem;
202
+ }
203
+ const stamp = date.toISOString().replace(/[-:]/g, "").replace(/\..+$/, "").replace("T", "-");
204
+ return `${stamp}-${id}`;
205
+ }
206
+ async function saveImage(outputDir, bytes, contentType, filename) {
207
+ const dir = resolve2(outputDir);
208
+ await mkdir(dir, { recursive: true });
209
+ const path = join(
210
+ dir,
211
+ `${toFileStem(filename)}${EXTENSION_BY_CONTENT_TYPE[contentType] ?? ".png"}`
212
+ );
213
+ await writeFile(path, bytes);
214
+ return path;
215
+ }
216
+
217
+ // src/server.ts
218
+ import { McpServer } from "@modelcontextprotocol/server";
219
+ import * as z from "zod";
220
+
221
+ // src/version.ts
222
+ var VERSION = "0.1.1";
223
+
224
+ // src/server.ts
225
+ var SIZE_PATTERN = /^\d{2,4}x\d{2,4}$/;
226
+ var ROOTS_TIMEOUT_MS = 5e3;
227
+ var generateImageInputSchema = z.object({
228
+ prompt: z.string().trim().min(1).describe("Detailed description of the image to generate."),
229
+ model: z.string().trim().min(1).optional().describe(
230
+ "ModelScope model id, including LoRA repositories. Defaults to the server's configured model."
231
+ ),
232
+ size: z.string().regex(SIZE_PATTERN, "size must look like 1024x1024").optional().describe("Output size as WIDTHxHEIGHT, e.g. 1024x1024 or 768x1344."),
233
+ output_filename: z.string().optional().describe("File name without directories. The extension follows the returned image type.")
234
+ });
235
+ async function runGenerateImage(config, input, deps = {}) {
236
+ const generate = deps.generate ?? generateImage;
237
+ const save = deps.save ?? saveImage;
238
+ const model = input.model ?? config.defaultModel;
239
+ try {
240
+ const roots = config.outputDir || !deps.listRootDirectories ? [] : await deps.listRootDirectories();
241
+ const outputDir = resolveOutputDir(config, roots);
242
+ const image = await generate(
243
+ { apiKey: config.apiKey, baseUrl: config.baseUrl },
244
+ { prompt: input.prompt, model, size: input.size }
245
+ );
246
+ const path = await save(outputDir, image.bytes, image.contentType, input.output_filename);
247
+ const lines = [
248
+ `Image saved to ${path}`,
249
+ `model: ${model}`,
250
+ ...input.size ? [`size: ${input.size}`] : [],
251
+ `task_id: ${image.taskId}`,
252
+ `url: ${image.imageUrl}`
253
+ ];
254
+ return { content: [{ type: "text", text: lines.join("\n") }] };
255
+ } catch (error) {
256
+ const message = error instanceof Error ? error.message : String(error);
257
+ return {
258
+ isError: true,
259
+ content: [{ type: "text", text: `Image generation failed: ${message}` }]
260
+ };
261
+ }
262
+ }
263
+ function createServer(config, deps = {}) {
264
+ const server = new McpServer({ name: "modelscope-image-mcp", version: VERSION });
265
+ const toolDeps = {
266
+ listRootDirectories: () => listClientRootDirectories(server),
267
+ ...deps
268
+ };
269
+ server.registerTool(
270
+ "generate_image",
271
+ {
272
+ title: "Generate image",
273
+ description: "Generate an image with a ModelScope API-Inference model (LoRA repository ids supported) and save it locally. Returns the saved file path.",
274
+ inputSchema: generateImageInputSchema
275
+ },
276
+ (input) => runGenerateImage(config, input, toolDeps)
277
+ );
278
+ return server;
279
+ }
280
+ async function listClientRootDirectories(server) {
281
+ if (!server.server.getClientCapabilities()?.roots) {
282
+ return [];
283
+ }
284
+ try {
285
+ const { roots } = await server.server.listRoots(void 0, { timeout: ROOTS_TIMEOUT_MS });
286
+ return rootDirectories(roots);
287
+ } catch {
288
+ return [];
289
+ }
290
+ }
291
+ export {
292
+ DEFAULT_BASE_URL,
293
+ DEFAULT_MODEL,
294
+ DEFAULT_OUTPUT_DIR,
295
+ ModelScopeError,
296
+ TOKEN_ENV_KEYS,
297
+ VERSION,
298
+ createServer,
299
+ generateImage,
300
+ generateImageInputSchema,
301
+ resolveConfig,
302
+ resolveOutputDir,
303
+ rootDirectories,
304
+ runGenerateImage,
305
+ saveImage,
306
+ toFileStem
307
+ };
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@gracefullight/modelscope-image-mcp",
3
+ "version": "0.1.1",
4
+ "description": "MCP server for ModelScope API-Inference image generation with async task polling and LoRA model support",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "bin": {
9
+ "modelscope-image-mcp": "./dist/cli.js"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "import": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ }
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "dev": "tsx src/cli.ts",
28
+ "test": "vitest",
29
+ "typecheck": "tsc",
30
+ "lint": "biome check src",
31
+ "lint:fix": "biome check --write src",
32
+ "format": "biome format --write src"
33
+ },
34
+ "keywords": [
35
+ "mcp",
36
+ "model-context-protocol",
37
+ "modelscope",
38
+ "image-generation",
39
+ "text-to-image",
40
+ "lora",
41
+ "qwen-image",
42
+ "flux"
43
+ ],
44
+ "author": {
45
+ "name": "Eunkwang Shin",
46
+ "email": "gracefullight.dev@gmail.com"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "https://github.com/gracefullight/pkgs.git",
51
+ "directory": "packages/modelscope-image-mcp"
52
+ },
53
+ "homepage": "https://github.com/gracefullight/pkgs/tree/main/packages/modelscope-image-mcp#readme",
54
+ "bugs": {
55
+ "url": "https://github.com/gracefullight/pkgs/issues"
56
+ },
57
+ "license": "MIT",
58
+ "funding": {
59
+ "type": "github",
60
+ "url": "https://github.com/sponsors/gracefullight"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public"
64
+ },
65
+ "dependencies": {
66
+ "@modelcontextprotocol/server": "^2.0.0",
67
+ "zod": "^4.2.0"
68
+ },
69
+ "devDependencies": {
70
+ "@types/node": "^24.10.9",
71
+ "tsup": "^8.5.1",
72
+ "tsx": "^4.21.0",
73
+ "typescript": "^6",
74
+ "vitest": "^4.0.17"
75
+ }
76
+ }