@chrischall/gemini-mcp 1.2.0 → 1.4.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.2.0"
10
+ "version": "1.4.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.2.0",
18
+ "version": "1.4.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.2.0",
4
+ "version": "1.4.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,6 +86,76 @@ 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
+
91
+ ## Seeing your images (hosted connector)
92
+
93
+ On the hosted connector there is no filesystem, so a generated image has to come back as
94
+ something you can *open*. It does: **every result includes a URL**, with no configuration.
95
+
96
+ ```jsonc
97
+ {
98
+ "images": ["https://connector.example.com/media/gen/2026-07-29/ab12cd34-a-cat.png?exp=…&sig=…"],
99
+ "media": [{ "url": "https://…", "r2_key": "gen/2026-07-29/ab12cd34-a-cat.png",
100
+ "expires_at": "2026-07-31T12:00:00.000Z",
101
+ "curl_hint": "curl -sS -o a-cat.png \"https://…\"" }]
102
+ }
103
+ ```
104
+
105
+ Those links need no auth header — the signature is in the URL — so they work in a browser, in
106
+ `curl`, and in a chat message. They expire (48h by default) and the objects behind them are
107
+ swept on a retention schedule. The `r2_key` is the durable handle for that window:
108
+
109
+ - **`gemini_sign_media`** (hosted connector only) mints a fresh signed URL from an `r2_key`,
110
+ so an expired link never forces you to re-generate — and re-pay for — the image.
111
+ - **`gemini_upload_file` with `r2_key`** turns media the connector generated into a Files API
112
+ reference (the connector reads its own bucket — no HTTP round trip, no signature needed), so
113
+ a generated image can become the reference image for the next generation in one cheap call.
114
+ - Idempotent replays (`idempotency_key`) re-mint the URLs inside the recorded result before
115
+ returning it, so a reused result never carries a dead link.
116
+
117
+ **Why this matters:** MCP's inline image content blocks (`inline: true`) are visible to the
118
+ *assistant* but many chat clients never render them to the user, and the assistant cannot
119
+ extract bytes back out of its own context to save them elsewhere. A generation could bill
120
+ successfully and be invisible. A URL is the portable answer; `inline` remains available, but
121
+ it is no longer the only way to receive media.
122
+
123
+ ### Three ways to serve the bytes
124
+
125
+ | | setup | URL shape |
126
+ |---|---|---|
127
+ | **Worker route** (default) | none — works out of the box | `https://<worker-host>/media/<key>?exp=&sig=` |
128
+ | **r2.dev public bucket** | enable public access on the bucket, set `MEDIA_PUBLIC_BASE_URL` | `https://pub-….r2.dev/<key>` |
129
+ | **Custom domain** (best) | attach a domain to the bucket in Cloudflare, set `MEDIA_PUBLIC_BASE_URL` | `https://media.example.com/<key>` |
130
+
131
+ The built-in Worker route is the zero-config path: the connector streams the object out of R2
132
+ itself. The other two are worth setting up if you want plain, non-expiring URLs or want media
133
+ traffic served by R2 rather than by the Worker — r2.dev is easiest but rate-limited and fine
134
+ for personal use; a custom domain is the best of the three.
135
+
136
+ **Auth on `/media` is a signed, expiring URL rather than an unlisted key.** Random keys would
137
+ be simpler, but they never expire and never revoke: anything that ever logged or forwarded the
138
+ link keeps working forever. A signature scopes access to one object with a deadline, and
139
+ rotating `MEDIA_URL_SECRET` invalidates every outstanding link at once. The tradeoff is that
140
+ links are long and cannot be shortened by hand.
141
+
142
+ ### For assistants relaying a result
143
+
144
+ Show the user the URL. If your sandbox has network egress to the connector host, fetching it
145
+ and attaching the bytes as a file gives the nicest result; otherwise present the link itself.
146
+ Whether a given client renders `![](url)` markdown inline varies by client — a bare URL is the
147
+ safe form, and a markdown link is a reasonable enhancement where you know it renders.
148
+
149
+ ### Retention
150
+
151
+ | Variable | Default | Effect |
152
+ |---|---|---|
153
+ | `MEDIA_TTL_DAYS` | `7` | Objects older than this are deleted by a daily cron (both the current `gen/` prefix and the legacy `media/` one) |
154
+ | `MEDIA_URL_SECRET` | generated | HMAC key for `/media` links; rotate to revoke all outstanding URLs |
155
+ | `MEDIA_PUBLIC_BASE_URL` | unset | Serve from R2 directly instead of the Worker route |
156
+
157
+ Signed-URL lifetime is clamped to `MEDIA_TTL_DAYS`, so a link never outlives the object it
158
+ points at.
89
159
 
