Image Studio MCP
by FloLey
README.md
# Image Studio MCP
An [MCP](https://modelcontextprotocol.io) server that lets Claude (or any MCP
client) **generate images** with Google's Gemini image models ("nano banana").
It is designed to be shared with a small group: anyone can connect with their
Google account, but only the email addresses in `IMG_ALLOWED_EMAILS` can
actually generate. It also ships a small web dashboard to browse what everyone
generated and track the estimated cost.
It is a standalone Python service: a Docker image, an HTTP MCP endpoint, and a
named volume for the generated images. Run it on any host that can sit behind a
TLS-terminating reverse proxy.
## What it does
A single MCP tool:
- `generate_image(prompt, aspect_ratio?, reference_images?, model?, image_size?)`
generates a PNG, stores it under the data volume with a random uuid name, and
returns a small inline preview plus the full-size URL
(`https://YOUR_DOMAIN/i/{uuid}.png`). Passing the filenames of previous
generations as `reference_images` lets you iterate ("same scene but at
night"); `model` picks flash or pro and `image_size` picks 1K/2K/4K.
Two models and three output sizes, chosen per request by use case (Google list
prices per generated image):
| Alias | Model id | 1K | 2K | 4K | Use for |
|---|---|---|---|---|---|
| `flash` | `gemini-3.1-flash-image-preview` (Nano Banana 2) | $0.067 | $0.101 | $0.151 | drafts, iterations, simple scenes |
| `pro` | `gemini-3-pro-image-preview` (Nano Banana Pro) | $0.134 | $0.134 | $0.24 | final renders, text in image, complex compositions |
The `model` parameter accepts the alias, the full id, or the doc-page name
(`gemini-3.1-flash-image` / `gemini-3-pro-image`). The tool contract tells the
model to only pass it when the human **explicitly asked** for a specific model;
otherwise the **per-user default** chosen in the `/ui` dashboard applies,
falling back to `flash`. `image_size` is `1K` (default), `2K`, or `4K`. Ids and
prices are env-overridable (`IMG_MODEL_FLASH`, `IMG_MODEL_PRO`,
`IMG_COST_{FLASH|PRO}_{1K|2K|4K}`), so a model rename or price change needs no
code change.
Public routes (not tools): `GET /health` for the container healthcheck, and
`GET /i/{name}` serving the generated PNGs (the unguessable uuid4 filename is
the access control, and Claude.ai needs to fetch the URL to show the image).
Every generation writes a sidecar JSON (`{uuid}.json`, never served publicly)
recording the caller's email, the prompt, the model and size used, the
timestamp, and the estimated cost. That feeds the dashboard.
## Web dashboard (`/ui`)
A browser-facing dashboard served by the same process at `https://YOUR_DOMAIN/ui`:
- a **cost per user** table (image count + estimated cost, with a total);
- a **gallery per user** (thumbnails, prompt, date, model, cost per image);
- a **"My default model" picker**: each user chooses the model used when a
generation does not specify one (stored in `prefs.json` in the data volume).
Auth: a browser Google login using the **same** OAuth client as the MCP
endpoint, restricted to the allow-listed emails, with a signed HttpOnly/Secure
session cookie. Emails in `IMG_ADMIN_EMAILS` see every user; other allow-listed
users only see their own images and cost. The dashboard is read-only. In local
dev (`IMG_AUTH_DISABLED=1`) the login is bypassed and everything is visible.
## Authentication: Google OAuth + email allow-list
The server uses FastMCP's `GoogleProvider` (an OAuth proxy that runs the OAuth
2.1 + PKCE discovery flow Claude.ai expects). On top of "any valid Google
login", `AllowedEmailsMiddleware` restricts tool calls to the emails in
`IMG_ALLOWED_EMAILS` (comma-separated, case-insensitive).
### Configuration (environment variables)
| Variable | Required | Purpose |
|---|---|---|
| `GEMINI_API_KEY` | yes | the image generation itself |
| `GOOGLE_OAUTH_CLIENT_ID` / `GOOGLE_OAUTH_CLIENT_SECRET` | yes (prod) | OAuth login; the server refuses to start without them unless auth is disabled |
| `IMG_PUBLIC_URL` | prod | the public base URL of the server, e.g. `https://YOUR_DOMAIN` |
| `IMG_JWT_SIGNING_KEY` | recommended | stable random value so issued tokens survive restarts (`openssl rand -hex 32`) |
| `IMG_ALLOWED_EMAILS` | yes (prod) | who may generate, e.g. `me@gmail.com,friend@gmail.com` |
| `IMG_ADMIN_EMAILS` | no | who sees every user in the dashboard (others see only their own) |
| `IMG_MODEL_FLASH` / `IMG_MODEL_PRO`, `IMG_COST_*` | no | override model ids / estimated prices |
| `IMG_ROOT` | no | where images are stored (default `/srv/images`) |
| `IMG_AUTH_DISABLED=1` | dev only | run open, no secrets needed |
Provide these through your deployment's environment (a `.env` file, container
secrets, an orchestrator, etc.). They are configuration, not committed to the
repo.
### Google OAuth client setup (one time)
In the [Google Cloud console](https://console.cloud.google.com/):
1. Create (or reuse) a project, then **APIs & Services -> OAuth consent
screen**: External, fill in the app name and contact email. The only scopes
used are `openid` and `userinfo.email` (non-sensitive, no verification
needed).
2. While the consent screen is in **Testing** mode, add yourself and your
friends as **test users**. This is a second gate on top of
`IMG_ALLOWED_EMAILS`; alternatively, publish the app.
3. **APIs & Services -> Credentials -> Create credentials -> OAuth client ID**,
type **Web application**, with **two** authorized redirect URIs (Google
matches them exactly, so both are needed):
- `https://YOUR_DOMAIN/auth/callback` (MCP / Claude.ai)
- `https://YOUR_DOMAIN/ui/auth/callback` (web dashboard)
## Architecture
- **Transport:** Streamable HTTP, MCP mounted at `/mcp`, listening on `:8766`.
- **Data:** generated images live under `IMG_ROOT` (default `/srv/images`),
intended to be a persistent volume so they survive restarts.
- **Public URL:** `https://YOUR_DOMAIN/mcp`, behind a reverse proxy that
terminates TLS (Caddy, nginx, Traefik, etc.).
```
image-mcp/
Dockerfile
pyproject.toml
src/image_mcp/
server.py # FastMCP app: auth, middleware, tool, /health, /i/{name}
access.py # email allow-list logic (pure, unit-tested)
storage.py # uuid-named PNG storage under IMG_ROOT (pure, unit-tested)
metadata.py # per-generation sidecar JSON + per-user totals (pure, unit-tested)
models.py # flash/pro registry: ids, prices, alias resolution (pure, unit-tested)
prefs.py # per-user default model in prefs.json (pure, unit-tested)
generate.py # the Gemini call + inline preview downscale
ui.py # /ui dashboard: Google browser login + galleries + costs + prefs
tests/
```
## Run locally
The fast way, with auth disabled (no OAuth needed; generation still needs a
real Gemini key):
```sh
docker build -t image-mcp .
docker run --rm -p 8766:8766 \
-e IMG_AUTH_DISABLED=1 \
-e GEMINI_API_KEY=your_key \
-v "$PWD/.localdata:/srv/images" \
image-mcp
```
Then:
```sh
curl -s localhost:8766/health # -> {"ok": true}
npx @modelcontextprotocol/inspector
# connect to: http://localhost:8766/mcp
# call generate_image with a prompt
```
## Tests
Pure-logic tests (allow-list, storage naming/path safety, model registry) need
only pytest, no API key or network. They run in CI on every push.
```sh
pip install pytest # or: pip install -e ".[dev]"
pytest -q
```
## Deployment
The image is built from the included `Dockerfile`. A GitHub Actions workflow
(`.github/workflows/ci.yml`) runs the tests and, on `main`, builds and pushes
the image to GHCR. From there, deploy the container however you like: any Docker
host works, as long as the MCP endpoint sits behind a reverse proxy that
terminates HTTPS at your public URL and the env vars above are supplied.
Two things to remember in production:
- mount a **persistent volume** at `IMG_ROOT` so generated images survive
restarts;
- point a DNS record for your domain at the host, and let the reverse proxy
handle the certificate.
## Connect Claude.ai
1. In Claude.ai: Settings -> Connectors -> Add custom connector.
2. URL: `https://YOUR_DOMAIN/mcp`.
3. Claude redirects to Google to log in and consent. Any Google account can log
in (if a test user, or once the app is published), but only allow-listed
emails can call the tool.
4. Others do the same from their own Claude accounts: send them the URL and make
sure their email is in `IMG_ALLOWED_EMAILS` (and in the OAuth test users while
the consent screen is in Testing mode).
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues