@chrischall/gemini-mcp 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Google Gemini image generation and editing — generate and edit images via the Gemini API",
10
- "version": "1.5.1"
10
+ "version": "1.6.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Gemini Images",
16
16
  "source": "./",
17
17
  "description": "Generate and edit images with Google Gemini image models (Nano Banana / Nano Banana Pro) via natural language prompts",
18
- "version": "1.5.1",
18
+ "version": "1.6.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gemini-mcp",
3
3
  "displayName": "Gemini Images",
4
- "version": "1.5.1",
4
+ "version": "1.6.0",
5
5
  "description": "MCP server for Google Gemini image generation and editing — generate and edit images via the Gemini API",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/README.md CHANGED
@@ -86,28 +86,28 @@ progress), two guards make re-issuing safe and unnecessary:
86
86
  | `gemini_upload_file` | Upload an image (or video/audio) to the Gemini Files API once — from a `url`, `data_base64`, or a local `path` — and get a reusable `files/<id>` reference |
87
87
  | `gemini_list_files` | List the files currently uploaded under this API key, with MIME types and expiry times |
88
88
  | `gemini_delete_file` | Delete an uploaded file before its ~48h expiry (confirm-gated) |
89
- | `gemini_sign_media` | *(hosted connector only)* Mint a fresh signed URL for generated media from its `r2_key` — an expired link is not a dead end |
90
- | `gemini_get_upload_url` | *(hosted connector only)* Mint a short-lived signed PUT URL so a shell can upload a reference image with no auth header; the PUT returns an `r2_key` usable in `images_r2_keys`, `gemini_save_character`, or `gemini_upload_file` |
91
- | `gemini_save_character` / `gemini_list_characters` / `gemini_delete_character` | *(hosted connector only)* Persistent per-account character library: save a reference image + description under a name, then pass `characters: ["name"]` on generation tools. No expiry |
92
- | `gemini_save_style` / `gemini_list_styles` / `gemini_delete_style` | *(hosted connector only)* Persistent per-account style presets: a reusable prompt fragment (optionally with a reference image), applied by passing `style: "name"` on generation tools. No expiry |
89
+ | `gemini_sign_media` | *(hosted deployments only)* Mint a fresh signed URL for generated media from its `r2_key` — an expired link is not a dead end |
90
+ | `gemini_get_upload_url` | *(hosted deployments only)* Mint a short-lived signed PUT URL so a shell can upload a reference image with no auth header; the PUT returns an `r2_key` usable in `images_r2_keys`, `gemini_save_character`, or `gemini_upload_file` |
91
+ | `gemini_save_character` / `gemini_list_characters` / `gemini_delete_character` | *(hosted deployments only)* Persistent per-account character library: save a reference image + description under a name, then pass `characters: ["name"]` on generation tools. No expiry |
92
+ | `gemini_save_style` / `gemini_list_styles` / `gemini_delete_style` | *(hosted deployments only)* Persistent per-account style presets: a reusable prompt fragment (optionally with a reference image), applied by passing `style: "name"` on generation tools. No expiry |
93
93
 
94
94
  Generation tools also share three throughput/latency controls: `async: true` (return a `job_id`
95
95
  immediately), `max_wait_ms` (wait up to a budget, then hand back the `job_id` — fast results stay
96
96
  in-band, slow batches never trip the host timeout), and `idempotency_key` (a retry returns the
97
- recorded result instead of re-billing). On the hosted connector, a `gemini_image_set` result with
97
+ recorded result instead of re-billing). On a hosted deployment, a `gemini_image_set` result with
98
98
  more than one image also carries a **`bundle_url`** — one signed URL for a zip of every image in
99
99
  the set — and set links are signed for ~7 days instead of the default ~48h. (A set too large to
100
- zip safely in Worker memory skips the bundle and says so via `bundle_skipped`; the per-image
100
+ zip safely in memory skips the bundle and says so via `bundle_skipped`; the per-image
101
101
  links are unaffected.)