90
160
  ## Sending reference images without burning context
91
161
 
package/dist/bundle.js CHANGED
@@ -32783,6 +32783,15 @@ var init_clipboard = __esm({
32783
32783
  }
32784
32784
  });
32785
32785
 
32786
+ // src/media-url.ts
32787
+ var MEDIA_URL_TTL_MS;
32788
+ var init_media_url = __esm({
32789
+ "src/media-url.ts"() {
32790
+ "use strict";
32791
+ MEDIA_URL_TTL_MS = 48 * 60 * 60 * 1e3;
32792
+ }
32793
+ });
32794
+
32786
32795
  // src/index.ts
32787
32796
  init_dist();
32788
32797
 
@@ -32796,7 +32805,7 @@ async function loadStdioDotenv() {
32796
32805
  }
32797
32806
 
32798
32807
  // src/version.ts
32799
- var VERSION = "1.2.0";
32808
+ var VERSION = "1.4.0";
32800
32809
 
32801
32810
  // src/tools/models.ts
32802
32811
  init_dist();
@@ -33214,13 +33223,21 @@ function createDiskSink() {
33214
33223
  const { writeMedia: writeMedia2, resolveOutputDir: resolveOutputDir2 } = await Promise.resolve().then(() => (init_images(), images_exports));
33215
33224
  const dir = resolveOutputDir2(opts.output_dir);
33216
33225
  const refs = [];
33217
- for (const it of items) refs.push(await writeMedia2(dir, it.base, it.base64, it.mimeType));
33226
+ for (const it of items) refs.push({ ref: await writeMedia2(dir, it.base, it.base64, it.mimeType) });
33218
33227
  return refs;
33219
33228
  },
33220
33229
  note: () => void 0
33221
33230
  };
33222
33231
  }
33223
33232
 
33233
+ // src/media-endpoint.ts
33234
+ init_media_url();
33235
+ function downloadFilename(key) {
33236
+ const last = key.split("/").pop() || "media";
33237
+ const stripped = last.replace(/^[0-9a-f]{6,12}-/i, "");
33238
+ return stripped || last;
33239
+ }
33240
+
33224
33241
  // src/tools/shared.ts
33225
33242
  var DEFAULT_SINK = createDiskSink();
33226
33243
  var ASPECT_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9", "1:4", "4:1", "1:8", "8:1"];
@@ -33346,14 +33363,32 @@ async function emitMedia(named, kind, opts, meta3, onWritten) {
33346
33363
  return { content: blocks };
33347
33364
  }
33348
33365
  const sink = opts.sink ?? DEFAULT_SINK;
33349
- const refs = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
33366
+ const persisted = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
33350
33367
  output_dir: opts.output_dir
33351
33368
  });
33369
+ const refs = persisted.map((p) => p.ref);
33352
33370
  if (sink.persistsFiles) await onWritten?.(refs);
33353
33371
  const note = opts.inline && kind === "video" ? { inline_unsupported: "video has no MCP inline content type \u2014 written to disk instead" } : {};
33354
33372
  const storageNote = sink.note();
33373
+ const unavailable = persisted.some((p) => p.unavailable) ? {
33374
+ media_url_unavailable: "The media was stored but no public URL could be generated for it, so the values above are bare object keys, not links. This is a connector misconfiguration: set MEDIA_PUBLIC_BASE_URL, or use inline: true to receive the bytes directly."
33375
+ } : {};
33376
+ const detailed = persisted.filter((p) => p.key !== void 0);
33377
+ const media = detailed.length === persisted.length && detailed.length > 0 ? {
33378
+ media: persisted.map((p) => ({
33379
+ url: p.ref,
33380
+ ...p.key ? { r2_key: p.key } : {},
33381
+ ...p.expiresAt ? { expires_at: p.expiresAt } : {},
33382
+ // A ready-to-run command, not a description of one. Models copy an
33383
+ // exact string far more reliably than they compose one — and in a
33384
+ // chat client this download-then-attach flow is the ONLY way the end
33385
+ // user actually sees the image (inline MCP blocks and markdown embeds
33386
+ // both render for the model and not for them).
33387
+ ...p.key ? { curl_hint: `curl -sS -o ${downloadFilename(p.key)} "${p.ref}"` } : {}
33388
+ }))
33389
+ } : {};
33355
33390
  const storage = sink.persistsFiles ? {} : { storage: sink.kind, storage_note: storageNote };
