@alonetech/gpt-image-mcp 0.1.0 → 0.3.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 CHANGED
@@ -90,6 +90,7 @@ generate_image
90
90
  Required arguments:
91
91
 
92
92
  - `prompt`: prompt describing the output image
93
+ - `output`: filesystem path where the generated image will be saved
93
94
 
94
95
  Optional arguments:
95
96
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alonetech/gpt-image-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP stdio server and CLI for text-to-image and image-to-image generation.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -3,15 +3,13 @@ import { basename } from "node:path";
3
3
 
4
4
  import { generateImage } from "./image-api.js";
5
5
 
6
- const DEFAULT_OUTPUT = "output.png";
7
-
8
6
  function usage() {
9
7
  return `Usage:
10
- gpt-image-mcp generate --base-url <url> --token <token> --prompt <text> [options]
8
+ gpt-image-mcp generate --base-url <url> --token <token> --prompt <text> --output <path> [options]
11
9
 
12
10
  Options:
13
11
  --image <path> Optional input image. Repeat for image-to-image mode.
14
- --output <path> Output image path. Default: ${DEFAULT_OUTPUT}
12
+ --output <path> Required output image path.
15
13
  --model <model> Image model. Default: gpt-image-2
16
14
  --size <size> Image size. Default: auto
17
15
  --quality <quality> auto, low, medium, or high. Default: auto
@@ -31,7 +29,6 @@ function readOption(args, index) {
31
29
  function parseGenerateArgs(args) {
32
30
  const options = {
33
31
  images: [],
34
- output: DEFAULT_OUTPUT,
35
32
  output_format: "png",
36
33
  };
37
34
 
@@ -134,6 +131,7 @@ async function runGenerate(args, dependencies) {
134
131
  requireOption(options, "baseUrl", "--base-url");
135
132
  requireOption(options, "token", "--token");
136
133
  requireOption(options, "prompt", "--prompt");
134
+ requireOption(options, "output", "--output");
137
135
 
138
136
  const input = {
139
137
  prompt: options.prompt,
package/src/image-api.js CHANGED
@@ -97,19 +97,50 @@ function commonOptions(input, outputFormat) {
97
97
  };
98
98
  }
99
99
 
100
- function resultFromResponseBody(body, outputFormat, model, mode) {
101
- const data = body?.data?.[0]?.b64_json;
100
+ async function resultFromResponseBody(body, outputFormat, model, mode, fetchImpl) {
101
+ const firstResult = body?.data?.[0];
102
102
 
103
- if (!data) {
104
- throw new Error("Image API response did not include base64 image data");
103
+ // Try b64_json first
104
+ const b64Data = firstResult?.b64_json;
105
+ if (b64Data) {
106
+ return {
107
+ data: b64Data,
108
+ mimeType: mimeTypeForOutputFormat(outputFormat),
109
+ model,
110
+ mode,
111
+ };
105
112
  }
106
113
 
107
- return {
108
- data,
109
- mimeType: mimeTypeForOutputFormat(outputFormat),
110
- model,
111
- mode,
114
+ // Try url - fetch image from URL and convert to base64
115
+ const url = firstResult?.url;
116
+ if (url) {
117
+ // Use globalThis.fetch for fetching image URLs (not fetchImpl which may have API-specific headers)
118
+ const response = await globalThis.fetch(url);
119
+ if (!response.ok) {
120
+ throw new Error(`Failed to fetch image from URL: ${response.status} ${response.statusText}`);
121
+ }
122
+ const arrayBuffer = await response.arrayBuffer();
123
+ const base64 = Buffer.from(arrayBuffer).toString('base64');
124
+ // Get correct mimeType from response Content-Type
125
+ const contentType = response.headers.get('content-type') || '';
126
+ const actualMimeType = contentType.startsWith('image/') ? contentType : mimeTypeForOutputFormat(outputFormat);
127
+ return {
128
+ data: base64,
129
+ mimeType: actualMimeType,
130
+ model,
131
+ mode,
132
+ };
133
+ }
134
+
135
+ // Debug: include response info in error message
136
+ const responseSummary = {
137
+ bodyKeys: Object.keys(body || {}),
138
+ dataType: typeof body?.data,
139
+ dataLength: body?.data?.length,
140
+ firstResultKeys: firstResult ? Object.keys(firstResult) : null,
141
+ bodyPreview: JSON.stringify(body).substring(0, 500)
112
142
  };
143
+ throw new Error(`Image API response did not include base64 image data or URL. Response summary: ${JSON.stringify(responseSummary)}`);
113
144
  }
114
145
 
115
146
  export async function generateImage(input, config = {}) {
@@ -125,6 +156,8 @@ export async function generateImage(input, config = {}) {
125
156
  const outputFormat = input.output_format || input.outputFormat || DEFAULT_OUTPUT_FORMAT;
126
157
  const options = commonOptions(input, outputFormat);
127
158
 
159
+ const signal = config.signal;
160
+
128
161
  if (!Array.isArray(images) || images.length === 0) {
129
162
  const { background, ...body } = options;
130
163
  if (background) {
@@ -138,6 +171,7 @@ export async function generateImage(input, config = {}) {
138
171
  "Content-Type": "application/json",
139
172
  },
140
173
  body: JSON.stringify(body),
174
+ signal,
141
175
  });
142
176
 
143
177
  if (!response.ok) {
@@ -145,7 +179,7 @@ export async function generateImage(input, config = {}) {
145
179
  throw new Error(`Image API request failed (${response.status} ${response.statusText}): ${message}`);
146
180
  }
147
181
 
148
- return resultFromResponseBody(await response.json(), outputFormat, options.model, "text");
182
+ return resultFromResponseBody(await response.json(), outputFormat, options.model, "text", fetchImpl);
149
183
  }
150
184
 
151
185
  const form = new FormData();
@@ -169,6 +203,7 @@ export async function generateImage(input, config = {}) {
169
203
  Authorization: `Bearer ${token}`,
170
204
  },
171
205
  body: form,
206
+ signal,
172
207
  });
173
208
 
174
209
  if (!response.ok) {
@@ -176,7 +211,7 @@ export async function generateImage(input, config = {}) {
176
211
  throw new Error(`Image API request failed (${response.status} ${response.statusText}): ${message}`);
177
212
  }
178
213
 
179
- return resultFromResponseBody(await response.json(), outputFormat, options.model, "edit");
214
+ return resultFromResponseBody(await response.json(), outputFormat, options.model, "edit", fetchImpl);
180
215
  }
181
216
 
182
217
  export const generateEditedImage = generateImage;
package/src/mcp-server.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { appendFileSync } from "node:fs";
2
+ import { writeFile } from "node:fs/promises";
2
3
 
3
4
  import { generateImage } from "./image-api.js";
4
5
 
@@ -12,12 +13,16 @@ const TOOL_DEFINITION = {
12
13
  "Generate one image in text-to-image mode from a prompt only, or in image-to-image mode from a prompt plus optional reference images.",
13
14
  inputSchema: {
14
15
  type: "object",
15
- required: ["prompt"],
16
+ required: ["prompt", "output"],
16
17
  properties: {
17
18
  prompt: {
18
19
  type: "string",
19
20
  description: "Prompt describing the single output image to generate.",
20
21
  },
22
+ output: {
23
+ type: "string",
24
+ description: "Required filesystem path where the generated image will be saved.",
25
+ },
21
26
  images: {
22
27
  type: "array",
23
28
  minItems: 0,
@@ -111,6 +116,15 @@ export function createServerConfig({ argv = process.argv, env = process.env } =
111
116
  config.debugLog = debugLog;
112
117
  }
113
118
 
119
+ const timeoutStr = valueAfterFlag(argv, "--timeout") || env.GPT_IMAGE_TIMEOUT;
120
+ if (timeoutStr) {
121
+ const timeoutMs = Number.parseInt(timeoutStr, 10);
122
+ if (Number.isNaN(timeoutMs) || timeoutMs <= 0) {
123
+ throw new Error(`Invalid timeout value: ${timeoutStr}`);
124
+ }
125
+ config.timeoutMs = timeoutMs;
126
+ }
127
+
114
128
  return config;
115
129
  }
116
130
 
@@ -302,15 +316,36 @@ async function handleToolCall(message, dependencies) {
302
316
  return failure(message.id, -32602, `Unknown tool: ${name || ""}`);
303
317
  }
304
318
 
319
+ const requestId = message.id;
320
+ const pendingRequests = dependencies.pendingRequests;
321
+ const timeoutMs = dependencies.config?.timeoutMs ?? 300000; // default 5 minutes
322
+ const controller = new AbortController();
323
+ const timeoutTimer = setTimeout(() => {
324
+ controller.abort(new Error(`Tool call timed out after ${timeoutMs}ms`));
325
+ }, timeoutMs);
326
+
327
+ if (pendingRequests && requestId !== undefined && requestId !== null) {
328
+ pendingRequests.set(requestId, { controller, timer: timeoutTimer });
329
+ }
330
+
305
331
  try {
332
+ const args = message.params?.arguments || {};
333
+ const outputPath = args.output;
334
+ if (typeof outputPath !== "string" || outputPath.trim() === "") {
335
+ throw new Error("output is required");
336
+ }
337
+
306
338
  const image = await generateImage(
307
- message.params?.arguments || {},
339
+ args,
308
340
  {
309
341
  ...dependencies.config,
310
342
  fetchImpl: dependencies.fetchImpl || globalThis.fetch,
343
+ signal: controller.signal,
311
344
  },
312
345
  );
313
346
 
347
+ await (dependencies.writeFile || writeFile)(outputPath, Buffer.from(image.data, "base64"));
348
+
314
349
  return success(message.id, {
315
350
  content: [
316
351
  {
@@ -326,15 +361,23 @@ async function handleToolCall(message, dependencies) {
326
361
  isError: false,
327
362
  });
328
363
  } catch (error) {
364
+ const isAborted = error instanceof Error && error.name === "AbortError";
329
365
  return success(message.id, {
330
366
  content: [
331
367
  {
332
368
  type: "text",
333
- text: error instanceof Error ? error.message : String(error),
369
+ text: isAborted
370
+ ? `Image generation was cancelled or timed out: ${error.message}`
371
+ : error instanceof Error ? error.message : String(error),
334
372
  },
335
373
  ],
336
374
  isError: true,
337
375
  });
376
+ } finally {
377
+ clearTimeout(timeoutTimer);
378
+ if (pendingRequests && requestId !== undefined && requestId !== null) {
379
+ pendingRequests.delete(requestId);
380
+ }
338
381
  }
339
382
  }
340
383
 
@@ -361,6 +404,19 @@ export async function handleJsonRpcMessage(message, dependencies = {}) {
361
404
  });
362
405
  case "notifications/initialized":
363
406
  return undefined;
407
+ case "notifications/cancelled": {
408
+ const pendingRequests = dependencies.pendingRequests;
409
+ if (pendingRequests) {
410
+ const requestId = message.params?.requestId;
411
+ const pending = pendingRequests.get(requestId);
412
+ if (pending) {
413
+ clearTimeout(pending.timer);
414
+ pending.controller.abort(new Error("Request cancelled by client"));
415
+ pendingRequests.delete(requestId);
416
+ }
417
+ }
418
+ return undefined;
419
+ }
364
420
  case "ping":
365
421
  return success(message.id, {});
366
422
  case "tools/list":
@@ -386,8 +442,11 @@ export async function runStdioServer({
386
442
  debug("server_started", {
387
443
  hasBaseUrl: Boolean(config.baseUrl),
388
444
  hasToken: Boolean(config.token),
445
+ timeoutMs: config.timeoutMs || 300000,
389
446
  });
390
447
 
448
+ const pendingRequests = new Map();
449
+
391
450
  try {
392
451
  for await (const { message, transport } of readFramedMessages(input, debug)) {
393
452
  debug("message_received", {
@@ -395,7 +454,7 @@ export async function runStdioServer({
395
454
  method: message?.method,
396
455
  transport,
397
456
  });
398
- const response = await handleJsonRpcMessage(message, { config });
457
+ const response = await handleJsonRpcMessage(message, { config, pendingRequests });
399
458
  if (response) {
400
459
  debug("message_sent", {
401
460
  id: response.id,