102
102
 
103
- ## Seeing your images (hosted connector)
103
+ ## Seeing your images (hosted)
104
104
 
105
- On the hosted connector there is no filesystem, so a generated image has to come back as
105
+ On a hosted deployment there is no filesystem, so a generated image has to come back as
106
106
  something you can *open*. It does: **every result includes a URL**, with no configuration.
107
107
 
108
108
  ```jsonc
109
109
  {
110
- "images": ["https://connector.example.com/media/gen/2026-07-29/ab12cd34-a-cat.png?exp=…&sig=…"],
110
+ "images": ["https://mcp.nullnet.app/b/<account>/gemini/gen/2026-07-29/ab12cd34-a-cat.png?exp=…&sig=…"],
111
111
  "media": [{ "url": "https://…", "r2_key": "gen/2026-07-29/ab12cd34-a-cat.png",
112
112
  "expires_at": "2026-07-31T12:00:00.000Z",
113
113
  "curl_hint": "curl -sS -o a-cat.png \"https://…\"" }]
@@ -118,10 +118,10 @@ Those links need no auth header — the signature is in the URL — so they work
118
118
  `curl`, and in a chat message. They expire (48h by default) and the objects behind them are
119
119
  swept on a retention schedule. The `r2_key` is the durable handle for that window:
120
120
 
121
- - **`gemini_sign_media`** (hosted connector only) mints a fresh signed URL from an `r2_key`,
121
+ - **`gemini_sign_media`** (hosted deployments only) mints a fresh signed URL from an `r2_key`,
122
122
  so an expired link never forces you to re-generate — and re-pay for — the image.
123
- - **`gemini_upload_file` with `r2_key`** turns media the connector generated into a Files API
124
- reference (the connector reads its own bucket — no HTTP round trip, no signature needed), so
123
+ - **`gemini_upload_file` with `r2_key`** turns media this server generated into a Files API
124
+ reference (the server reads it back with its own key — no signature for you to mint), so
125
125
  a generated image can become the reference image for the next generation in one cheap call.
126
126
  - Idempotent replays (`idempotency_key`) re-mint the URLs inside the recorded result before
127
127
  returning it, so a reused result never carries a dead link.
@@ -132,20 +132,14 @@ extract bytes back out of its own context to save them elsewhere. A generation c
132
132
  successfully and be invisible. A URL is the portable answer; `inline` remains available, but
133
133
  it is no longer the only way to receive media.
134
134
 
135
- ### Three ways to serve the bytes
135
+ ### How the bytes are served
136
136
 
137
- | | setup | URL shape |
138
- |---|---|---|
139
- | **Worker route** (default) | none — works out of the box | `https://<worker-host>/media/<key>?exp=&sig=` |
140
- | **r2.dev public bucket** | enable public access on the bucket, set `MEDIA_PUBLIC_BASE_URL` | `https://pub-….r2.dev/<key>` |
141
- | **Custom domain** (best) | attach a domain to the bucket in Cloudflare, set `MEDIA_PUBLIC_BASE_URL` | `https://media.example.com/<key>` |
142
-
143
- The built-in Worker route is the zero-config path: the connector streams the object out of R2
144
- itself. The other two are worth setting up if you want plain, non-expiring URLs or want media
145
- traffic served by R2 rather than by the Worker — r2.dev is easiest but rate-limited and fine
146
- for personal use; a custom domain is the best of the three.
137
+ The host stores each generated object and serves it back at a signed, expiring
138
+ URL — `https://<host>/b/<account>/gemini/<key>?exp=&sig=`. No setup, and no auth
139
+ header: the signature in the link is the authorization, so `curl` and a browser
140
+ both work.
147
141
 
148
- **Auth on `/media` is a signed, expiring URL rather than an unlisted key.** Random keys would
142
+ **Auth is a signed, expiring URL rather than an unlisted key.** Random keys would
149
143
  be simpler, but they never expire and never revoke: anything that ever logged or forwarded the