33356
- return textResult({ [`${kind}s`]: refs, ...note, ...storage, ...meta3 });
33391
+ return textResult({ [`${kind}s`]: refs, ...media, ...unavailable, ...note, ...storage, ...meta3 });
33357
33392
  }
33358
33393
  async function emit(named, opts, meta3, onWritten) {
33359
33394
  return emitMedia(named.map((n) => ({ media: n.image, base: n.base })), "image", opts, meta3, onWritten);
@@ -33384,6 +33419,47 @@ function isExpired(entry, now) {
33384
33419
  function fingerprintRequest(toolName, parts) {
33385
33420
  return createHash("sha256").update(`${toolName}\0${JSON.stringify(parts)}`).digest("hex");
33386
33421
  }
33422
+ async function refreshMedia(result, sink) {
33423
+ const first = result.content?.[0];
33424
+ if (!sink?.resign || !first || first.type !== "text") return result;
33425
+ let body;
33426
+ try {
33427
+ body = JSON.parse(first.text);
33428
+ } catch {
33429
+ return result;
33430
+ }
33431
+ const entries = Array.isArray(body.media) ? body.media : [];
33432
+ if (!entries.some((e) => typeof e.r2_key === "string")) return result;
33433
+ const refreshed = await Promise.all(entries.map(async (e) => {
33434
+ if (typeof e.r2_key !== "string") return void 0;
33435
+ try {
33436
+ return await sink.resign(e.r2_key);
33437
+ } catch {
33438
+ return void 0;
33439
+ }
33440
+ }));
33441
+ if (refreshed.every((r) => r === void 0)) return result;
33442
+ const mediaKeyName = ["images", "videos", "audios"].find((k) => Array.isArray(body[k]));
33443
+ const refs = [];
33444
+ refreshed.forEach((fresh, i) => {
33445
+ if (!fresh) {
33446
+ refs.push(String(entries[i]?.url ?? ""));
33447
+ return;
33448
+ }
33449
+ const { expires_at: _stale, ...rest } = entries[i];
33450
+ entries[i] = {
33451
+ ...rest,
33452
+ url: fresh.ref,
33453
+ ...fresh.expiresAt ? { expires_at: fresh.expiresAt } : {},
33454
+ curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? "media")} "${fresh.ref}"`
33455
+ };
33456
+ refs.push(fresh.ref);
33457
+ });
33458
+ body.media = entries;
33459
+ if (mediaKeyName) body[mediaKeyName] = refs;
33460
+ body.media_urls_refreshed = true;
33461
+ return { ...result, content: [{ ...first, text: JSON.stringify(body, null, 2) }, ...(result.content ?? []).slice(1)] };
33462
+ }
33387
33463
  function annotateReused(result, jobId) {
33388
33464
  const content = result.content ?? [];
33389
33465
  const first = content[0];
@@ -33411,6 +33487,12 @@ function jobHandle(jobId, status) {
33411
33487
  });
33412
33488
  }
33413
33489
  var JobRegistry = class {
33490
+ /**
33491
+ * Lets a replayed result have its media URLs re-minted. Set by the session
33492
+ * that owns this registry; absent on stdio, where refs are file paths that
33493
+ * never expire.
33494
+ */
33495
+ resigner;
33414
33496
  jobs = /* @__PURE__ */ new Map();
33415
33497
  byKey = /* @__PURE__ */ new Map();
33416
33498
  // idempotencyKey -> jobId
@@ -33470,7 +33552,7 @@ var JobRegistry = class {
33470
33552
  }
33471
33553
  if (hit) {
33472
33554
  if (async) return jobHandle(hit.jobId, hit.status);
33473
- return annotateReused(await hit.promise, hit.jobId);
33555
+ return annotateReused(await refreshMedia(await hit.promise, this.resigner), hit.jobId);
33474
33556
  }
33475
33557
  const jobId = randomUUID();
33476
33558
  this.evict(now);
@@ -33598,31 +33680,43 @@ function registerGenerateTools(server, client2) {
33598
33680
  const named = [];
33599
33681
  let capturedText;
33600
33682
  let capturedGrounding;
33683
+ const failed = [];
33684
+ let firstError;
33601
33685
  await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : "image"} (${model})`, async () => {
33602
33686
  for (let i = 0; i < count; i++) {
33603
- const result = await client2.generate({
33604
- prompt: args.prompt,
33605
- images: refInputs.length > 0 ? refInputs : void 0,
33606
- model: args.model,
33607
- aspectRatio: args.aspect_ratio,
33608
- imageSize: args.image_size,
33609
- seed: seed + i,
33610
- thinkingLevel: args.thinking_level,
33611
- googleSearch: args.google_search,
33612
- videoUrl: video.videoUrl,
33613
- videoMimeType: video.videoMimeType,
33614
- timeoutMs: args.timeout_ms
33615
- });
33616
- const { images: [img], text } = result;
33617
- if (text && !capturedText) capturedText = text;
33618
- if (result.grounding && !capturedGrounding) capturedGrounding = result.grounding;
33619
- named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, "0")}` });
33687
+ try {
33688
+ const result = await client2.generate({
33689
+ prompt: args.prompt,
33690
+ images: refInputs.length > 0 ? refInputs : void 0,
33691
+ model: args.model,
33692
+ aspectRatio: args.aspect_ratio,
33693
+ imageSize: args.image_size,
33694
+ seed: seed + i,
33695
+ thinkingLevel: args.thinking_level,
33696
+ googleSearch: args.google_search,
33697
+ videoUrl: video.videoUrl,
33698
+ videoMimeType: video.videoMimeType,
33699
+ timeoutMs: args.timeout_ms
33700
+ });
33701
+ const { images: [img], text } = result;
33702
+ if (text && !capturedText) capturedText = text;
33703
+ if (result.grounding && !capturedGrounding) capturedGrounding = result.grounding;
33704
+ named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, "0")}` });
33705
+ } catch (err) {
33706
+ firstError ??= err;
33707
+ failed.push({ image: i + 1, error: err instanceof Error ? err.message : String(err) });
33708
+ }
33620
33709
  }
