Skip to main content
Glama
README.md
# image-gen-mcp

An MCP server that generates images with Google's Gemini native image models
("Nano Banana"), served over Streamable HTTP.

One tool, `generate_image`. No state anywhere, and no auth of its own —
in production it runs as a backend behind
[`mcp-oauth-proxy`](https://github.com/ramzpat/mcp-oauth-proxy), deployed by
[`cloudrun-mcp-deployment`](https://github.com/ramzpat/cloudrun-mcp-deployment).

`DESIGN.md` explains why it is built this way; this file explains how to run it.

## Quick start (local, token auth)

```sh
uv sync
export GEMINI_API_KEY="…"                       # from Google AI Studio
export IMAGE_MCP_TOKEN="$(openssl rand -base64 32)"
uv run python -m image_gen_mcp
```

Point a client at `http://127.0.0.1:8080/mcp` with
`Authorization: Bearer $IMAGE_MCP_TOKEN`. For Claude Code:

```sh
claude mcp add --transport http image-gen http://127.0.0.1:8080/mcp \
  --header "Authorization: Bearer $IMAGE_MCP_TOKEN"
```

## The tool

```
generate_image(prompt, aspect_ratio="1:1", image_size="1K", model=None)
```

| Parameter | Values |
|---|---|
| `prompt` | free text, up to `IMAGE_MCP_MAX_PROMPT` characters |
| `aspect_ratio` | `1:1` `16:9` `9:16` `4:3` `3:4` `3:2` `2:3` `21:9` `4:5` `5:4` |
| `image_size` | `1K` `2K` `4K` — 2K/4K need a capable model *and* a bucket |
| `model` | optional; must be in `IMAGE_MCP_ALLOWED_MODELS` |

Images at or under `IMAGE_MCP_INLINE_MAX_BYTES` (default 1.5 MB) come back
inline and render in the chat. Larger ones are uploaded to Cloud Storage and
returned as a signed URL. Structured output always reports the true pixel
dimensions, the model used, and which delivery route was taken.

### Model / resolution support

| Model | 1K | 2K | 4K |
|---|---|---|---|
| `gemini-2.5-flash-image` (default) | ✅ | — | — |
| `gemini-3-pro-image-preview` / `gemini-3-pro-image` | ✅ | ✅ | ✅ |
| `gemini-3.1-flash-lite-image` | ✅ | ✅ | ✅ |

Unsupported combinations are rejected at the tool boundary, in milliseconds,
with a message naming what *is* supported — rather than after a 30-second
round trip.

## Configuration

Everything comes from the environment. See `.env.example` for the annotated
list; the essentials:

| Variable | Required | Purpose |
|---|---|---|
| `GEMINI_API_KEY` | yes | Google AI Studio key |
| `IMAGE_MCP_TOKEN` | unless proxied | static bearer token |
| `IMAGE_MCP_MODEL` | | default model |
| `IMAGE_MCP_ALLOWED_MODELS` | | models a caller may select |
| `IMAGE_MCP_TRUST_PROXY_HEADERS` | behind proxy | take identity from `X-Auth-*` |
| `ALLOWED_EMAILS` | | optional narrowing of the proxy's allowlist |
| `IMAGE_MCP_GCS_BUCKET` | for 2K/4K | bucket for oversized images |

The server refuses to start rather than run misconfigured: no API key, and no
auth boundary at all (neither a static token nor proxy mode) are both startup
failures.

## Auth

Two shapes, and the server refuses to start in neither.

**Behind the proxy (production).** Set `IMAGE_MCP_TRUST_PROXY_HEADERS=1`. The
proxy authenticates the user against Google, enforces its allowlist, strips the
client's `Authorization` header, and forwards the identity as `X-Auth-Email` /
`X-Auth-Subject` / `X-Auth-Scope`. This server reads those and requires no token
of its own — a request without `X-Auth-Email` gets a 401.

Trusting headers is safe **only** because nothing else can reach the process:
in the Cloud Run multi-container layout the backend declares no ingress port,
so the only things that can open a socket to it are the proxy in the same
instance and the startup prober. Never enable this on a routable port.

`ALLOWED_EMAILS` is optional here and *narrows* the proxy's allowlist — useful
when the proxy admits a whole domain but image generation should cost money for
only a few people. Unset means "whoever the proxy admitted".

**Token (local, Claude Code).** Leave `IMAGE_MCP_TRUST_PROXY_HEADERS` unset and
set `IMAGE_MCP_TOKEN`. Callers send `Authorization: Bearer <token>`; the
`X-Auth-*` headers are ignored entirely, since without the proxy they are just
untrusted request data.

The server does not terminate OAuth and has no `/authorize`, `/token` or
`/register` endpoints. Setting `GOOGLE_OAUTH_CLIENT_ID` or `_SECRET` is a
startup error rather than a silent no-op — those belong on the proxy.

## Building the image

`.github/workflows/build.yml` runs the tests, builds the image, and publishes
it to GitHub Container Registry. **It does not deploy** — deployment is handled
by a separate workflow or repository.

| Event | Test | Build | Push |
|---|---|---|---|
| pull request | ✅ | ✅ | — |
| push to `main` | ✅ | ✅ | `latest`, `sha-<full-sha>` |
| tag `v*` | ✅ | ✅ | `1.2.3`, `1.2`, `sha-<full-sha>` |

Published as `ghcr.io/ramzpat/image-gen-mcp`. Nothing needs configuring: the
workflow authenticates with the built-in `GITHUB_TOKEN`.

### Consuming it from a deploy workflow

**Deploy by digest, not by tag.** A pull-through cache in front of a mutable
tag like `:latest` will happily serve a previous image; a digest cannot go
stale. Each run prints the digest to its job summary, and the workflow is
callable if you want to build and deploy in one pipeline:

```yaml
jobs:
  build:
    uses: ramzpat/image-gen-mcp/.github/workflows/build.yml@main
  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "deploying ${{ needs.build.outputs.image }}@${{ needs.build.outputs.digest }}"
```

From a separate repository, resolve the digest at deploy time instead:

```sh
DIGEST=$(docker buildx imagetools inspect \
  ghcr.io/ramzpat/image-gen-mcp:latest --format '{{.Manifest.Digest}}')
```

The GHCR package is private by default. A deploy job in another repository
needs either a PAT with `read:packages`, or the package set to public under
its GitHub package settings.

### Deployment

Deployed by
[`cloudrun-mcp-deployment`](https://github.com/ramzpat/cloudrun-mcp-deployment)
(`.github/workflows/deploy-image-gen-mcp.yml`), which runs this image as the
backend container of a Cloud Run multi-container service with `mcp-oauth-proxy`
in front. That repo owns the GCP project, region, allowlist, and secrets; this
one only publishes the image.

What that deployment sets on this container:

| | |
|---|---|
| `PORT` / `HOST` | `8000` / `0.0.0.0` (set by the shared deploy action) |
| `IMAGE_MCP_TRUST_PROXY_HEADERS` | `1` |
| `GEMINI_API_KEY` | from the service's GitHub Environment secret |
| entrypoint | `/app/.venv/bin/python -m image_gen_mcp` |

`HOST=0.0.0.0` rather than loopback is required, not a leak: Cloud Run's
startup prober runs outside the container's network namespace and cannot reach
a loopback-only socket. Only the container declaring `--port` (the proxy)
receives ingress, so the backend stays unreachable from outside the instance.

### Cloud Storage for 2K/4K

```sh
gcloud storage buckets create gs://BUCKET --uniform-bucket-level-access
gcloud storage buckets update gs://BUCKET \
  --lifecycle-file=<(echo '{"rule":[{"action":{"type":"Delete"},"condition":{"age":30}}]}')

# The runtime service account signs URLs through the IAM Credentials API,
# because it has no private key file. It needs this role *on itself*:
gcloud iam service-accounts add-iam-policy-binding RUNTIME_SA \
  --member="serviceAccount:RUNTIME_SA" --role=roles/iam.serviceAccountTokenCreator
gcloud storage buckets add-iam-policy-binding gs://BUCKET \
  --member="serviceAccount:RUNTIME_SA" --role=roles/storage.objectAdmin
```

Omitting the `serviceAccountTokenCreator` binding is the most common way signed
URLs ship broken.

## Tests

```sh
uv run pytest -q
```

57 tests: startup guards, identity resolution in both shapes (proxy headers,
static token, and each ignoring the other's credential), tool validation and
delivery, and end-to-end smoke tests driving a real MCP client over real HTTP
against uvicorn — in token mode and in the proxied production shape.

## Cost

Every allowlisted user draws on one API key. The controls, in order of how much
they actually help: `--max-instances`, `IMAGE_MCP_MAX_CONCURRENCY`, a GCP
billing budget alert, and `IMAGE_MCP_RATE_PER_HOUR`. The rate limit is counted
per instance, so its true ceiling is `IMAGE_MCP_RATE_PER_HOUR × --max-instances`.