150
144
  link keeps working forever. A signature scopes access to one object with a deadline, and
151
145
  rotating `MEDIA_URL_SECRET` invalidates every outstanding link at once. The tradeoff is that
@@ -153,7 +147,7 @@ links are long and cannot be shortened by hand.
153
147
 
154
148
  ### For assistants relaying a result
155
149
 
156
- Show the user the URL. If your sandbox has network egress to the connector host, fetching it
150
+ Show the user the URL. If your sandbox has network egress to the host, fetching it
157
151
  and attaching the bytes as a file gives the nicest result; otherwise present the link itself.
158
152
  Whether a given client renders `![](url)` markdown inline varies by client — a bare URL is the
159
153
  safe form, and a markdown link is a reasonable enhancement where you know it renders.
@@ -164,7 +158,6 @@ safe form, and a markdown link is a reasonable enhancement where you know it ren
164
158
  |---|---|---|
165
159
  | `MEDIA_TTL_DAYS` | `7` | Objects older than this are deleted by a daily cron — generated media (`gen/`, legacy `media/`) and signed uploads (`up/`). The character/style library (`lib/`) is exempt: saved entries never expire |
166
160
  | `MEDIA_URL_SECRET` | generated | HMAC key for `/media` links; rotate to revoke all outstanding URLs |
167
- | `MEDIA_PUBLIC_BASE_URL` | unset | Serve from R2 directly instead of the Worker route |
168
161
 
169
162
  Signed-URL lifetime is clamped to `MEDIA_TTL_DAYS`, so a link never outlives the object it
170
163
  points at.