33621
33710
  });
33711
+ if (named.length === 0) throw firstError;
33622
33712
  const meta3 = buildMeta(model, seed, args);
33623
33713
  if (capturedText) meta3.text = capturedText;
33624
33714
  if (capturedGrounding) meta3.grounding = capturedGrounding;
33625
33715
  if (report) meta3.image_inputs = report;
33716
+ if (failed.length) {
33717
+ meta3.failed_images = failed;
33718
+ meta3.partial = `${failed.length} of ${count} image(s) failed; the ${named.length} that succeeded are returned above. Re-run with count set to the shortfall rather than the whole batch \u2014 each failure message is in failed_images.`;
33719
+ }
33626
33720
  if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
33627
33721
  meta3.hint = REFINE_HINT;
33628
33722
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, count, persistsFiles: client2.mediaSink?.persistsFiles });
@@ -33756,6 +33850,7 @@ function registerSetTools(server, client2) {
33756
33850
  client2
33757
33851
  );
33758
33852
  const masterRefInputs = [...masterOnly.inputs, ...carried.inputs];
33853
+ const failed = [];
33759
33854
  const report = carried.report || masterOnly.report ? { ...masterOnly.report, ...carried.report } : void 0;
33760
33855
  const named = [];
33761
33856
  const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
@@ -33769,18 +33864,33 @@ function registerSetTools(server, client2) {
33769
33864
  named.push({ image: master, base: `${slug}-master` });
33770
33865
  const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
33771
33866
  const mode = args.reference_mode ?? "master";
33867
+ const sceneName = (i) => `${slug}-${String(i + 1).padStart(2, "0")}`;
33868
+ const record2 = (err, i) => {
33869
+ failed.push({
33870
+ scene: i + 1,
33871
+ prompt: scenePrompts[i],
33872
+ error: err instanceof Error ? err.message : String(err)
33873
+ });
33874
+ };
33772
33875
  if (mode === "chain") {
33773
33876
  let ref = master;
33774
33877
  for (let i = 0; i < scenePrompts.length; i++) {
33775
- const { images: [img] } = await client2.generate({ prompt: scenePrompts[i], images: [ref, ...carried.inputs], seed: seed + i + 1, ...cfg });
33776
- named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` });
33777
- ref = img;
33878
+ try {
33879
+ const { images: [img] } = await client2.generate({ prompt: scenePrompts[i], images: [ref, ...carried.inputs], seed: seed + i + 1, ...cfg });
33880
+ named.push({ image: img, base: sceneName(i) });
33881
+ ref = img;
33882
+ } catch (err) {
33883
+ record2(err, i);
33884
+ }
33778
33885
  }
33779
33886
  } else {
33780
- const scenes = await Promise.all(
33887
+ const settled = await Promise.allSettled(
33781
33888
  scenePrompts.map((p, i) => client2.generate({ prompt: p, images: [master, ...carried.inputs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
33782
33889
  );
33783
- scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` }));
33890
+ settled.forEach((outcome, i) => {
33891
+ if (outcome.status === "fulfilled") named.push({ image: outcome.value, base: sceneName(i) });
33892
+ else record2(outcome.reason, i);
33893
+ });
33784
33894
  }
