image-gen-mcp
by ramzpat
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`.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues