image2-mcp
by sailor20
README.md
# image2-mcp
[](https://github.com/sailor20/image2-mcp/actions/workflows/ci.yml)
[](LICENSE)
[](https://nodejs.org)
[](https://modelcontextprotocol.io)
An **MCP Server** that wraps an **OpenAI-compatible image generation API**, giving any MCP
client (Claude Desktop / Cursor / Cline / WorkBuddy …) native image-generation tools.
**[中文文档 →](README.zh-CN.md)**
Dependencies are deliberately minimal — only `@modelcontextprotocol/sdk` and `zod` at
runtime, using Node's built-in `fetch`, with an 80-line hand-rolled `.env` parser.
> **This repository is not affiliated with any API relay service.** No relay is bundled or
> assumed — you must point it at a service you are authorized to use via `IMAGE2_BASE_URL`.
---
## Table of Contents
- [1. What this server wraps](#1-what-this-server-wraps)
- [2. The 3 exposed tools](#2-the-3-exposed-tools)
- [3. Install & configure](#3-install--configure)
- [4. Usage](#4-usage)
- [5. Connecting to an MCP client](#5-connecting-to-an-mcp-client)
- [6. Environment variables](#6-environment-variables)
- [7. Development](#7-development)
- [8. Switching to another relay](#8-switching-to-another-relay)
- [9. Known upstream behaviors & pitfalls](#9-known-upstream-behaviors--pitfalls)
- [10. Project layout](#10-project-layout)
---
## 1. What this server wraps
An **OpenAI-compatible** image API — you supply the endpoint via `IMAGE2_BASE_URL`.
Its main job is turning the API response into a **real image file on disk** and handing back an
absolute path. That is the advantage over calling the API with raw `curl`: the client gets a
local file it can immediately read and display.
### 1.1 Endpoints used
All three are standard OpenAI shapes, so they are the same across compatible relays:
| Endpoint | Method | Content-Type | Purpose |
| --------------------------- | ------ | --------------------- | ------------------------------- |
| `{base}/images/generations` | POST | `application/json` | Text-to-image |
| `{base}/images/edits` | POST | `multipart/form-data` | Image edit / multi-image fusion |
| `{base}/models` | GET | — | Connectivity & model list probe |
### 1.2 Response shape
```json
{
"created": 1789630481,
"data": [{ "b64_json": "iVBORw0KGgoAAAANSUhEUg..." }],
"size": "1024x1024",
"quality": "high",
"output_format": "png",
"usage": { "output_tokens": 2058, "total_tokens": 2065 }
}
```
> ⚠️ **Two possible response shapes.** An entry in `data[]` carries **either** `b64_json`
> **or** a temporary `url` — which one you get depends on the request (in testing,
> `output_format: "jpeg"` produced a `url`, while the default returned `b64_json`).
> Both are handled: base64 is decoded locally, a `url` is downloaded (with retries).
### 1.3 Model names depend on your relay
**This server assumes no particular model names.** Which models a relay exposes is up to that
relay — the same capability may be named completely differently from one service to another.
**Find out what yours offers:**
```
check_image_service
```
It queries `{base}/models` and lists everything the relay advertises, highlighting the
image-related entries. Then pass whatever ID actually exists on your relay as the `model`
parameter.
> ⚠️ **The table below is an example, not a standard.** These are the model IDs that happened to
> work on the author's relay in 2026-09. Your relay will very likely use different names.
| Model ID (example) | Alias | Status | Notes |
| ------------------------ | ------------ | ------ | ------------------------ |
| `gpt-image-2` | `base` | ✅ OK | **Default** |
| `gpt-image-2-5-flare` | `flare` | ✅ OK | Variant, different style |
| `gpt-image-2-5-sunburst` | `sunburst` | ✅ OK | Variant, different style |
| `gpt-image-2-pro` | `pro` / `hq` | ✅ OK | Higher-detail variant |
**Which model gets used**
| Setting | Effect |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `model` parameter | Used for that one call. Any model ID works — aliases are optional. |
| `IMAGE2_DEFAULT_MODEL` | Used when `model` is omitted. Defaults to `gpt-image-2` — **change it if your relay doesn't serve that name.** |
| `IMAGE2_MODEL_ALIASES` | Define your own shorthands, e.g. `fast=my-turbo,cheap=my-lite`. Setting it replaces the built-in aliases entirely. |
The built-in aliases (`base` / `pro` / `hq` / `flare` / `sunburst`) are pure convenience and
carry no meaning outside the author's relay. You can ignore them and pass explicit model IDs,
or replace them via `IMAGE2_MODEL_ALIASES` — no code changes needed.
---
## 2. The 3 exposed tools
### `generate_image` — text-to-image
Generates an image from a description and writes it to disk, returning an absolute path.
| Parameter | Required | Description |
| --------------- | -------- | --------------------------------------------------------------------------------------- |
| `prompt` | ✅ | Image description; more detail is better |
| `model` | | A model ID your relay actually serves — see [1.3](#13-model-names-depend-on-your-relay) |
| `size` | | `1024x1024` / `1024x1536` / `1536x1024` / `auto` |
| `quality` | | `low` / `medium` / `high` / `auto` |
| `n` | | Count, 1–4 |
| `background` | | `opaque` / `transparent` / `auto` |
| `output_format` | | `png` / `jpeg` / `webp` |
| `out_dir` | | Output directory, default `./output` |
| `basename` | | Filename prefix |
### `edit_image` — image edit / img2img
Generates a new image from existing ones; accepts multiple reference images for fusion
and an optional mask for inpainting.
| Parameter | Required | Description |
| ------------- | -------- | ----------------------------------------------------------------- |
| `prompt` | ✅ | What to change, e.g. "replace the background with a snowy sunset" |
| `image_paths` | ✅ | Array of local input paths, 1–8 images |
| `mask_path` | | Optional mask (PNG; **transparent area** = region to repaint) |
| others | | Same as `generate_image` |
### `check_image_service` — connectivity self-check
Probes relay reachability, key validity, timeout and output-dir settings, and lists the
image models the server advertises. **Run this first when troubleshooting.**
---
## 3. Install & configure
### Requirements
- **Node.js ≥ 20** (uses built-in `fetch` / `FormData` / `Blob`; see `.nvmrc`)
- An OpenAI-compatible relay endpoint and API key
### Install
```bash
git clone https://github.com/sailor20/image2-mcp.git
cd image2-mcp
npm install
```
### Configure
```bash
cp .env.example .env
```
Then edit `.env` and fill in **both** required values:
```env
# Your relay's root URL — there is no built-in default
IMAGE2_BASE_URL=https://your-relay.example.com/v1
IMAGE2_API_KEY=sk-your-key
```
Alternatively pass them via your MCP client's `env` field
(see [section 5](#5-connecting-to-an-mcp-client)) so no `.env` is written.
If either value is missing, the tools return an error that names the missing variable — the
server does not guess an endpoint.
> `.env` is gitignored and will never be committed.
> **Never put a real key in any file that gets committed.**
---
## 4. Usage
### Just describe what you want
Once the tools are live you don't need to write JSON — natural language works:
```
Generate an image: a cyberpunk city at night, neon reflecting off rain-slicked streets
```
```
Make a 16:9 tech product poster for an AI coding assistant, dark background
```
For fine control, state the parameters inline:
```
Generate 4 forest illustrations in Ghibli style, square, high quality
```
### Switching models
Run `check_image_service` first if you don't know what your relay offers — model IDs are
relay-specific (see [1.3](#13-model-names-depend-on-your-relay)).
**A. Per call**
Just name the model, either as a raw ID or (if you defined one) an alias:
```
Use my-relay-turbo to generate 4 forest illustrations in Ghibli style, square
```
Aliases are entirely optional. Define your own in `.env` — no code changes needed:
```env
# 别名=模型ID,逗号分隔;设置后完全取代内置别名
IMAGE2_MODEL_ALIASES=fast=my-relay-turbo,cheap=my-relay-lite
```
The built-in aliases, for reference, are only meaningful on the author's relay:
| Alias | Resolves to |
| ------------ | ------------------------ |
| `base` | `gpt-image-2` |
| `pro` / `hq` | `gpt-image-2-pro` |
| `flare` | `gpt-image-2-5-flare` |
| `sunburst` | `gpt-image-2-5-sunburst` |
**B. Change the default (persistent)**
Set it to a model your relay actually serves:
```env
IMAGE2_DEFAULT_MODEL=my-relay-turbo
```
You must **restart the MCP connection** (toggle the server off/on, or restart the client).
**C. Set it in the MCP client's `env` (highest priority)**
```json
"env": {
"IMAGE2_DEFAULT_MODEL": "my-relay-turbo"
}
```
> Priority: **MCP client `env` > `.env` file.** `config.js` only fills from the file when
> the variable isn't already set. If editing `.env` seems to have no effect, check here first.
### Parameter reference
| Parameter | Values | Default | Notes |
| --------------- | ----------------------------------------------------------------------- | ----------- | ------------------------------------------------------ |
| `model` | `base` / `flare` / `sunburst` / `pro` or full ID | `base` | see table above |
| `size` | `1024x1024` / `1024x1536` (portrait) / `1536x1024` (landscape) / `auto` | `1024x1024` | server may adjust |
| `quality` | `low` / `medium` / `high` / `auto` | `high` | affects latency and tokens |
| `n` | 1–4 | 1 | more images = slower and pricier |
| `background` | `opaque` / `transparent` / `auto` | `opaque` | `transparent` yields transparent PNG |
| `output_format` | `png` / `jpeg` / `webp` | `png` | see [section 9](#9-known-upstream-behaviors--pitfalls) |
| `out_dir` | path | `./output` | relative paths resolve against the server root |
| `basename` | filename prefix | auto | handy for finding results later |
### Image editing
`edit_image` takes **local file paths**:
```
Change the wall in ~/pics/room.png to dark green, keep the furniture as-is
```
Multi-image fusion (1–8 references):
```
Using the style of ~/pics/style1.png and the subject of ~/pics/subject.png,
generate a new illustration
```
Inpainting requires a mask PNG (**transparent area = region to repaint**):
```
Use ~/pics/mask.png as the mask and replace the masked area with a cat
```
### Where output goes
By default into `output/` under the project, named like `{prompt-words}-{timestamp}.png`.
The returned **absolute path** can be read, displayed, or edited further. If a name is already
taken, a numeric suffix is appended (`name-2.png`, `name-3.png`, …) so nothing is overwritten.
The extension reflects the **actual file bytes** (see
[section 9](#9-known-upstream-behaviors--pitfalls), item 2). If the real format differs
from what you requested, the returned text says so explicitly.
---
## 5. Connecting to an MCP client
### 5.1 The config, in JSON
Merge this into your client's `mcpServers`:
```json
{
"mcpServers": {
"image2": {
"type": "stdio",
"command": "node",
"args": ["/absolute/path/to/image2-mcp/src/index.js"],
"env": {
"IMAGE2_BASE_URL": "https://your-relay.example.com/v1",
"IMAGE2_API_KEY": "sk-your-key",
"IMAGE2_OUTPUT_DIR": "/absolute/path/to/image2-mcp/output",
"IMAGE2_TIMEOUT_MS": "300000"
},
"timeout": 600000
}
}
}
```
Where the config lives:
| Client | Config file |
| -------------- | --------------------------------------------------- |
| Claude Desktop | `claude_desktop_config.json` |
| Cursor | `~/.cursor/mcp.json` |
| WorkBuddy | `~/.workbuddy/mcp.json` (note: **not** `.mcp.json`) |
| Others | see your client's MCP / stdio docs |
> **Windows:** prefer an absolute path to `node` for `command`, and use either
> double backslashes (`C:\\path\\to\\...`) or forward slashes (`C:/path/to/...`) in `args`.
### 5.2 Adding it from the WorkBuddy UI
The MCP page's **Add MCP server** dialog has three tabs — **Quick** / **Manual** / **JSON**
(快速 / 手动 / JSON). They all produce the same server entry; they differ only in how much you
type. Pick whichever suits you.
**Tab 1 — Quick (快速)**
One box: paste a command, a URL, or a JSON blob, and the transport is detected automatically.
For a local stdio server like this one, paste the command that starts it:
```
node /absolute/path/to/image2-mcp/src/index.js
```
That's the whole thing — **no environment variables needed in the dialog**, provided you have
already filled in `.env`. The server resolves `.env` relative to its own script location, not
the working directory, so it finds the file even when the client launches it from elsewhere.
Switch to another tab if you need to pass env vars through the dialog instead of `.env`.
**Tab 2 — Manual (手动)**
Fill in three fields, then expand **Advanced options** (高级选项) if you want to set
environment variables or a working directory.
| Field | Value |
| -------------------- | ------------------------------------------------ |
| Name (名称) | `image2-mcp` |
| Transport (传输方式) | `stdio` |
| Command (命令) | `node /absolute/path/to/image2-mcp/src/index.js` |
**Tab 3 — JSON**
Paste the configuration exactly as shown in [5.1](#51-the-config-in-json). This tab accepts
either form — with or without the outer `mcpServers` wrapper:
```json
{ "image2": { "type": "stdio", "command": "node", "args": ["..."] } }
```
```json
{ "mcpServers": { "image2": { "type": "stdio", "command": "node", "args": ["..."] } } }
```
**After adding:** the server appears in the MCP list. On clients that require it, trust or
enable it there before the tools become available.
> **Windows tip:** use forward slashes in paths (`D:/tools/image2-mcp/src/index.js`) or escape
> the backslashes (`D:\\tools\\...`). If `node` is not on the client's `PATH`, use the full path
> to the `node` executable as the command instead.
### 5.3 Three things to watch
1. **Raise the timeout.** A single image takes 40–180 s; most clients default to 60 s and
will cut it off. Increase **both** the client-side `timeout` and the server-side
`IMAGE2_TIMEOUT_MS` (e.g. 300000–600000).
2. **Config changes often don't hot-reload.** Restart the client, or trust/enable the new
server in the connector/MCP settings page.
3. **Never commit your key.** Prefer `.env` (gitignored), or the client's local config
file, which should itself stay out of version control.
---
## 6. Environment variables
| Variable | Default | Description |
| ------------------------ | ------------- | ---------------------------------------------------------------------------------------------------- |
| `IMAGE2_BASE_URL` | — | **Required.** Relay root, **without** `/images/...` |
| `IMAGE2_API_KEY` | — | **Required.** |
| `IMAGE2_OUTPUT_DIR` | `./output` | Output dir; relative paths resolve against server root |
| `IMAGE2_TIMEOUT_MS` | `300000` | Per-request timeout (ms); max 2147483647 |
| `IMAGE2_DEFAULT_MODEL` | `gpt-image-2` | Model used when `model` is omitted — **set this to a model your relay serves** |
| `IMAGE2_MODEL_ALIASES` | — | Your own shorthands, `alias=model-id,alias2=id2`. Replaces the built-in aliases |
| `IMAGE2_DEFAULT_SIZE` | `1024x1024` | Default size |
| `IMAGE2_DEFAULT_QUALITY` | `high` | Default quality |
| `IMAGE2_EDIT_FIELD` | `image` | multipart field name for `edit_image`, see [section 9](#9-known-upstream-behaviors--pitfalls) item 3 |
| `IMAGE2_ENV_FILE` | — | Path to an extra `.env` (useful for multi-environment setups) |
> **There is no built-in default relay.** `IMAGE2_BASE_URL` has no fallback, on purpose:
> the server should never silently target a third-party service you did not choose.
> If either required variable is missing, tool calls return an error naming exactly which ones.
**Load priority:** process env (i.e. the MCP client's `env`) > `IMAGE2_ENV_FILE` > project `.env`.
Variables already present in the environment are never overwritten by the file.
---
## 7. Development
### Everyday commands
| Command | What it does |
| ---------------------- | ---------------------------------------------------------- |
| `npm test` | Full test suite via Node's built-in test runner |
| `npm run lint` | ESLint over the repository |
| `npm run lint:fix` | ESLint with automatic fixes |
| `npm run format` | Format everything with Prettier |
| `npm run format:check` | Verify formatting without writing (what CI runs) |
| `npm run smoke` | Real end-to-end request — **costs credits**, not run in CI |
### Tests
The suite lives in `test/` and runs on the built-in
[`node:test`](https://nodejs.org/api/test.html) runner — no test framework dependency.
```bash
npm test # everything
node --test test/edge-cases.test.js # a single file
```
It is **fully offline**: it never contacts a real API and never spends credits.
`test/protocol.test.js` spawns the real server over stdio and checks the handshake, the tool
list, input validation, and the error paths for a missing API key and unreadable input files.
`test/edge-cases.test.js` covers format sniffing, filename sanitization, and atomic writes.
### Smoke test
Hits a live endpoint and generates one image, so it is run manually by maintainers:
```bash
npm run smoke # default prompt
node scripts/smoke.js a cat wearing sunglasses # custom prompt
node scripts/smoke.js --models # probe only, no generation
```
### Continuous integration
`.github/workflows/ci.yml` runs lint, format check, and tests on Node 20 and 22, plus one
Windows job (filename handling is platform-sensitive). It also runs `npm pack --dry-run` to
keep the published file list honest.
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
---
## 8. Switching to another relay
This implementation is **not tied to any specific relay**. As long as the target service is
OpenAI-compatible, just change `IMAGE2_BASE_URL` and `IMAGE2_API_KEY`. Four things need
attention:
1. **Model names** — there is no universal default, and every relay names its models
differently. Run `check_image_service` to list what yours offers, then set
`IMAGE2_DEFAULT_MODEL` to one of them (and add your own `IMAGE2_MODEL_ALIASES` if you want
shorthands). See [1.3](#13-model-names-depend-on-your-relay).
2. **`b64_json` vs `url`** — both are already handled in `persistImages()`.
3. **Support for `size` / `quality` / `output_format`** — unsupported params are either
ignored or rejected by the server; error messages are passed through verbatim.
4. **The `edits` field name** — the standard is `image` plus optional `mask`. If your relay
differs, just change the `IMAGE2_EDIT_FIELD` env var — no code changes needed.
---
## 9. Known upstream behaviors & pitfalls
All items below were **empirically verified**, not assumed.
### 1. `size` may be adjusted by the server
Request `size: 1024x1024` and you may get 1254×1254 back. Some upstream implementations apply
their own sizing policy; the `size` field in the response is the real value.
**This is expected, not a bug.**
### 2. `output_format: "jpeg"` may still return PNG bytes
Verified: when requesting `output_format: "jpeg"`, the response **echoes
`output_format: "jpeg"`**, but the decoded `data[].b64_json` bytes are **actually PNG**.
**How this server handles it:** the on-disk extension is derived from the **magic bytes**
(`sniffImageExt()`), ignoring both the request parameter and the response field. So you'll
get `photo.png`, not `photo.jpg`. This is intentional — it prevents producing files whose
extension contradicts their contents, which image hosts and CDNs with MIME sniffing reject.
Because of this, the tool result reports two separate fields rather than one ambiguous one:
| Field | Meaning |
| ------------------ | ---------------------------------------------------- |
| `format` | the format actually written to disk (from the bytes) |
| `requested_format` | what you asked for, or `null` if you didn't specify |
When the two differ, the human-readable part of the result also says so explicitly.
Every entry in `files[]` carries its own `format` as well.
### 3. `image` vs `image[]` route to different upstream backends
The relay is sensitive to the multipart field name:
| Field name | `data[0]` | `size` | `quality` |
| ---------- | ----------------------------- | ------------------ | --------- |
| `image` | `b64_json` + `revised_prompt` | `1254x1254` (real) | `auto` |
| `image[]` | **`url`** | `""` (empty) | `medium` |
So mixing the two field names makes 1-image and multi-image calls hit **different
implementations**, changing both output style and response shape.
**How this server handles it:** it uses **one field name for all images** (default `image`),
overridable via `IMAGE2_EDIT_FIELD`. If your relay requires `image[]`, set
`IMAGE2_EDIT_FIELD=image[]`.
### 4. Multi-image requests may omit `size` / `quality`
When passing multiple reference images, the upstream response may lack `size` and `quality`;
those fields will be `null` in the result.
### 5. `url` responses can be slow to connect, and the image is already paid for
When the response uses a `url`, the file lives on a CDN whose connection time varies widely —
in testing, between roughly 0.5 s and beyond 10 s. Node's `fetch` applies its **own 10-second
connect timeout** (`UND_ERR_CONNECT_TIMEOUT`), which fires _before_ `IMAGE2_TIMEOUT_MS` and
cannot be raised through that variable.
This matters because by the time the download starts, the image has **already been generated
and billed**. A single transient network hiccup would otherwise mean paying for an image and
receiving nothing.
**How this server handles it:** downloads are retried up to 3 times with a short backoff, and
4xx responses (a genuinely dead link) are not retried. Failure messages include the underlying
cause rather than a bare `fetch failed`, so the reason is visible when retries are exhausted.
---
## 10. Project layout
```
image2-mcp/
├── src/
│ ├── index.js # MCP server: tool definitions, validation, startup
│ ├── relay-client.js # HTTP client + format sniffing + safe writes
│ └── config.js # zero-dependency .env parser
├── test/
│ ├── protocol.test.js # spawns the server; handshake, tools, error paths
│ └── edge-cases.test.js # format sniffing, filenames, atomic writes
├── scripts/
│ └── smoke.js # manual end-to-end check (costs credits)
├── .github/workflows/ci.yml # lint + format check + tests + pack check
├── eslint.config.js # ESLint flat config
├── .prettierrc.json # formatting rules
├── .editorconfig # editor whitespace rules
├── .nvmrc # pinned Node major version
├── .env.example # config template (.env is gitignored)
└── output/ # generated images (gitignored)
```
---
## Contributing
Issues and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
For security matters, please follow [SECURITY.md](SECURITY.md) instead of opening a public issue.
## License
[MIT](LICENSE)