33785
33895
  return result;
33786
33896
  });
@@ -33789,6 +33899,10 @@ function registerSetTools(server, client2) {
33789
33899
  if (masterText) meta3.text = masterText;
33790
33900
  if (masterResult.grounding) meta3.grounding = masterResult.grounding;
33791
33901
  if (report) meta3.image_inputs = report;
33902
+ if (failed.length) {
33903
+ meta3.failed_scenes = failed;
33904
+ meta3.partial = `${failed.length} of ${failed.length + named.length - 1} scene(s) failed; the master and the ${named.length - 1} scene(s) that succeeded are returned above. Re-run just the failed prompts rather than the whole set \u2014 each failure message is in failed_scenes.`;
33905
+ }
33792
33906
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client2.mediaSink?.persistsFiles });
33793
33907
  if (risk) meta3.timeout_risk = risk;
33794
33908
  return emit(named, { ...args, sink: client2.mediaSink }, meta3);
@@ -33958,6 +34072,7 @@ var GeminiClient = class {
33958
34072
  session = new SessionState();
33959
34073
  constructor(opts = {}) {
33960
34074
  this.mediaSink = opts.mediaSink ?? createDiskSink();
34075
+ this.session.jobs.resigner = this.mediaSink;
33961
34076
  this.explicitApiKey = opts.apiKey?.trim() || void 0;
33962
34077
  this.fetchImpl = opts.fetchImpl ?? fetch;
33963
34078
  this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
@@ -34027,6 +34142,22 @@ var GeminiClient = class {
34027
34142
  async fetchRemoteImage(url2, opts = {}) {
34028
34143
  return fetchRemoteImage(url2, { ...opts, fetchImpl: this.fetchImpl, timeoutMs: resolveTimeoutMs(opts.timeoutMs) });
34029
34144
  }
34145
+ /**
34146
+ * Read back media this connector generated, straight out of its own store.
34147
+ *
34148
+ * The alternative — fetching our own `/media` URL — needs a signature the
34149
+ * caller cannot mint and returns 403, which is the loop this exists to close.
34150
+ */
34151
+ async readStoredMedia(key) {
34152
+ const stored = await this.mediaSink.read?.(key.trim());
34153
+ if (!stored) {
34154
+ throw new McpToolError(
34155
+ `No stored media for r2_key "${key}" \u2014 objects are removed on a retention schedule, so an old key may be gone. Re-run the generation, or upload the bytes with \`url\` / \`data_base64\`.`,
34156
+ { hint: "Use the exact media[].r2_key from a generation result." }
34157
+ );
34158
+ }
34159
+ return stored;
34160
+ }
34030
34161
  /** The default model after env override (no per-call arg). */
34031
34162
  defaultModel() {
34032
34163
  return resolveModel(void 0, readEnvVar("GEMINI_IMAGE_MODEL"));
@@ -34508,7 +34639,7 @@ function splitReattachedOutputs(images, writtenOutputs) {
34508
34639
  }
34509
34640
  function registerInteractTools(server, client2) {
34510
34641
  const onDiskRuntime = client2.mediaSink?.persistsFiles ?? true;
34511
- const recoveryDescription = onDiskRuntime ? "If a call times out on the client side, the generation usually still completes: the image plus a `<image>.json` sidecar recording its interaction id land in the output dir, and `continue_last: true` still resumes that interaction \u2014 check the output dir before re-issuing (a re-issue is a second billable generation). If a chained call 404s, this tool re-anchors itself on the prior output image and re-issues un-chained: success is reported as `chain_recovered` (the chain was the problem, and you get your image anyway); a second 404 is reported as the interaction id NOT being the cause (check the model id / files uri instead). " : "This connector has no filesystem: there is no output dir and no `<image>.json` sidecar, so a lost response cannot be recovered from disk and a 404 on a chained call cannot be re-anchored automatically. Capture the returned `interaction_id` from every result, and use `async: true` + gemini_get_result for long generations. ";
34642
+ const recoveryDescription = onDiskRuntime ? "If a call times out on the client side, the generation usually still completes: the image plus a `<image>.json` sidecar recording its interaction id land in the output dir, and `continue_last: true` still resumes that interaction \u2014 check the output dir before re-issuing (a re-issue is a second billable generation). If a chained call 404s, this tool re-anchors itself on the prior output image and re-issues un-chained: success is reported as `chain_recovered` (the chain was the problem, and you get your image anyway); a second 404 is reported as the interaction id NOT being the cause (check the model id / files uri instead). " : "On this connector each result returns an image URL you can open or share directly (see `media[].url`) \u2014 show it to the user rather than assuming they can see the image. There is no filesystem, so no output dir and no `<image>.json` sidecar: a lost response cannot be recovered from disk and a 404 on a chained call cannot be re-anchored automatically. Capture the returned `interaction_id` from every result, and use `async: true` + gemini_get_result for long generations. ";
34512
34643
  server.registerTool(
34513
34644
  "gemini_interact",
34514
34645
  {
@@ -34882,13 +35013,16 @@ function registerFileTools(server, client2) {
34882
35013
  server.registerTool(
34883
35014
  "gemini_upload_file",
34884
35015
  {
34885
- description: "Upload an image (or video/audio) to the Gemini Files API ONCE and get back a reusable `files/<id>` reference. Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever enter the conversation, and it can be reused across many generations until it expires (~48h). Provide exactly one of `url` (the server downloads it), `data_base64`" + (onDisk ? ", or `path` (a local file)." : ".") + (onDisk ? "" : " This connector also accepts raw bytes over HTTP: POST them to /upload with the same Authorization header and a Content-Type of image/*, which avoids base64 entirely."),
35016
+ description: "Upload a file \u2014 an image, reference photo, picture, screenshot, video or audio clip \u2014 to the Gemini Files API ONCE, and get back a reusable `file_uri` (`files/<id>`) to attach to later image, video or music generations. Keywords: upload, upload file, upload image, upload photo, attach, reference image, reference photo, file_uri, files api, image reference, reuse across calls. Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever enter the conversation, and it can be reused across many generations until it expires (~48h). Provide exactly one of `url` (the server downloads it), `data_base64`" + (onDisk ? ", or `path` (a local file)." : ", or `r2_key` (re-upload media this connector generated, from media[].r2_key in an earlier result).") + (onDisk ? "" : " This connector also accepts raw bytes over HTTP: POST them to /upload with the same Authorization header and a Content-Type of image/*, which avoids base64 entirely."),
34886
35017
  annotations: { readOnlyHint: false, openWorldHint: true },
34887
35018
  inputSchema: {
34888
35019
  url: external_exports.string().url().optional().describe(
34889
35020
  `Public https URL the SERVER downloads and uploads (image/video/audio, up to ${wholeMb(urlMaxBytes)}MB). No bytes pass through the conversation.` + (onDisk ? "" : " Larger files: POST the raw bytes to /upload, which streams instead of buffering.")
34890
35021
  ),
34891
35022
  data_base64: external_exports.string().min(1).optional().describe("Raw base64 or a data: URI. Last resort \u2014 this is the one form that costs model context (~14k tokens for a modest JPEG)."),
35023
+ r2_key: external_exports.string().min(1).optional().describe(
35024
+ onDisk ? "Unavailable on this server (media is written to local disk) \u2014 pass the file path via `path` instead." : "An `r2_key` from a previous generation result (media[].r2_key) \u2014 re-uploads media THIS connector generated, so a generated image can become a reference image in one cheap call. The server reads its own bucket directly: no HTTP request, no signed URL, and no expiry to worry about. Do NOT pass a /media URL as `url` for this \u2014 that would be the connector fetching itself and needs a signature."
35025
+ ),
34892
35026
  path: external_exports.string().min(1).optional().describe(
34893
35027
  onDisk ? "Path to a local file (absolute, or resolved against $GEMINI_INPUT_DIR). Confirm-gated like every other local-file input." : "Unavailable on this hosted connector (no filesystem) \u2014 use `url`, `data_base64`, or POST /upload."
34894
35028
  ),
@@ -34898,10 +35032,10 @@ function registerFileTools(server, client2) {
34898
35032
  }
34899
35033
  },
34900
35034
  async (args, extra) => {
34901
- const sources = [args.url && "url", args.data_base64 && "data_base64", args.path && "path"].filter(Boolean);
35035
+ const sources = [args.url && "url", args.data_base64 && "data_base64", args.r2_key && "r2_key", args.path && "path"].filter(Boolean);
34902
35036
  if (sources.length !== 1) {
34903
35037
  throw new McpToolError(
34904
- sources.length === 0 ? "Provide exactly one of `url`, `data_base64`" + (onDisk ? " or `path`." : ".") : `Provide exactly one source, not ${sources.length} (${sources.join(", ")}).`
35038
+ sources.length === 0 ? "Provide exactly one of `url`, `data_base64`" + (onDisk ? " or `path`." : " or `r2_key`.") : `Provide exactly one source, not ${sources.length} (${sources.join(", ")}).`
34905
35039
  );
34906
35040
  }
34907
35041
  if (args.path) {
@@ -34930,6 +35064,19 @@ function registerFileTools(server, client2) {
34930
35064
  args.display_name ?? fileNameFromUrl(args.url, "upload")
34931
35065
  );
34932
35066
  }
35067
+ if (args.r2_key) {
35068
+ if (onDisk) {
35069
+ throw new McpToolError(
35070
+ "`r2_key` is only available on the hosted connector \u2014 this server writes media to local disk, so pass the generated file via `path` instead."
35071
+ );
35072
+ }
35073
+ const stored = await client2.readStoredMedia(args.r2_key);
35074
+ return client2.uploadBytes(
35075
+ stored.bytes,
35076
+ args.mime_type ?? stored.mimeType,
35077
+ args.display_name ?? downloadFilename(args.r2_key)
35078
+ );
35079
+ }
34933
35080
  if (args.data_base64) {
34934
35081
  const { decodeImageInput: decodeImageInput2 } = await Promise.resolve().then(() => (init_images(), images_exports));
34935
35082
  const decoded = decodeImageInput2(args.data_base64);
@@ -34952,7 +35099,7 @@ function registerFileTools(server, client2) {
34952
35099
  server.registerTool(
34953
35100
  "gemini_list_files",
34954
35101
  {
34955
- description: "List the files currently uploaded to the Gemini Files API under this API key, with their `files/<id>` references, MIME types and expiry times. Retention is ~48h, so an entry that has vanished has expired rather than failed.",
35102
+ description: "List files, images and photos currently uploaded to the Gemini Files API under this API key, with their reusable `file_uri` (`files/<id>`) references, MIME types and expiry times. Retention is ~48h, so an entry that has vanished has expired rather than failed.",
34956
35103
  annotations: { readOnlyHint: true, openWorldHint: true },
34957
35104
  inputSchema: {
34958
35105
  page_size: external_exports.number().int().positive().max(100).optional().describe("Maximum files to return (1-100, default 100)")
@@ -34976,10 +35123,44 @@ function registerFileTools(server, client2) {
34976
35123
  });
34977
35124
  }
34978
35125
  );
35126
+ if (!onDisk) {
35127
+ server.registerTool(
35128
+ "gemini_sign_media",
35129
+ {
35130
+ description: "Mint a FRESH signed URL for media this connector generated, from its `r2_key`. Signed media links expire well before the object behind them is cleaned up, so an expired link is not a dead end \u2014 re-sign it instead of re-generating (and re-paying for) the image. Keywords: expired link, refresh url, re-sign, media url, download again.",
35131
+ annotations: { readOnlyHint: true, openWorldHint: false },
35132
+ inputSchema: {
35133
+ r2_key: external_exports.string().min(1).describe("The `media[].r2_key` from an earlier generation result")
35134
+ }
35135
+ },
35136
+ async (args) => {
35137
+ const sink = client2.mediaSink;
35138
+ if (!sink?.resign) {
35139
+ throw new McpToolError("This server does not serve media URLs \u2014 generated files are written to disk and the path in the result is already usable.", {
35140
+ hint: "gemini_sign_media applies to the hosted connector only."
35141
+ });
35142
+ }
35143
+ const fresh = await sink.resign(args.r2_key.trim());
35144
+ if (!fresh) {
35145
+ throw new McpToolError(
35146
+ `No stored media for r2_key "${args.r2_key}" \u2014 it has probably passed its retention window. Re-run the generation to get a new one.`,
35147
+ { hint: "Objects are swept on a retention schedule (MEDIA_TTL_DAYS, default 7 days)." }
35148
+ );
35149
+ }
35150
+ return textResult({
35151
+ url: fresh.ref,
35152
+ r2_key: fresh.key,
35153
+ ...fresh.expiresAt ? { expires_at: fresh.expiresAt } : {},
35154
+ curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? "media")} "${fresh.ref}"`,
35155
+ hint: "In a Claude chat client, download this URL into your sandbox and present it as an output file \u2014 inline image blocks and markdown embeds do not render for the end user."
35156
+ });
35157
+ }
35158
+ );
35159
+ }
34979
35160
  server.registerTool(
34980
35161
  "gemini_delete_file",
34981
35162
  {
34982
- description: "Delete an uploaded file from the Gemini Files API before its ~48h expiry. Any tool call still referencing it will then fail with a generic 404, so delete only references you are finished with.",
35163
+ description: "Delete an uploaded file, image or photo (by file_uri) from the Gemini Files API before its ~48h expiry. Any tool call still referencing it will then fail with a generic 404, so delete only references you are finished with.",
34983
35164
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
34984
35165
  inputSchema: {
34985
35166
  file_uri: external_exports.string().min(1).describe("The `files/<id>` reference (or full uri) to delete"),
package/dist/client.js CHANGED
@@ -155,6 +155,9 @@ export class GeminiClient {
155
155
  session = new SessionState();
156
156
  constructor(opts = {}) {
157
157
  this.mediaSink = opts.mediaSink ?? createDiskSink();
158
+ // A replayed idempotent result must not hand back an expired media URL, so
159
+ // the job registry needs a way to re-sign from the stable r2_key.
160
+ this.session.jobs.resigner = this.mediaSink;
158
161
  this.explicitApiKey = opts.apiKey?.trim() || undefined;
159
162
  this.fetchImpl = opts.fetchImpl ?? fetch;
160
163
  this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
@@ -227,6 +230,21 @@ export class GeminiClient {
227
230
  // one unbounded network operation in the server.
228
231
  return fetchRemoteImage(url, { ...opts, fetchImpl: this.fetchImpl, timeoutMs: resolveTimeoutMs(opts.timeoutMs) });
229
232
  }
233
+ /**
234
+ * Read back media this connector generated, straight out of its own store.
235
+ *
236
+ * The alternative — fetching our own `/media` URL — needs a signature the
237
+ * caller cannot mint and returns 403, which is the loop this exists to close.
238
+ */
239
+ async readStoredMedia(key) {
240
+ const stored = await this.mediaSink.read?.(key.trim());
241
+ if (!stored) {
242
+ // Remediation goes in the MESSAGE: MCP serialization drops hints.
243
+ throw new McpToolError(`No stored media for r2_key "${key}" — objects are removed on a retention schedule, so an old key may be gone. ` +
244
+ 'Re-run the generation, or upload the bytes with `url` / `data_base64`.', { hint: 'Use the exact media[].r2_key from a generation result.' });
245
+ }
246
+ return stored;
247
+ }
230
248
  /** The default model after env override (no per-call arg). */
231
249
  defaultModel() {
232
250
  return resolveModel(undefined, readEnvVar('GEMINI_IMAGE_MODEL'));
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Tell an MCP session which hostname it was reached on.
3
+ *
4
+ * This is the single load-bearing link in the zero-config media-URL path: the
5
+ * origin captured here becomes `https://<host>/media/…` for every generated
6
+ * image. If it goes missing, generations silently stop coming back with
7
+ * openable links — the exact failure the media work exists to end — so it lives
8
+ * in its own module, free of any Cloudflare import, purely so it can be tested.
9
+ * (`src/worker.ts` pulls in `agents` and cannot load under Node at all.)
10
+ *
11
+ * Deriving the origin per request rather than configuring it is what makes a
12
+ * fork, a `*.workers.dev` preview and the custom domain all mint correct links
13
+ * with nothing to set. It rides on `ctx.props` — the object the OAuth provider
14
+ * has already populated with the decrypted grant — rather than a module-level
15
+ * variable, because one isolate serves many authenticated sessions (session.ts).
16
+ */
17
+ /** Property name the session reads back off its props. */
18
+ export const CONNECTOR_ORIGIN_PROP = 'connectorOrigin';
19
+ /**
20
+ * Wrap a handler so `ctx.props.connectorOrigin` is set before it runs.
21
+ *
22
+ * Existing props are preserved — they carry the user's API key, and dropping
23
+ * them would break authentication rather than merely lose a URL.
24
+ */
25
+ export function withConnectorOrigin(inner) {
26
+ return {
27
+ fetch(request, env, ctx) {
28
+ const carrier = (ctx ?? {});
29
+ const existing = (typeof carrier.props === 'object' && carrier.props !== null ? carrier.props : {});
30
+ carrier.props = { ...existing, [CONNECTOR_ORIGIN_PROP]: new URL(request.url).origin };
31
+ return inner.fetch(request, env, ctx);
32
+ },
33
+ };
34
+ }