@falldownthesystem/imagen-mcp 0.1.1 → 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
@@ -1,11 +1,13 @@
1
1
  # imagen-mcp
2
2
 
3
- Minimal MCP server for OpenAI GPT Image 2.5. Two tools, two models.
3
+ Minimal MCP server for OpenAI GPT Image 2.6 and Krea 3. Generate and edit tools for each provider.
4
4
 
5
- | Alias | API model | Use |
5
+ | Provider | Tools | Models |
6
6
  |---|---|---|
7
- | `flare` (default) | `gpt-image-2.5-flare` | Fast, general purpose |
8
- | `sunburst` | `gpt-image-2.5-sunburst` | Slower, higher precision |
7
+ | OpenAI | `generate_image`, `edit_image` | `flare` (default, `gpt-image-2.6-flare`): fast, good layouts. `sunburst` (`gpt-image-2.6-sunburst`): more detail, more consistent across edits. |
8
+ | Krea | `krea_generate_image`, `krea_edit_image` | Krea 3: fast, instruction-following editor. Early-access beta. |
9
+
10
+ The server registers a provider's tools only when that provider's API key is set.
9
11
 
10
12
  ## Setup
11
13
 
@@ -22,7 +24,10 @@ npm install
22
24
  npm run build
23
25
  ```
24
26
 
25
- Set `OPENAI_API_KEY` in the environment, or put it in a `.env` file next to `package.json`.
27
+ Set one or both API keys in the environment, or put them in a `.env` file next to `package.json`:
28
+
29
+ - `OPENAI_API_KEY`: enables the OpenAI tools.
30
+ - `KREA_API_KEY`: enables the Krea tools. Create a token at [krea.ai/settings/api-tokens](https://www.krea.ai/settings/api-tokens). The Krea 3 API is in early access; only workspaces in the beta can call it. Request access at sales@krea.ai.
26
31
 
27
32
  Optional environment variables:
28
33
 
@@ -41,6 +46,7 @@ Claude Code (`.mcp.json`) or Claude Desktop, using the npm package:
41
46
  "args": ["-y", "@falldownthesystem/imagen-mcp"],
42
47
  "env": {
43
48
  "OPENAI_API_KEY": "sk-...",
49
+ "KREA_API_KEY": "...",
44
50
  "IMAGEN_OUTPUT_DIR": "C:/Users/me/Pictures/imagen"
45
51
  }
46
52
  }
@@ -53,29 +59,33 @@ Or with a local build, use `"command": "node"` and `"args": ["path/to/imagen-mcp
53
59
  Claude Code one-liner:
54
60
 
55
61
  ```sh
56
- claude mcp add imagen -e OPENAI_API_KEY=sk-... -- npx -y @falldownthesystem/imagen-mcp
62
+ claude mcp add imagen -e OPENAI_API_KEY=sk-... -e KREA_API_KEY=... -- npx -y @falldownthesystem/imagen-mcp
57
63
  ```
58
64
 
59
- ## Tools
65
+ ## OpenAI tools
60
66
 
61
67
  ### `generate_image`
62
68
 
63
- Text to image. Required: `prompt`.
69
+ Text to image, animated GIF, or SVG. Required: `prompt`.
64
70
 
65
71
  | Option | Values | Default | Notes |
66
72
  |---|---|---|---|
73
+ | `output_format` | `png`, `jpeg`, `webp`, `gif`, `svg` | `png` | Also sets the file extension. `gif` is an animation: describe the subject, the motion, and how it loops, and `size` sets each frame. `svg` is a static vector file (paths, shapes, colors); it needs `n` = 1. `gif` and `svg` reject `output_compression`. |
74
+ | `frame_count` | 2 to 16 | 9 | `gif` only. Frames, frame size, and `n` share one pixel budget, so use a small `size` for many frames. |
75
+ | `frame_duration_ms` | 20 to 2000 | 200 | `gif` only. Delay per frame, rounded to 10 ms. |
67
76
  | `moderation` | `auto`, `low` | `low` | OpenAI content filter strictness. `low` refuses fewer borderline prompts; OpenAI usage policies still apply. The API default is `auto`; this tool sends `low` unless set. |
68
77
 
69
78
  ### `edit_image`
70
79
 
71
- Reference images to image. Required: `prompt`, `images`. The model regenerates the whole picture guided by the references and the prompt. Without a mask, the prompt alone decides what changes.
80
+ Reference images to image. Required: `prompt`, `images`. The model regenerates the whole picture guided by the references and the prompt. Without a mask or markup, the prompt alone decides what changes. Output is `png`, `jpeg`, or `webp`; the edit endpoint does not make GIF or SVG.
72
81
 
73
82
  | Option | Values | Default | Notes |
74
83
  |---|---|---|---|
75
84
  | `images` | 1 to 16 file paths | required | `png`, `jpeg`, or `webp`. The first image is the main subject; extra images supply elements to combine, such as a logo or a style reference. Relative paths resolve against the server working directory. |
76
- | `mask` | file path | none | PNG with an alpha channel, same width and height as the first image. Fully transparent pixels mark the region the model may repaint; opaque pixels mark what to keep. Use for inpainting one area or filling an extended canvas. It is guidance, not a hard pixel lock. |
85
+ | `mask` | file path | none | PNG with an alpha channel, same width and height as the first image. Fully transparent pixels mark the region the model may repaint; opaque pixels mark what to keep. Use for inpainting one area or filling an extended canvas. It is guidance, not a hard pixel lock. Cannot be combined with `markup`. |
86
+ | `markup` | file path | none | Transparent PNG annotation layer, same width and height as the first image, at most 50 MB. Draw circles, arrows, or scribbles where the change goes, and say in the prompt what to change and to remove the marks. Applies to the first image; other images stay references. Cannot be combined with `mask`. |
77
87
 
78
- There is no `input_fidelity` option. The GPT Image 2.5 models always process reference images at high fidelity, and the API rejects the parameter. Faces, logos, text, and textures from the references are preserved by default.
88
+ There is no `input_fidelity` option. The GPT Image 2.6 models always process reference images at high fidelity, and the API rejects the parameter. Faces, logos, text, and textures from the references are preserved by default.
79
89
 
80
90
  ### Shared options
81
91
 
@@ -84,17 +94,63 @@ Only the options you set are sent to the API. The default column shows what appl
84
94
  | Option | Values | Default | Notes |
85
95
  |---|---|---|---|
86
96
  | `prompt` | text, up to 32000 chars | required | |
87
- | `model` | `flare`, `sunburst` | `flare` | `flare` (`gpt-image-2.5-flare`) is fast and general purpose. `sunburst` (`gpt-image-2.5-sunburst`) is slower with higher precision. Same token price. `IMAGEN_DEFAULT_MODEL` env changes the default. |
97
+ | `model` | `flare`, `sunburst` | `flare` | `flare` (`gpt-image-2.6-flare`) is fast and lays out posters and product shots well. `sunburst` (`gpt-image-2.6-sunburst`) gives more detail and keeps characters and products more consistent across edits at `medium` or `high` quality. Same token price. `IMAGEN_DEFAULT_MODEL` env changes the default. |
88
98
  | `size` | `auto`, `1024x1024`, `1536x1024`, `1024x1536`, `WIDTHxHEIGHT` | `auto` | Custom sizes: multiples of 16, ratio 1:3 to 3:1, long edge up to 3840. Overrides `aspect_ratio`. |
89
99
  | `aspect_ratio` | `1:1`, `3:2`, `2:3`, `4:3`, `3:4`, `16:9`, `9:16`, `21:9`, `9:21` | none | Combined with `long_edge` to compute a size. |
90
100
  | `long_edge` | 256 to 3840 | 1536 | Only used with `aspect_ratio`. |
91
101
  | `quality` | `low`, `medium`, `high`, `xhigh`, `max`, `auto` | `auto` | Higher tiers add detail and cost more output tokens. `auto` lets the model choose and may pick an expensive tier. Set it explicitly to control cost. |
102
+ | `complexity` | `low`, `medium`, `high` | `high` | How much the model plans the scene before it renders. Separate from `quality`, no extra fee. `low` is faster and suits focused edits such as a new background or sign. `high` helps with dense text, complex layouts, and many objects, but can add latency and unwanted changes. |
92
103
  | `background` | `transparent`, `opaque`, `auto` | `auto` | `transparent` gives an alpha background for stickers, logos, and cut-outs. Needs `png` or `webp`. |
93
- | `output_format` | `png`, `jpeg`, `webp` | `png` | Also sets the file extension. |
104
+ | `output_format` | `png`, `jpeg`, `webp` | `png` | Also sets the file extension. `generate_image` also takes `gif` and `svg`. |
94
105
  | `output_compression` | 0 to 100 | 100 | `jpeg` and `webp` only. Works like JPEG quality: lower is smaller with more artifacts. |
95
106
  | `n` | 1 to 10 | 1 | Number of separate variations generated from the same prompt in one call. Each costs its own output tokens. |
96
107
  | `output_dir` | directory path | `IMAGEN_OUTPUT_DIR` env, else `./output` | Relative to the server working directory. |
97
108
  | `file_name` | base name without extension | timestamp plus a slug of the prompt | With `n` greater than 1, a `-1`, `-2` suffix is added. |
98
- | `return_image` | boolean | `false` | Also return each image inline as MCP image content, so the client shows it and the model can inspect it. Adds about 1.4 MB of context per 1024x1024 png. The file is saved to disk either way. |
109
+ | `return_image` | boolean | `false` | Also return each image inline as MCP image content, so the client shows it and the model can inspect it. Adds about 1.4 MB of context per 1024x1024 png. SVG files are never returned inline. The file is saved to disk either way. |
99
110
 
100
111
  The result lists the saved file paths, the model, the size, and token usage.
112
+
113
+ ## Krea tools
114
+
115
+ Both tools call `POST /generate/image/krea/krea-3`, poll the job every 2 seconds, and download the result. A job that does not finish in 5 minutes, or a tool call that the client cancels, is cancelled on Krea. Failed and cancelled jobs are not billed.
116
+
117
+ ### `krea_generate_image`
118
+
119
+ Text to image. Required: `prompt`.
120
+
121
+ ### `krea_edit_image`
122
+
123
+ Reference images to image. Required: `prompt`, `images`. Write the prompt as an instruction: lead with the change, then add one sentence about what stays the same. With several references, name each one by what it shows, for example "Put the sneaker from the second image on the person in the first image."
124
+
125
+ | Option | Values | Default | Notes |
126
+ |---|---|---|---|
127
+ | `images` | 1 to 8 local paths or `https` URLs | required | Local files (`png`, `jpeg`, `webp`) are uploaded to Krea assets for the request and deleted after it. URLs are passed through. References over 4 MP are resized to 4 MP. With 5 or more references, each is resized to 1536 px on its long side, so send 4 or fewer for full detail. |
128
+ | `regional_edit` | boolean | `false` | `true` regenerates only the parts that must change and keeps the rest pixel-exact. Use it for local edits (recolor, add or remove an object, change text) and for chained edits on the latest output. Leave it off for global edits (restyle, relight, reframe, combine images into a new scene), which re-render the whole picture. |
129
+
130
+ There is no mask option. Krea 3 decides the edit region from the prompt.
131
+
132
+ ### Shared Krea options
133
+
134
+ Only the options you set are sent, except `thinking_mode` and `resolution`, which are always sent with their defaults.
135
+
136
+ | Option | Values | Default | Notes |
137
+ |---|---|---|---|
138
+ | `prompt` | text | required | |
139
+ | `thinking_mode` | `fast`, `high` | `fast` | How much Krea 3 plans before it generates. `high` uses a stronger reasoning engine for complex or multi-step edits and for combining several references. It is slower and costs $0.02 more. |
140
+ | `resolution` | `1K`, `2K` | `2K` | Output canvas size. 2K is the native resolution. |
141
+ | `aspect_ratio` | `1:1`, `4:5`, `3:4`, `2:3`, `9:16`, `5:4`, `4:3`, `3:2`, `16:9`, `21:9` | none | Omit for automatic framing. |
142
+ | `negative_prompt` | text, up to 1024 chars | none | What to avoid. |
143
+ | `seed` | integer | random | With `n` greater than 1, variation `i` uses `seed + i`. The same seed does not guarantee the same image, because the prompt expansion step varies between runs. |
144
+ | `n` | 1 to 4 | 1 | Each variation is a separate job, billed separately. If some jobs fail, the finished images are still saved and the failures are listed in `errors`. |
145
+ | `output_dir`, `file_name`, `return_image` | | | Same as the OpenAI tools. |
146
+
147
+ Pricing per image:
148
+
149
+ | Component | 1K output | 2K output |
150
+ |---|---|---|
151
+ | Base price | $0.03 | $0.06 |
152
+ | Each 1K reference image | +$0.02 | +$0.02 |
153
+ | Each 2K reference image | +$0.07 | +$0.07 |
154
+ | `high` thinking mode | +$0.02 | +$0.02 |
155
+
156
+ The result lists the saved file paths, the model, the resolution, the aspect ratio, the thinking mode, and the Krea job IDs.
package/dist/index.js CHANGED
@@ -1,11 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import fs from "node:fs/promises";
3
2
  import path from "node:path";
4
3
  import { McpServer } from "@modelcontextprotocol/server";
5
4
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
6
- import OpenAI, { toFile } from "openai";
7
- import * as z from "zod/v4";
8
- import { commonInput, resolveModel, resolveSize } from "./params.js";
5
+ import { registerKreaTools } from "./krea.js";
6
+ import { registerOpenAITools } from "./openai.js";
9
7
  // A .env next to the project is a convenience for local runs; MCP client configs normally pass env directly.
10
8
  try {
11
9
  process.loadEnvFile(path.join(import.meta.dirname, "..", ".env"));
@@ -13,109 +11,16 @@ try {
13
11
  catch {
14
12
  // No .env file present; rely on the process environment.
15
13
  }
16
- const client = new OpenAI();
17
- const server = new McpServer({ name: "imagen-mcp", version: "0.1.1" });
18
- const MIME = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" };
19
- const outputSchema = z.object({
20
- files: z.array(z.string()),
21
- model: z.string(),
22
- size: z.string(),
23
- usage: z
24
- .object({
25
- input_tokens: z.number(),
26
- output_tokens: z.number(),
27
- total_tokens: z.number(),
28
- input_tokens_details: z.object({ image_tokens: z.number(), text_tokens: z.number() }).optional(),
29
- output_tokens_details: z.object({ image_tokens: z.number(), text_tokens: z.number() }).optional(),
30
- })
31
- .optional(),
32
- });
33
- const annotations = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
34
- function slug(text) {
35
- return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "image";
14
+ const openaiKey = process.env.OPENAI_API_KEY;
15
+ const kreaKey = process.env.KREA_API_KEY;
16
+ if (!openaiKey && !kreaKey) {
17
+ console.error("imagen-mcp: set OPENAI_API_KEY, KREA_API_KEY, or both.");
18
+ process.exit(1);
36
19
  }
37
- function apiParams(input) {
38
- return {
39
- model: resolveModel(input.model),
40
- prompt: input.prompt,
41
- size: resolveSize(input),
42
- quality: input.quality,
43
- background: input.background,
44
- output_format: input.output_format,
45
- output_compression: input.output_compression,
46
- n: input.n,
47
- };
48
- }
49
- async function saveImages(result, input, model, size) {
50
- const images = result.data ?? [];
51
- if (images.length === 0)
52
- throw new Error("The API returned no images.");
53
- const ext = input.output_format ?? "png";
54
- const dir = path.resolve(input.output_dir ?? process.env.IMAGEN_OUTPUT_DIR ?? "output");
55
- await fs.mkdir(dir, { recursive: true });
56
- const base = input.file_name ?? `${new Date().toISOString().replace(/[:.]/g, "-")}-${slug(input.prompt)}`;
57
- const files = [];
58
- const content = [];
59
- for (const [i, image] of images.entries()) {
60
- if (!image.b64_json)
61
- throw new Error(`Image ${i + 1} has no b64_json payload.`);
62
- const file = path.join(dir, images.length > 1 ? `${base}-${i + 1}.${ext}` : `${base}.${ext}`);
63
- await fs.writeFile(file, Buffer.from(image.b64_json, "base64"));
64
- files.push(file);
65
- if (input.return_image)
66
- content.push({ type: "image", data: image.b64_json, mimeType: MIME[ext] });
67
- }
68
- const output = { files, model, size: size ?? "auto", usage: result.usage };
69
- content.unshift({ type: "text", text: JSON.stringify(output, null, 2) });
70
- return { content, structuredContent: output };
71
- }
72
- async function loadImage(file) {
73
- const abs = path.resolve(file);
74
- const ext = path.extname(abs).slice(1).toLowerCase();
75
- const type = MIME[ext === "jpg" ? "jpeg" : ext] ?? "application/octet-stream";
76
- return toFile(await fs.readFile(abs), path.basename(abs), { type });
77
- }
78
- server.registerTool("generate_image", {
79
- title: "Generate image",
80
- description: "Generate images from a text prompt with OpenAI GPT Image 2.5. Saves files to disk and returns paths and token usage.",
81
- inputSchema: z.object({
82
- ...commonInput,
83
- moderation: z
84
- .enum(["auto", "low"])
85
- .optional()
86
- .describe("OpenAI content filter. low refuses fewer borderline prompts. Default low."),
87
- }),
88
- outputSchema,
89
- annotations,
90
- }, async (input) => {
91
- const params = apiParams(input);
92
- const result = await client.images.generate({ ...params, moderation: input.moderation ?? "low" });
93
- return saveImages(result, input, params.model, params.size);
94
- });
95
- server.registerTool("edit_image", {
96
- title: "Edit image",
97
- description: "Edit, restyle, or combine 1-16 reference images with a text prompt using OpenAI GPT Image 2.5. The whole picture is regenerated; without a mask the prompt decides what changes. Reference details are always kept at high fidelity (no input_fidelity option). Saves files to disk and returns paths and token usage.",
98
- inputSchema: z.object({
99
- ...commonInput,
100
- images: z
101
- .array(z.string())
102
- .min(1)
103
- .max(16)
104
- .describe("Paths of 1-16 reference images (png/jpeg/webp). First = main subject; others = elements to combine. Relative to server cwd."),
105
- mask: z
106
- .string()
107
- .optional()
108
- .describe("Path of a PNG with alpha, same size as first image. Transparent pixels = region to repaint (guidance, not a hard lock). Default none."),
109
- }),
110
- outputSchema,
111
- annotations,
112
- }, async (input) => {
113
- const params = apiParams(input);
114
- const result = await client.images.edit({
115
- ...params,
116
- image: await Promise.all(input.images.map(loadImage)),
117
- mask: input.mask ? await loadImage(input.mask) : undefined,
118
- });
119
- return saveImages(result, input, params.model, params.size);
120
- });
20
+ const server = new McpServer({ name: "imagen-mcp", version: "0.3.0" });
21
+ // Tools are registered per configured provider so clients never see a tool that can only fail.
22
+ if (openaiKey)
23
+ registerOpenAITools(server);
24
+ if (kreaKey)
25
+ registerKreaTools(server, kreaKey);
121
26
  await server.connect(new StdioServerTransport());
package/dist/krea.js ADDED
@@ -0,0 +1,200 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { setTimeout as sleep } from "node:timers/promises";
4
+ import * as z from "zod/v4";
5
+ import { annotations, MIME, saveImages, outputInput } from "./output.js";
6
+ const API = "https://api.krea.ai";
7
+ const ENDPOINT = "/generate/image/krea/krea-3";
8
+ const POLL_INTERVAL_MS = 2000;
9
+ // Krea times out hosted jobs after 3 minutes; the extra margin covers queueing behind the concurrency limit.
10
+ const JOB_TIMEOUT_MS = 5 * 60_000;
11
+ const PENDING = new Set(["backlogged", "queued", "scheduled", "processing", "sampling", "intermediate-complete"]);
12
+ const ASPECT_RATIOS = ["1:1", "4:5", "3:4", "2:3", "9:16", "5:4", "4:3", "3:2", "16:9", "21:9"];
13
+ const kreaInput = {
14
+ prompt: z
15
+ .string()
16
+ .min(1)
17
+ .describe("Plain-language instruction. Lead with the change; end with one sentence on what to keep. Name multiple references by what they show."),
18
+ thinking_mode: z
19
+ .enum(["fast", "high"])
20
+ .optional()
21
+ .describe("Prompt planning before generation. high = stronger reasoning for complex or multi-reference edits, slower, +$0.02. Default fast."),
22
+ resolution: z.enum(["1K", "2K"]).optional().describe("Output canvas. 1K = $0.03, 2K = $0.06 base price. Default 2K."),
23
+ aspect_ratio: z.enum(ASPECT_RATIOS).optional().describe("Default none (automatic framing)."),
24
+ negative_prompt: z.string().max(1024).optional().describe("What to avoid. Default none."),
25
+ seed: z
26
+ .number()
27
+ .int()
28
+ .optional()
29
+ .describe("Fixed seed; with n > 1 each variation uses seed + index. Prompt expansion still varies between runs. Default random."),
30
+ n: z
31
+ .number()
32
+ .int()
33
+ .min(1)
34
+ .max(4)
35
+ .optional()
36
+ .describe("Number of variations; each is a separate paid job. Files get -1, -2 suffixes. Default 1."),
37
+ ...outputInput,
38
+ };
39
+ const outputSchema = z.object({
40
+ files: z.array(z.string()),
41
+ model: z.string(),
42
+ resolution: z.string(),
43
+ aspect_ratio: z.string(),
44
+ thinking_mode: z.string(),
45
+ job_ids: z.array(z.string()),
46
+ errors: z.array(z.string()).optional(),
47
+ });
48
+ function sniffExt(data, url) {
49
+ if (data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
50
+ return "png";
51
+ if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff)
52
+ return "jpeg";
53
+ if (data.toString("ascii", 0, 4) === "RIFF" && data.toString("ascii", 8, 12) === "WEBP")
54
+ return "webp";
55
+ // An unknown format is still a paid result, so keep it under whatever extension the URL carries.
56
+ return path.extname(new URL(url).pathname).slice(1).toLowerCase() || "img";
57
+ }
58
+ function jobError(job) {
59
+ const detail = job.error?.message ?? job.error?.code ?? job.result?.error;
60
+ return `Krea job ${job.job_id} ${job.status}${detail ? `: ${typeof detail === "string" ? detail : JSON.stringify(detail)}` : ""}`;
61
+ }
62
+ function createClient(apiKey) {
63
+ async function request(pathname, init = {}) {
64
+ const res = await fetch(`${API}${pathname}`, {
65
+ ...init,
66
+ headers: { Authorization: `Bearer ${apiKey}`, ...init.headers },
67
+ });
68
+ const text = await res.text();
69
+ if (!res.ok) {
70
+ let message = text;
71
+ try {
72
+ message = JSON.parse(text).error ?? text;
73
+ }
74
+ catch {
75
+ // Non-JSON error body; report it verbatim.
76
+ }
77
+ throw new Error(`Krea ${init.method ?? "GET"} ${pathname} failed (${res.status}): ${message}`);
78
+ }
79
+ return (text ? JSON.parse(text) : undefined);
80
+ }
81
+ async function upload(file, signal) {
82
+ const abs = path.resolve(file);
83
+ const ext = path.extname(abs).slice(1).toLowerCase();
84
+ const type = MIME[ext === "jpg" ? "jpeg" : ext] ?? "application/octet-stream";
85
+ const form = new FormData();
86
+ form.append("file", new Blob([await fs.readFile(abs)], { type }), path.basename(abs));
87
+ return request("/assets", { method: "POST", body: form, signal });
88
+ }
89
+ async function cancel(jobId) {
90
+ await request(`/jobs/${jobId}`, { method: "DELETE" }).catch(() => { });
91
+ }
92
+ async function run(body, signal) {
93
+ const created = await request(ENDPOINT, {
94
+ method: "POST",
95
+ headers: { "Content-Type": "application/json" },
96
+ body: JSON.stringify(body),
97
+ signal,
98
+ });
99
+ const deadline = Date.now() + JOB_TIMEOUT_MS;
100
+ let job = created;
101
+ try {
102
+ while (PENDING.has(job.status)) {
103
+ if (Date.now() > deadline)
104
+ throw new Error(`Krea job ${job.job_id} still ${job.status} after ${JOB_TIMEOUT_MS / 1000}s.`);
105
+ await sleep(POLL_INTERVAL_MS, undefined, { signal });
106
+ job = await request(`/jobs/${job.job_id}`, { signal });
107
+ }
108
+ }
109
+ catch (error) {
110
+ // Failed and cancelled jobs are not billed, so stop work the caller will never receive.
111
+ await cancel(created.job_id);
112
+ throw error;
113
+ }
114
+ if (job.status !== "completed")
115
+ throw new Error(jobError(job));
116
+ const urls = job.result?.urls ?? [];
117
+ if (urls.length === 0)
118
+ throw new Error(`Krea job ${job.job_id} completed without output URLs.`);
119
+ const images = await Promise.all(urls.map(async (url) => {
120
+ const res = await fetch(url, { signal });
121
+ if (!res.ok)
122
+ throw new Error(`Download of ${url} failed (${res.status}).`);
123
+ const data = Buffer.from(await res.arrayBuffer());
124
+ return { data, ext: sniffExt(data, url) };
125
+ }));
126
+ return { jobId: job.job_id, images };
127
+ }
128
+ async function generate(input, signal) {
129
+ const uploaded = [];
130
+ try {
131
+ const imageUrls = input.images
132
+ ? await Promise.all(input.images.map(async (ref) => {
133
+ if (/^https?:\/\//i.test(ref))
134
+ return ref;
135
+ const asset = await upload(ref, signal);
136
+ uploaded.push(asset.id);
137
+ return asset.image_url;
138
+ }))
139
+ : undefined;
140
+ const resolution = input.resolution ?? "2K";
141
+ const thinkingMode = input.thinking_mode ?? "fast";
142
+ const runs = await Promise.allSettled(Array.from({ length: input.n ?? 1 }, (_, i) => run({
143
+ prompt: input.prompt,
144
+ thinking_mode: thinkingMode,
145
+ resolution,
146
+ aspect_ratio: input.aspect_ratio,
147
+ negative_prompt: input.negative_prompt,
148
+ seed: input.seed === undefined ? undefined : input.seed + i,
149
+ image_urls: imageUrls,
150
+ regional_edit: input.regional_edit,
151
+ }, signal)));
152
+ const done = runs.flatMap((r) => (r.status === "fulfilled" ? [r.value] : []));
153
+ const errors = runs.flatMap((r) => (r.status === "rejected" ? [String(r.reason instanceof Error ? r.reason.message : r.reason)] : []));
154
+ // Completed variations are already billed, so save them even when siblings fail.
155
+ if (done.length === 0)
156
+ throw new Error(errors.join("\n"));
157
+ return saveImages(done.flatMap((d) => d.images), input, {
158
+ model: "krea-3",
159
+ resolution,
160
+ aspect_ratio: input.aspect_ratio ?? "auto",
161
+ thinking_mode: thinkingMode,
162
+ job_ids: done.map((d) => d.jobId),
163
+ ...(errors.length > 0 && { errors }),
164
+ });
165
+ }
166
+ finally {
167
+ // Reference uploads exist only for this request; keep them out of the user's Krea asset library.
168
+ await Promise.allSettled(uploaded.map((id) => request(`/assets/${id}`, { method: "DELETE" })));
169
+ }
170
+ }
171
+ return { generate };
172
+ }
173
+ export function registerKreaTools(server, apiKey) {
174
+ const krea = createClient(apiKey);
175
+ server.registerTool("krea_generate_image", {
176
+ title: "Generate image (Krea 3)",
177
+ description: "Generate images from a text prompt with Krea 3 (early-access beta). Saves files to disk and returns paths and Krea job IDs.",
178
+ inputSchema: z.object(kreaInput),
179
+ outputSchema,
180
+ annotations,
181
+ }, (input, ctx) => krea.generate(input, ctx.mcpReq.signal));
182
+ server.registerTool("krea_edit_image", {
183
+ title: "Edit image (Krea 3)",
184
+ description: "Edit or combine 1-8 reference images with a plain-language instruction using Krea 3 (early-access beta). Without regional_edit the whole picture is re-rendered. Saves files to disk and returns paths and Krea job IDs.",
185
+ inputSchema: z.object({
186
+ ...kreaInput,
187
+ images: z
188
+ .array(z.string())
189
+ .min(1)
190
+ .max(8)
191
+ .describe("1-8 reference images: local paths (png/jpeg/webp, relative to server cwd) or https URLs. Each costs +$0.02 (1K) or +$0.07 (2K). Over 4 refs are downscaled to 1536 px."),
192
+ regional_edit: z
193
+ .boolean()
194
+ .optional()
195
+ .describe("true = regenerate only what must change and keep the rest pixel-exact; use for local edits and chained edits. Omit for restyle, relight, reframe, or compositing. Default false."),
196
+ }),
197
+ outputSchema,
198
+ annotations,
199
+ }, (input, ctx) => krea.generate(input, ctx.mcpReq.signal));
200
+ }
package/dist/openai.js ADDED
@@ -0,0 +1,133 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import OpenAI, { toFile } from "openai";
4
+ import * as z from "zod/v4";
5
+ import { annotations, MIME, saveImages } from "./output.js";
6
+ import { commonInput, resolveModel, resolveSize } from "./params.js";
7
+ const outputSchema = z.object({
8
+ files: z.array(z.string()),
9
+ model: z.string(),
10
+ size: z.string(),
11
+ usage: z
12
+ .object({
13
+ input_tokens: z.number(),
14
+ output_tokens: z.number(),
15
+ total_tokens: z.number(),
16
+ input_tokens_details: z.object({ image_tokens: z.number(), text_tokens: z.number() }).optional(),
17
+ output_tokens_details: z.object({ image_tokens: z.number(), text_tokens: z.number() }).optional(),
18
+ })
19
+ .optional(),
20
+ });
21
+ const generateInput = {
22
+ ...commonInput,
23
+ output_format: z
24
+ .enum(["png", "jpeg", "webp", "gif", "svg"])
25
+ .optional()
26
+ .describe("Also the file extension. gif = animation (size is per frame). svg = static vector (n must be 1). gif/svg ignore output_compression. Default png."),
27
+ frame_count: z.number().int().min(2).max(16).optional().describe("gif only: frames per animation. Keep size small for many frames. Default 9."),
28
+ frame_duration_ms: z
29
+ .number()
30
+ .int()
31
+ .min(20)
32
+ .max(2000)
33
+ .optional()
34
+ .describe("gif only: delay per frame in ms, rounded to 10. Default 200."),
35
+ moderation: z
36
+ .enum(["auto", "low"])
37
+ .optional()
38
+ .describe("OpenAI content filter. low refuses fewer borderline prompts. Default low."),
39
+ };
40
+ function apiParams(input) {
41
+ return {
42
+ model: resolveModel(input.model),
43
+ prompt: input.prompt,
44
+ size: resolveSize(input),
45
+ quality: input.quality,
46
+ complexity: input.complexity,
47
+ background: input.background,
48
+ output_compression: input.output_compression,
49
+ n: input.n,
50
+ };
51
+ }
52
+ // The API rejects these combinations, so fail before spending a request on them.
53
+ function checkGenerateFormat(input) {
54
+ const format = input.output_format ?? "png";
55
+ if (format !== "gif" && (input.frame_count !== undefined || input.frame_duration_ms !== undefined)) {
56
+ throw new Error("frame_count and frame_duration_ms need output_format gif.");
57
+ }
58
+ if ((format === "gif" || format === "svg") && input.output_compression !== undefined) {
59
+ throw new Error(`output_compression is not supported with output_format ${format}.`);
60
+ }
61
+ if (format === "svg" && (input.n ?? 1) !== 1) {
62
+ throw new Error("output_format svg needs n = 1.");
63
+ }
64
+ }
65
+ function saveResult(result, input, ext, model, size) {
66
+ const images = (result.data ?? []).map((image, i) => {
67
+ if (!image.b64_json)
68
+ throw new Error(`Image ${i + 1} has no b64_json payload.`);
69
+ return { data: Buffer.from(image.b64_json, "base64"), ext };
70
+ });
71
+ return saveImages(images, input, { model, size: size ?? "auto", usage: result.usage });
72
+ }
73
+ async function loadImage(file) {
74
+ const abs = path.resolve(file);
75
+ const ext = path.extname(abs).slice(1).toLowerCase();
76
+ const type = MIME[ext === "jpg" ? "jpeg" : ext] ?? "application/octet-stream";
77
+ return toFile(await fs.readFile(abs), path.basename(abs), { type });
78
+ }
79
+ export function registerOpenAITools(server) {
80
+ const client = new OpenAI();
81
+ server.registerTool("generate_image", {
82
+ title: "Generate image",
83
+ description: "Generate images, animated GIFs, or SVG vector graphics from a text prompt with OpenAI GPT Image 2.6. For a GIF, describe the subject, the motion, and how it loops. Saves files to disk and returns paths and token usage.",
84
+ inputSchema: z.object(generateInput),
85
+ outputSchema,
86
+ annotations,
87
+ }, async (input) => {
88
+ checkGenerateFormat(input);
89
+ const params = apiParams(input);
90
+ const result = await client.images.generate({
91
+ ...params,
92
+ output_format: input.output_format,
93
+ frame_count: input.frame_count,
94
+ frame_duration_ms: input.frame_duration_ms,
95
+ moderation: input.moderation ?? "low",
96
+ });
97
+ return saveResult(result, input, input.output_format ?? "png", params.model, params.size);
98
+ });
99
+ server.registerTool("edit_image", {
100
+ title: "Edit image",
101
+ description: "Edit, restyle, or combine 1-16 reference images with a text prompt using OpenAI GPT Image 2.6. The whole picture is regenerated; without a mask or markup the prompt decides what changes. Reference details are always kept at high fidelity (no input_fidelity option). Output is png, jpeg, or webp only. Saves files to disk and returns paths and token usage.",
102
+ inputSchema: z.object({
103
+ ...commonInput,
104
+ images: z
105
+ .array(z.string())
106
+ .min(1)
107
+ .max(16)
108
+ .describe("Paths of 1-16 reference images (png/jpeg/webp). First = main subject; others = elements to combine. Relative to server cwd."),
109
+ mask: z
110
+ .string()
111
+ .optional()
112
+ .describe("Path of a PNG with alpha, same size as first image. Transparent pixels = region to repaint (guidance, not a hard lock). Not with markup. Default none."),
113
+ markup: z
114
+ .string()
115
+ .optional()
116
+ .describe("Path of a transparent PNG annotation layer, same size as first image, with circles, arrows, or scribbles that mark where to change. The prompt says what to change and to remove the marks. Max 50 MB. Not with mask. Default none."),
117
+ }),
118
+ outputSchema,
119
+ annotations,
120
+ }, async (input) => {
121
+ if (input.mask && input.markup)
122
+ throw new Error("Use mask or markup, not both.");
123
+ const params = apiParams(input);
124
+ const result = await client.images.edit({
125
+ ...params,
126
+ output_format: input.output_format,
127
+ image: await Promise.all(input.images.map(loadImage)),
128
+ mask: input.mask ? await loadImage(input.mask) : undefined,
129
+ markup: input.markup ? await loadImage(input.markup) : undefined,
130
+ });
131
+ return saveResult(result, input, input.output_format ?? "png", params.model, params.size);
132
+ });
133
+ }
package/dist/output.js ADDED
@@ -0,0 +1,37 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import * as z from "zod/v4";
4
+ // svg is left out on purpose: MCP image content is raster, so saved SVGs are returned as file paths only.
5
+ export const MIME = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif" };
6
+ export const outputInput = {
7
+ output_dir: z.string().optional().describe("Save directory. Default IMAGEN_OUTPUT_DIR env or ./output (relative to server cwd)."),
8
+ file_name: z.string().optional().describe("Base name without extension. Default timestamp + prompt slug."),
9
+ return_image: z
10
+ .boolean()
11
+ .optional()
12
+ .describe("Also return the image inline (base64, ~1.4 MB per 1024x1024 png) so it can be viewed. File is saved either way. Default false."),
13
+ };
14
+ export const annotations = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
15
+ function slug(text) {
16
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "image";
17
+ }
18
+ export async function saveImages(images, options, meta) {
19
+ if (images.length === 0)
20
+ throw new Error("The API returned no images.");
21
+ const dir = path.resolve(options.output_dir ?? process.env.IMAGEN_OUTPUT_DIR ?? "output");
22
+ await fs.mkdir(dir, { recursive: true });
23
+ const base = options.file_name ?? `${new Date().toISOString().replace(/[:.]/g, "-")}-${slug(options.prompt)}`;
24
+ const files = [];
25
+ const content = [];
26
+ for (const [i, image] of images.entries()) {
27
+ const file = path.join(dir, images.length > 1 ? `${base}-${i + 1}.${image.ext}` : `${base}.${image.ext}`);
28
+ await fs.writeFile(file, image.data);
29
+ files.push(file);
30
+ const mimeType = MIME[image.ext];
31
+ if (options.return_image && mimeType)
32
+ content.push({ type: "image", data: image.data.toString("base64"), mimeType });
33
+ }
34
+ const output = { files, ...meta };
35
+ content.unshift({ type: "text", text: JSON.stringify(output, null, 2) });
36
+ return { content, structuredContent: output };
37
+ }
package/dist/params.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import * as z from "zod/v4";
2
+ import { outputInput } from "./output.js";
2
3
  export const MODELS = {
3
- flare: "gpt-image-2.5-flare",
4
- sunburst: "gpt-image-2.5-sunburst",
4
+ flare: "gpt-image-2.6-flare",
5
+ sunburst: "gpt-image-2.6-sunburst",
5
6
  };
6
7
  const ASPECT_RATIOS = {
7
8
  "1:1": 1,
@@ -28,7 +29,7 @@ export const commonInput = {
28
29
  model: z
29
30
  .enum(["flare", "sunburst"])
30
31
  .optional()
31
- .describe("flare = fast, general purpose. sunburst = slower, higher precision. Same price. Default flare."),
32
+ .describe("flare = fast, good layouts. sunburst = more detail, more consistent across edits at medium/high quality. Same price. Default flare."),
32
33
  size: z
33
34
  .string()
34
35
  .regex(/^(auto|\d+x\d+)$/)
@@ -49,6 +50,10 @@ export const commonInput = {
49
50
  .enum(["low", "medium", "high", "xhigh", "max", "auto"])
50
51
  .optional()
51
52
  .describe("Higher = more detail, more output tokens. auto lets the model choose (may be costly). Default auto."),
53
+ complexity: z
54
+ .enum(["low", "medium", "high"])
55
+ .optional()
56
+ .describe("How much the model plans the scene before rendering; separate from quality, no extra fee. low = faster, suits focused edits. high = dense text, complex layouts, many objects. Default high."),
52
57
  background: z
53
58
  .enum(["transparent", "opaque", "auto"])
54
59
  .optional()
@@ -68,12 +73,7 @@ export const commonInput = {
68
73
  .max(10)
69
74
  .optional()
70
75
  .describe("Number of variations to generate; each costs output tokens. Files get -1, -2 suffixes. Default 1."),
71
- output_dir: z.string().optional().describe("Save directory. Default IMAGEN_OUTPUT_DIR env or ./output (relative to server cwd)."),
72
- file_name: z.string().optional().describe("Base name without extension. Default timestamp + prompt slug."),
73
- return_image: z
74
- .boolean()
75
- .optional()
76
- .describe("Also return the image inline (base64, ~1.4 MB per 1024x1024 png) so it can be viewed. File is saved either way. Default false."),
76
+ ...outputInput,
77
77
  };
78
78
  export function resolveModel(alias) {
79
79
  const fallback = process.env.IMAGEN_DEFAULT_MODEL;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@falldownthesystem/imagen-mcp",
3
- "version": "0.1.1",
4
- "description": "Minimal MCP server for OpenAI GPT Image 2.5 (flare and sunburst): generate and edit images",
3
+ "version": "0.3.0",
4
+ "description": "Minimal MCP server for OpenAI GPT Image 2.6 (flare and sunburst) and Krea 3: generate and edit images",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "imagen-mcp": "dist/index.js"
@@ -23,6 +23,7 @@
23
23
  "model-context-protocol",
24
24
  "openai",
25
25
  "gpt-image",
26
+ "krea",
26
27
  "image-generation"
27
28
  ],
28
29
  "author": "FallDownTheSystem",
@@ -36,12 +37,12 @@
36
37
  "access": "public"
37
38
  },
38
39
  "dependencies": {
39
- "@modelcontextprotocol/server": "^2.0.0",
40
- "openai": "^7.13.0",
41
- "zod": "^4.5.4"
40
+ "@modelcontextprotocol/server": "^2.3.1",
41
+ "openai": "^7.51.0",
42
+ "zod": "^4.6.7"
42
43
  },
43
44
  "devDependencies": {
44
- "@types/node": "^26.5.0",
45
+ "@types/node": "^26.6.5",
45
46
  "typescript": "^7.0.2"
46
47
  }
47
48
  }