Skip to main content
Glama

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, deployed by cloudrun-mcp-deployment.

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

Quick start (local, token auth)

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:

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

Related MCP server: Imagen MCP Server

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:

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:

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 (.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

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

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.

Related MCP Connectors

Related MCP Servers