@@ -179,9 +172,9 @@ Every tool that takes a reference image — `gemini_image_generate`, `gemini_ima
179
172
  |---|---|---|
180
173
  | `images_url` (`master_images_url`) | the **server** downloads the https URL | none |
181
174
  | `images_file_uris` (`master_images_file_uris`) | a `files/<id>` reference, already uploaded | none |
182
- | `images_r2_keys` (`master_images_r2_keys`) | the connector reads its **own store** (hosted connector only) | none |
175
+ | `images_r2_keys` (`master_images_r2_keys`) | the server reads its **own store** (hosted deployments only) | none |
183
176
  | `images` | read off local disk (stdio builds only) | none |
184
- | `characters` / `style` | saved library entries, attached by name (hosted connector only) | none |
177
+ | `characters` / `style` | saved library entries, attached by name (hosted deployments only) | none |
185
178
  | `images_base64` | **through the tool-call JSON** | **~14k tokens per JPEG** |
186
179
 
187
180
  `images_base64` is the fallback of last resort. It costs roughly 14k tokens per modest photo,
@@ -223,31 +216,12 @@ On stdio builds, a local `images` path that gets referenced **more than once in
223
216
  uploaded to the Files API automatically (keyed on path + mtime + size), so repeated edits of
224
217
  the same photo stop re-sending the bytes. Editing the file invalidates the cached upload.
225
218
 
226
- ### `POST /upload` — raw bytes, no base64 at all (hosted connector)
227
-
228
- The hosted connector exposes an HTTP upload endpoint behind the **same OAuth token as
229
- `/mcp`**. This is the intended path for an agent with a shell: disk file → curl → `file_uri` →
230
- tool call, with the image never entering the conversation.
231
-
232
- ```bash
233
- curl -X POST https://connector.gemini.nullnet.app/upload \
234
- -H "Authorization: Bearer $ACCESS_TOKEN" \
235
- -H "Content-Type: image/jpeg" \
236
- --data-binary @photo.jpg
237
- # → {"file_uri":"files/abc123","mime_type":"image/jpeg","expires":"...","...":"..."}
238
- ```
239
-
240
- Then pass `"images_file_uris": ["files/abc123"]` to any image tool. The body is streamed
241
- straight to the Files API (nothing is buffered), so `Content-Length` is required — curl sets it
242
- automatically with `--data-binary @file`. Accepted content types are `image/*`, `video/*` and
243
- `audio/*`; an optional `?name=` or `X-Filename` header sets the display name.
244
-
245
- ### Signed upload URLs — no token at all (hosted connector)
219
+ ### Signed upload URLs — no token at all (hosted)
246
220
 
247
- `POST /upload` needs the MCP session's OAuth bearer token, which a sandboxed shell often does
248
- not have. `gemini_get_upload_url` closes that gap: the authenticated MCP session mints a
249
- short-lived (~10 min) signed **PUT** URL, and the shell uploads with zero auth headers — the
250
- signature in the URL is the authorization, mirroring how `/media` downloads work.
221
+ This is the intended path for an agent with a shell: disk file → curl → `r2_key` → tool call,
222
+ with the image never entering the conversation. `gemini_get_upload_url` mints a short-lived
223
+ (~10 min) signed **PUT** URL, and the shell uploads with zero auth headers — the signature in
224
+ the URL is the authorization, mirroring how the download links work.
251
225
 
252
226
  ```bash
253
227
  # 1. tool call: gemini_get_upload_url { filename: "photo.jpg", content_type: "image/jpeg" }
@@ -261,13 +235,13 @@ curl -sS -X PUT -H "Content-Type: image/jpeg" --data-binary @photo.jpg "$UPLOAD_
261
235
  The signature covers one tenant-scoped object key, the declared content type and the expiry;
262
236
  uploads are capped at 15MB, enforced while reading the stream. Only **raster** image types are
263
237
  accepted (jpeg/png/webp/gif/avif/heic/heif/bmp/tiff) — SVG is deliberately refused, because an
264
- SVG is a scriptable document and `/media` serves from the connector's own origin. The returned
238
+ SVG is a scriptable document and `/media` serves from the server's own origin. The returned
265
239
  `r2_key` is then usable three ways: directly as `images_r2_keys` on any generation tool (the
266
240
  server reads its own bucket — no bytes in the conversation), permanently via
267
241
  `gemini_save_character`, or as a ~48h Files API reference via `gemini_upload_file({ r2_key })`.
268
242
  Uploads themselves follow the media retention schedule (`up/` prefix, default 7 days).
269
243
 
270
- ### Character & style library (hosted connector)
244
+ ### Character & style library (hosted)
271
245
 
272
246
  Recurring subjects and styles can be saved once, per account, with **no expiry** (the retention
273
247
  cron deliberately skips the library's `lib/` prefix):
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Media, uploads and the character library, backed by mcp-host's shared blob
3
+ * store instead of a bucket binding of our own.
4
+ *
5
+ * This is what replaced the retired Cloudflare Worker connector. That Worker
6
+ * owned an R2 bucket and served `/media`, `/put` and `/upload` itself; hosted
7
+ * on mcp-host there is no Worker and no binding, but the platform offers the
8
+ * same shape over signed HTTP:
9
+ *
10
+ * PUT <base>/<key>?exp=&sig=
11
+ * GET <base>/<key>?exp=&sig=
12
+ * DELETE <base>/<key>?exp=&sig=
13
+ * GET <base>/?prefix=&cursor=&limit=&exp=&sig=
14
+ *
15
+ * where `<base>` is `MCP_BLOB_BASE_URL` (`https://host/b/<registrationId>`) and
16
+ * every signature is HMAC-SHA256 under `MCP_BLOB_SIGNING_KEY` — this
17
+ * registration's own derived key, which can only ever produce signatures valid
18
+ * under its own prefix.
19
+ *
20
+ * The existing seams do the rest: `createR2Sink` and `createR2Library` both
21
+ * take a STRUCTURAL bucket, so the adapter below is all that was needed —
22
+ * neither of them learns that storage moved.
23
+ *
24
+ * ## The signature covers the FULL key, not ours
25
+ *
26
+ * The gateway signs `<registrationId>/<key>`; we only ever think in `<key>`.
27
+ * The prefix is recovered from the base URL's own path (everything after
28
+ * `/b/`), so there is exactly one place that knows the difference and the rest
29
+ * of this repo keeps using relative keys.
30
+ *
31
+ * ## The four payload shapes must match the gateway byte for byte
32
+ *
33
+ * They also happen to be the shapes this MCP's own connector used, which is why
34
+ * media links minted before the migration still resolve. Do not "tidy" them.
35
+ */
36
+ const READ = (key, exp) => `${key}\n${exp}`;
37
+ const WRITE = (key, ct, exp) => `put\0${key}\0${ct}\0${exp}`;
38
+ /**
39
+ * A write the host's retention prune must never reclaim. `lib/` only: a saved
40
+ * character or style outlives every signed URL and every upload, which is why
41
+ * saving COPIES the image bytes rather than pointing at `gen/`. The retired
42
+ * connector's own cleanup skipped this prefix for the same reason.
43
+ */
44
+ const WRITE_PERMANENT = (key, ct, exp) => `putp\0${key}\0${ct}\0${exp}`;
45
+ const DELETE = (key, exp) => `del\0${key}\0${exp}`;
46
+ const LIST = (prefix, exp) => `list\0${prefix}\0${exp}`;
47
+ /** The library's key prefix — the one thing written to outlive retention. */
48
+ const PERMANENT_PREFIX = 'lib';
49
+ /** Default life of a signed link. The gateway refuses anything over 24h. */
50
+ const DEFAULT_TTL_MS = 60 * 60 * 1000;
51
+ function base64url(bytes) {
52
+ return Buffer.from(bytes).toString('base64url');
53
+ }
54
+ async function hmac(secret, payload) {
55
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
56
+ return base64url(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload)));
57
+ }
58
+ /**
59
+ * The key-space prefix out of `https://host/b/<registrationId>` — what the
60
+ * gateway folds into every signature. Whatever follows `/b/`, so this does not
61
+ * care how many segments the host uses.
62
+ */
63
+ export function prefixFromBaseUrl(baseUrl) {
64
+ const path = new URL(baseUrl).pathname.replace(/^\/+|\/+$/g, '');
65
+ const marker = path.startsWith('b/') ? path.slice(2) : path;
66
+ return marker;
67
+ }
68
+ /**
69
+ * One object store over the signed HTTP API, satisfying BOTH `MediaBucket`
70
+ * (put/get) and `LibraryBucket` (put/get/delete/list). They are structurally
71
+ * compatible, so one adapter serves both rather than two that can drift.
72
+ */
73
+ export function createBlobBucket(opts) {
74
+ const base = opts.baseUrl.replace(/\/+$/, '');
75
+ const prefix = prefixFromBaseUrl(base);
76
+ const doFetch = opts.fetchImpl ?? fetch;
77
+ const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
78
+ const now = opts.now ?? (() => Date.now());
79
+ const full = (key) => `${prefix}/${key}`;
80
+ const encode = (key) => key.split('/').map(encodeURIComponent).join('/');
81
+ async function signedUrl(key, payload, exp) {
82
+ const sig = await hmac(opts.signingKey, payload);
83
+ return `${base}/${encode(key)}?exp=${exp}&sig=${encodeURIComponent(sig)}`;
84
+ }
85
+ return {
86
+ /** Exposed so the media sink can mint read links with the same key. */
87
+ async signRead(key, expiresAtMs) {
88
+ return hmac(opts.signingKey, READ(full(key), expiresAtMs));
89
+ },
90
+ async signWrite(key, contentType, expiresAtMs) {
91
+ return hmac(opts.signingKey, WRITE(full(key), contentType, expiresAtMs));
92
+ },
93
+ async put(key, value, options) {
94
+ const contentType = options?.httpMetadata?.contentType ?? 'application/octet-stream';
95
+ const exp = now() + ttl;
96
+ // The library is permanent; generated media and uploads are not. The
97
+ // prefix IS the distinction, and it is ours to know — the host has no
98
+ // idea what `lib/` means, it only enforces what was signed.
99
+ const permanent = key.startsWith(`${PERMANENT_PREFIX}/`);
100
+ const payload = permanent
101
+ ? WRITE_PERMANENT(full(key), contentType, exp)
102
+ : WRITE(full(key), contentType, exp);
103
+ const url = (await signedUrl(key, payload, exp)) + (permanent ? '&retain=permanent' : '');
104
+ const body = typeof value === 'string' ? value : toArrayBuffer(value);
105
+ const res = await doFetch(url, { method: 'PUT', headers: { 'content-type': contentType }, body });
106
+ if (!res.ok)
107
+ throw new Error(`blob put failed (${res.status}) for ${key}`);
108
+ return undefined;
109
+ },
110
+ async get(key) {
111
+ const exp = now() + ttl;
112
+ const url = await signedUrl(key, READ(full(key), exp), exp);
113
+ const res = await doFetch(url);
114
+ // A missing object is `null`, not a throw: both callers treat absence as
115
+ // a normal outcome (a swept media object, an unknown character name).
116
+ if (res.status === 404)
117
+ return null;
118
+ if (!res.ok)
119
+ throw new Error(`blob get failed (${res.status}) for ${key}`);
120
+ const buf = await res.arrayBuffer();
121
+ return {
122
+ arrayBuffer: async () => buf,
123
+ httpMetadata: { contentType: res.headers.get('content-type') ?? undefined },
124
+ };
125
+ },
126
+ async delete(keys) {
127
+ for (const key of Array.isArray(keys) ? keys : [keys]) {
128
+ const exp = now() + ttl;
129
+ const url = await signedUrl(key, DELETE(full(key), exp), exp);
130
+ const res = await doFetch(url, { method: 'DELETE' });
131
+ // The gateway's delete is idempotent, so only a real failure throws.
132
+ if (!res.ok && res.status !== 404) {
133
+ throw new Error(`blob delete failed (${res.status}) for ${key}`);
134
+ }
135
+ }
136
+ },
137
+ async list(options) {
138
+ const rel = options.prefix ?? '';
139
+ const exp = now() + ttl;
140
+ const sig = await hmac(opts.signingKey, LIST(rel, exp));
141
+ const params = new URLSearchParams({ exp: String(exp), sig });
142
+ if (rel)
143
+ params.set('prefix', rel);
144
+ if (options.cursor)
145
+ params.set('cursor', options.cursor);
146
+ if (options.limit)
147
+ params.set('limit', String(options.limit));
148
+ const res = await doFetch(`${base}/?${params}`);
149
+ if (!res.ok)
150
+ throw new Error(`blob list failed (${res.status})`);
151
+ // Keys come back relative to this registration's space, which is exactly
152
+ // the key space the library already thinks in.
153
+ return (await res.json());
154
+ },
155
+ };
156
+ }
157
+ /** A standalone ArrayBuffer copy — `fetch` bodies will not take a view. */
158
+ function toArrayBuffer(value) {
159
+ if (value instanceof ArrayBuffer)
160
+ return value;
161
+ const view = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
162
+ return view.slice().buffer;
163
+ }
164
+ /**
165
+ * Synchronous on purpose: `client.ts` calls this while building its
166
+ * module-level singleton, and a top-level `await` there would put one in every
167
+ * consumer's module graph for something that is pure string work.
168
+ */
169
+ export function blobStoreFromEnv(env = process.env, overrides = {}) {
170
+ const baseUrl = overrides.baseUrl ?? env.MCP_BLOB_BASE_URL;
171
+ const signingKey = overrides.signingKey ?? env.MCP_BLOB_SIGNING_KEY;
172
+ if (!baseUrl || !signingKey)
173
+ return undefined;
174
+ const bucket = createBlobBucket({ ...overrides, baseUrl, signingKey });
175
+ return { bucket, baseUrl, signRead: bucket.signRead, signWrite: bucket.signWrite };
176
+ }