@chrischall/gemini-mcp 1.3.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.3.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.3.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.3.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,7 @@ 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 |
89
90
 
90
91
  ## Seeing your images (hosted connector)
91
92
 
@@ -96,13 +97,22 @@ something you can *open*. It does: **every result includes a URL**, with no conf
96
97
  {
97
98
  "images": ["https://connector.example.com/media/gen/2026-07-29/ab12cd34-a-cat.png?exp=…&sig=…"],
98
99
  "media": [{ "url": "https://…", "r2_key": "gen/2026-07-29/ab12cd34-a-cat.png",
99
- "expires_at": "2026-07-31T12:00:00.000Z" }]
100
+ "expires_at": "2026-07-31T12:00:00.000Z",
101
+ "curl_hint": "curl -sS -o a-cat.png \"https://…\"" }]
100
102
  }
101
103
  ```
102
104
 
103
105
  Those links need no auth header — the signature is in the URL — so they work in a browser, in
104
106
  `curl`, and in a chat message. They expire (48h by default) and the objects behind them are
105
- swept on a retention schedule.
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.
106
116
 
107
117
  **Why this matters:** MCP's inline image content blocks (`inline: true`) are visible to the
108
118
  *assistant* but many chat clients never render them to the user, and the assistant cannot
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.3.0";
32808
+ var VERSION = "1.4.0";
32800
32809
 
32801
32810
  // src/tools/models.ts
32802
32811
  init_dist();
@@ -33221,6 +33230,14 @@ function createDiskSink() {
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"];
@@ -33361,7 +33378,13 @@ async function emitMedia(named, kind, opts, meta3, onWritten) {
33361
33378
  media: persisted.map((p) => ({
33362
33379
  url: p.ref,
33363
33380
  ...p.key ? { r2_key: p.key } : {},
33364
- ...p.expiresAt ? { expires_at: p.expiresAt } : {}
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}"` } : {}
33365
33388
  }))
33366
33389
  } : {};
33367
33390
  const storage = sink.persistsFiles ? {} : { storage: sink.kind, storage_note: storageNote };
@@ -33396,6 +33419,47 @@ function isExpired(entry, now) {
33396
33419
  function fingerprintRequest(toolName, parts) {
33397
33420
  return createHash("sha256").update(`${toolName}\0${JSON.stringify(parts)}`).digest("hex");
33398
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
+ }
33399
33463
  function annotateReused(result, jobId) {
33400
33464
  const content = result.content ?? [];
33401
33465
  const first = content[0];
@@ -33423,6 +33487,12 @@ function jobHandle(jobId, status) {
33423
33487
  });
33424
33488
  }
33425
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;
33426
33496
  jobs = /* @__PURE__ */ new Map();
33427
33497
  byKey = /* @__PURE__ */ new Map();
33428
33498
  // idempotencyKey -> jobId
@@ -33482,7 +33552,7 @@ var JobRegistry = class {
33482
33552
  }
33483
33553
  if (hit) {
33484
33554
  if (async) return jobHandle(hit.jobId, hit.status);
33485
- return annotateReused(await hit.promise, hit.jobId);
33555
+ return annotateReused(await refreshMedia(await hit.promise, this.resigner), hit.jobId);
33486
33556
  }
33487
33557
  const jobId = randomUUID();
33488
33558
  this.evict(now);
@@ -33610,31 +33680,43 @@ function registerGenerateTools(server, client2) {
33610
33680
  const named = [];
33611
33681
  let capturedText;
33612
33682
  let capturedGrounding;
33683
+ const failed = [];
33684
+ let firstError;
33613
33685
  await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : "image"} (${model})`, async () => {
33614
33686
  for (let i = 0; i < count; i++) {
33615
- const result = await client2.generate({
33616
- prompt: args.prompt,
33617
- images: refInputs.length > 0 ? refInputs : void 0,
33618
- model: args.model,
33619
- aspectRatio: args.aspect_ratio,
33620
- imageSize: args.image_size,
33621
- seed: seed + i,
33622
- thinkingLevel: args.thinking_level,
33623
- googleSearch: args.google_search,
33624
- videoUrl: video.videoUrl,
33625
- videoMimeType: video.videoMimeType,
33626
- timeoutMs: args.timeout_ms
33627
- });
33628
- const { images: [img], text } = result;
33629
- if (text && !capturedText) capturedText = text;
33630
- if (result.grounding && !capturedGrounding) capturedGrounding = result.grounding;
33631
- 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
+ }
33632
33709
  }
33633
33710
  });
33711
+ if (named.length === 0) throw firstError;
33634
33712
  const meta3 = buildMeta(model, seed, args);
33635
33713
  if (capturedText) meta3.text = capturedText;
33636
33714
  if (capturedGrounding) meta3.grounding = capturedGrounding;
33637
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
+ }
33638
33720
  if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
33639
33721
  meta3.hint = REFINE_HINT;
33640
33722
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, count, persistsFiles: client2.mediaSink?.persistsFiles });
@@ -33768,6 +33850,7 @@ function registerSetTools(server, client2) {
33768
33850
  client2
33769
33851
  );
33770
33852
  const masterRefInputs = [...masterOnly.inputs, ...carried.inputs];
33853
+ const failed = [];
33771
33854
  const report = carried.report || masterOnly.report ? { ...masterOnly.report, ...carried.report } : void 0;
33772
33855
  const named = [];
33773
33856
  const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
@@ -33781,18 +33864,33 @@ function registerSetTools(server, client2) {
33781
33864
  named.push({ image: master, base: `${slug}-master` });
33782
33865
  const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
33783
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
+ };
33784
33875
  if (mode === "chain") {
33785
33876
  let ref = master;
33786
33877
  for (let i = 0; i < scenePrompts.length; i++) {
33787
- const { images: [img] } = await client2.generate({ prompt: scenePrompts[i], images: [ref, ...carried.inputs], seed: seed + i + 1, ...cfg });
33788
- named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` });
33789
- 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
+ }
33790
33885
  }
33791
33886
  } else {
33792
- const scenes = await Promise.all(
33887
+ const settled = await Promise.allSettled(
33793
33888
  scenePrompts.map((p, i) => client2.generate({ prompt: p, images: [master, ...carried.inputs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
33794
33889
  );
33795
- 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
+ });
33796
33894
  }
33797
33895
  return result;
33798
33896
  });
@@ -33801,6 +33899,10 @@ function registerSetTools(server, client2) {
33801
33899
  if (masterText) meta3.text = masterText;
33802
33900
  if (masterResult.grounding) meta3.grounding = masterResult.grounding;
33803
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
+ }
33804
33906
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client2.mediaSink?.persistsFiles });
33805
33907
  if (risk) meta3.timeout_risk = risk;
33806
33908
  return emit(named, { ...args, sink: client2.mediaSink }, meta3);
@@ -33970,6 +34072,7 @@ var GeminiClient = class {
33970
34072
  session = new SessionState();
33971
34073
  constructor(opts = {}) {
33972
34074
  this.mediaSink = opts.mediaSink ?? createDiskSink();
34075
+ this.session.jobs.resigner = this.mediaSink;
33973
34076
  this.explicitApiKey = opts.apiKey?.trim() || void 0;
33974
34077
  this.fetchImpl = opts.fetchImpl ?? fetch;
33975
34078
  this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
@@ -34039,6 +34142,22 @@ var GeminiClient = class {
34039
34142
  async fetchRemoteImage(url2, opts = {}) {
34040
34143
  return fetchRemoteImage(url2, { ...opts, fetchImpl: this.fetchImpl, timeoutMs: resolveTimeoutMs(opts.timeoutMs) });
34041
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
+ }
34042
34161
  /** The default model after env override (no per-call arg). */
34043
34162
  defaultModel() {
34044
34163
  return resolveModel(void 0, readEnvVar("GEMINI_IMAGE_MODEL"));
@@ -34894,13 +35013,16 @@ function registerFileTools(server, client2) {
34894
35013
  server.registerTool(
34895
35014
  "gemini_upload_file",
34896
35015
  {
34897
- 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)." : ".") + (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."),
34898
35017
  annotations: { readOnlyHint: false, openWorldHint: true },
34899
35018
  inputSchema: {
34900
35019
  url: external_exports.string().url().optional().describe(
34901
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.")
34902
35021
  ),
34903
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
+ ),
34904
35026
  path: external_exports.string().min(1).optional().describe(
34905
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."
34906
35028
  ),
@@ -34910,10 +35032,10 @@ function registerFileTools(server, client2) {
34910
35032
  }
34911
35033
  },
34912
35034
  async (args, extra) => {
34913
- 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);
34914
35036
  if (sources.length !== 1) {
34915
35037
  throw new McpToolError(
34916
- 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(", ")}).`
34917
35039
  );
34918
35040
  }
34919
35041
  if (args.path) {
@@ -34942,6 +35064,19 @@ function registerFileTools(server, client2) {
34942
35064
  args.display_name ?? fileNameFromUrl(args.url, "upload")
34943
35065
  );
34944
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
+ }
34945
35080
  if (args.data_base64) {
34946
35081
  const { decodeImageInput: decodeImageInput2 } = await Promise.resolve().then(() => (init_images(), images_exports));
34947
35082
  const decoded = decodeImageInput2(args.data_base64);
@@ -34988,6 +35123,40 @@ function registerFileTools(server, client2) {
34988
35123
  });
34989
35124
  }
34990
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
+ }
34991
35160
  server.registerTool(
34992
35161
  "gemini_delete_file",
34993
35162
  {
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'));
package/dist/jobs.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { randomUUID, createHash } from 'node:crypto';
2
2
  import { textResult, McpToolError } from '@chrischall/mcp-utils';
3
+ import { downloadFilename } from './media-endpoint.js';
3
4
  /** Recently-completed jobs are reusable by key for this long, then evicted. */
4
5
  const JOB_TTL_MS = 600_000; // 10 min
5
6
  /** Hard cap on retained entries (oldest-settled evicted first). */
@@ -22,6 +23,72 @@ function isExpired(entry, now) {
22
23
  export function fingerprintRequest(toolName, parts) {
23
24
  return createHash('sha256').update(`${toolName}\u0000${JSON.stringify(parts)}`).digest('hex');
24
25
  }
26
+ /**
27
+ * Re-mint any media URLs a recorded result carries, before it is replayed.
28
+ *
29
+ * A recorded result can outlive the signed URLs inside it — and after a change
30
+ * to the result shape it can even carry the OLD shape entirely. Either way the
31
+ * caller gets something that looks right and does not work. Re-signing from the
32
+ * `r2_key`, which is stable, fixes both: an expired link becomes a live one,
33
+ * and a legacy entry gains the `url`/`curl_hint` fields the current shape has.
34
+ *
35
+ * Best-effort by design. A replay that cannot be refreshed is still a valid
36
+ * replay — the point of idempotency is not billing twice, and failing here
37
+ * would trade a stale URL for no result at all.
38
+ */
39
+ async function refreshMedia(result, sink) {
40
+ const first = result.content?.[0];
41
+ if (!sink?.resign || !first || first.type !== 'text')
42
+ return result;
43
+ let body;
44
+ try {
45
+ body = JSON.parse(first.text);
46
+ }
47
+ catch {
48
+ return result;
49
+ }
50
+ const entries = Array.isArray(body.media) ? body.media : [];
51
+ if (!entries.some((e) => typeof e.r2_key === 'string'))
52
+ return result;
53
+ // Per ENTRY, not per key: filtering first would shift every assignment after
54
+ // an entry without an r2_key and truncate the flat list below.
55
+ const refreshed = await Promise.all(entries.map(async (e) => {
56
+ if (typeof e.r2_key !== 'string')
57
+ return undefined;
58
+ try {
59
+ return await sink.resign(e.r2_key);
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }));
65
+ if (refreshed.every((r) => r === undefined))
66
+ return result;
67
+ const mediaKeyName = ['images', 'videos', 'audios'].find((k) => Array.isArray(body[k]));
68
+ const refs = [];
69
+ refreshed.forEach((fresh, i) => {
70
+ if (!fresh) {
71
+ refs.push(String(entries[i]?.url ?? ''));
72
+ return;
73
+ }
74
+ // Drop the recorded expiry rather than carrying it over: a re-signed URL
75
+ // has its own deadline (or none, on a public-bucket sink), and the old one
76
+ // would mislabel a live link as expired.
77
+ const { expires_at: _stale, ...rest } = entries[i];
78
+ entries[i] = {
79
+ ...rest,
80
+ url: fresh.ref,
81
+ ...(fresh.expiresAt ? { expires_at: fresh.expiresAt } : {}),
82
+ curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? 'media')} "${fresh.ref}"`,
83
+ };
84
+ refs.push(fresh.ref);
85
+ });
86
+ body.media = entries;
87
+ if (mediaKeyName)
88
+ body[mediaKeyName] = refs;
89
+ body.media_urls_refreshed = true;
90
+ return { ...result, content: [{ ...first, text: JSON.stringify(body, null, 2) }, ...(result.content ?? []).slice(1)] };
91
+ }
25
92
  /** Clone `result`, adding `reused: true` + `reused_job_id` to its meta block. */
26
93
  function annotateReused(result, jobId) {
27
94
  const content = result.content ?? [];
@@ -58,6 +125,12 @@ function jobHandle(jobId, status) {
58
125
  * `client.session.jobs`.
59
126
  */
60
127
  export class JobRegistry {
128
+ /**
129
+ * Lets a replayed result have its media URLs re-minted. Set by the session
130
+ * that owns this registry; absent on stdio, where refs are file paths that
131
+ * never expire.
132
+ */
133
+ resigner;
61
134
  jobs = new Map();
62
135
  byKey = new Map(); // idempotencyKey -> jobId
63
136
  runningByFingerprint = new Map(); // fingerprint -> jobId (only while running)
@@ -126,7 +199,10 @@ export class JobRegistry {
126
199
  if (hit) {
127
200
  if (async)
128
201
  return jobHandle(hit.jobId, hit.status);
129
- return annotateReused(await hit.promise, hit.jobId);
202
+ // Refresh before annotating: a recorded result can outlive the signed
203
+ // URLs inside it, and replaying an expired link is its own kind of wrong
204
+ // answer — it looks like success.
205
+ return annotateReused(await refreshMedia(await hit.promise, this.resigner), hit.jobId);
130
206
  }
131
207
  const jobId = randomUUID();
132
208
  this.evict(now);
@@ -1,4 +1,75 @@
1
1
  import { loadSigningKey, verifyMediaSignature } from './media-url.js';
2
+ /**
3
+ * Human-friendly download name from an object key.
4
+ *
5
+ * Keys look like `gen/<tenant>/2026-07-29/ab12cd34-a-cat.png`; the random component is
6
+ * there to make the key unique, not to be read. Stripping it turns a saved file
7
+ * into `a-cat.png`, which matters because the reliable way to show a user an
8
+ * image in a chat client is for the agent to download it and attach it — and an
9
+ * attachment called `ab12cd34-a-cat.png` looks like a machine artefact.
10
+ */
11
+ export function downloadFilename(key) {
12
+ const last = key.split('/').pop() || 'media';
13
+ const stripped = last.replace(/^[0-9a-f]{6,12}-/i, '');
14
+ return stripped || last;
15
+ }
16
+ /**
17
+ * Parse a single-range `Range: bytes=a-b` header. Multi-range is unsupported,
18
+ * and an unknown object size means no range handling at all (Range is advisory
19
+ * — `undefined` degrades to a full 200, never an error).
20
+ */
21
+ function parseRange(header, size) {
22
+ if (!header || size === undefined)
23
+ return undefined;
24
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
25
+ if (!match)
26
+ return undefined;
27
+ const [, startText, endText] = match;
28
+ if (startText === '' && endText === '')
29
+ return undefined;
30
+ if (startText === '') {
31
+ // Suffix range: the last N bytes.
32
+ const suffix = Number(endText);
33
+ if (!suffix)
34
+ return undefined;
35
+ const offset = Math.max(0, size - suffix);
36
+ return { offset, length: size - offset };
37
+ }
38
+ const offset = Number(startText);
39
+ if (offset >= size)
40
+ return 'unsatisfiable';
41
+ const end = endText === '' ? size - 1 : Number(endText);
42
+ if (end < offset)
43
+ return 'unsatisfiable';
44
+ return { offset, length: end - offset + 1 };
45
+ }
46
+ const MISSING = 'That media object no longer exists — it may have been cleaned up. Re-run the generation to get a fresh one.';
47
+ /**
48
+ * Response headers shared by GET, HEAD and 206. `Content-Disposition` carries a
49
+ * readable filename because the reliable way to show a user an image in a chat
50
+ * client is for the agent to download it and attach it.
51
+ */
52
+ function baseHeaders(key, object, size) {
53
+ const headers = new Headers({
54
+ 'content-type': object.httpMetadata?.contentType ?? 'application/octet-stream',
55
+ // The URL is signed and expiring, so it is safe to cache for its lifetime;
56
+ // `private` keeps shared caches from serving it to anyone else.
57
+ 'cache-control': 'private, max-age=3600',
58
+ 'content-disposition': `inline; filename="${downloadFilename(key)}"`,
59
+ // The bytes are model output being handed to a browser — never let a
60
+ // sniffed content type turn an image response into something executable.
61
+ 'x-content-type-options': 'nosniff',
62
+ // Range support is cheap on R2 and is what makes a resumable `curl -C -`
63
+ // work against a large video.
64
+ 'accept-ranges': 'bytes',
65
+ });
66
+ const etag = object.httpEtag ?? object.etag;
67
+ if (etag)
68
+ headers.set('etag', etag);
69
+ if (size !== undefined)
70
+ headers.set('content-length', String(size));
71
+ return headers;
72
+ }
2
73
  function problem(status, message) {
3
74
  return new Response(`${message}\n`, {
4
75
  status,
@@ -49,24 +120,41 @@ export function createMediaHandler(env, basePath = '/media/') {
49
120
  }
50
121
  return problem(403, 'Invalid or missing media signature. Media links must be used exactly as returned by the tool, including the exp and sig parameters.');
51
122
  }
123
+ const rangeHeader = request.headers.get('range');
124
+ // HEAD is what a link-preview fetcher and `curl -I` send first; serve it
125
+ // from a metadata read where the bucket offers one, rather than opening a
126
+ // body we are about to throw away.
127
+ if (request.method === 'HEAD' && env.bucket.head) {
128
+ const meta = await env.bucket.head(key);
129
+ if (!meta)
130
+ return problem(404, MISSING);
131
+ return new Response(null, { status: 200, headers: baseHeaders(key, meta, meta.size) });
132
+ }
133
+ // Fetch whole and slice on the way out rather than pushing the range into
134
+ // R2: the objects here are single images and short clips, and a typed
135
+ // range option is not expressible against both the real binding and a test
136
+ // fake without casting away the very types that make this safe.
52
137
  const object = await env.bucket.get(key);
53
- if (!object) {
54
- return problem(404, 'That media object no longer exists — it may have been cleaned up. Re-run the generation to get a fresh one.');
138
+ if (!object)
139
+ return problem(404, MISSING);
140
+ const totalSize = object.size;
141
+ const range = parseRange(rangeHeader, totalSize);
142
+ if (range === 'unsatisfiable') {
143
+ return new Response('Requested range not satisfiable\n', {
144
+ status: 416,
145
+ headers: { 'content-range': `bytes */${totalSize ?? 0}`, 'content-type': 'text/plain; charset=utf-8' },
146
+ });
55
147
  }
56
- const headers = new Headers({
57
- 'content-type': object.httpMetadata?.contentType ?? 'application/octet-stream',
58
- // The URL is signed and expiring, so it is safe to cache for its lifetime;
59
- // `private` keeps shared caches from serving it to anyone else.
60
- 'cache-control': 'private, max-age=3600',
61
- 'content-disposition': `inline; filename="${key.split('/').pop() ?? 'media'}"`,
62
- // The bytes are model output being handed to a browser — never let a
63
- // sniffed content type turn an image response into something executable.
64
- 'x-content-type-options': 'nosniff',
65
- });
66
- if (object.size !== undefined)
67
- headers.set('content-length', String(object.size));
148
+ const headers = baseHeaders(key, object, totalSize);
68
149
  if (request.method === 'HEAD')
69
150
  return new Response(null, { status: 200, headers });
70
- return new Response(object.body, { status: 200, headers });
151
+ if (range && totalSize !== undefined && object.arrayBuffer) {
152
+ const length = Math.min(range.length, totalSize - range.offset);
153
+ const slice = (await object.arrayBuffer()).slice(range.offset, range.offset + length);
154
+ headers.set('content-length', String(slice.byteLength));
155
+ headers.set('content-range', `bytes ${range.offset}-${range.offset + slice.byteLength - 1}/${totalSize}`);
156
+ return new Response(slice, { status: 206, headers });
157
+ }
158
+ return new Response(object.body ?? null, { status: 200, headers });
71
159
  };
72
160
  }
@@ -36,6 +36,11 @@ export function createDiskSink() {
36
36
  note: () => undefined,
37
37
  };
38
38
  }
39
+ /** Stable, non-reversible short tenant id from a per-session secret. */
40
+ export async function tenantIdFor(secret) {
41
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));
42
+ return [...new Uint8Array(digest).slice(0, 6)].map((b) => b.toString(16).padStart(2, '0')).join('');
43
+ }
39
44
  // `gen`, not `media`: the objects are served at `/media/<key>`, and a `media`
40
45
  // prefix would make every URL read `/media/media/…`.
41
46
  const KEY_PREFIX_DEFAULT = 'gen';
@@ -62,19 +67,52 @@ export function createR2Sink(bucket, opts) {
62
67
  const publicBase = opts.publicBaseUrl?.replace(/\/+$/, '');
63
68
  const signedBase = opts.signedBaseUrl?.replace(/\/+$/, '');
64
69
  const ttlMs = opts.urlTtlMs ?? 0;
70
+ /** `<prefix>/` or `<prefix>/<tenant>/` — every key this sink may touch. */
71
+ let ownPrefixCache;
72
+ const ownPrefix = () => (ownPrefixCache ??= (async () => {
73
+ const tenant = typeof opts.tenant === 'function' ? await opts.tenant() : opts.tenant;
74
+ return tenant ? `${prefix}/${tenant}/` : `${prefix}/`;
75
+ })());
76
+ /**
77
+ * Ownership gate for caller-supplied keys. A foreign key is refused without
78
+ * touching storage, so the refusal is indistinguishable from a swept object.
79
+ * With no tenant configured there is nothing to enforce (single-user shapes).
80
+ */
81
+ const owned = async (key) => !opts.tenant || key.startsWith(await ownPrefix());
65
82
  return {
66
83
  kind: 'r2',
67
84
  persistsFiles: false,
85
+ async read(key) {
86
+ if (!bucket.get || !(await owned(key)))
87
+ return undefined;
88
+ const object = await bucket.get(key);
89
+ if (!object)
90
+ return undefined;
91
+ return {
92
+ bytes: new Uint8Array(await object.arrayBuffer()),
93
+ mimeType: object.httpMetadata?.contentType ?? 'application/octet-stream',
94
+ };
95
+ },
96
+ async resign(key) {
97
+ if (!(await owned(key)))
98
+ return undefined;
99
+ // Only for objects that still exist — re-signing a swept key would hand
100
+ // back a link that 404s, which is no better than the expired one.
101
+ if (bucket.get && !(await bucket.get(key)))
102
+ return undefined;
103
+ return describe(key, now().getTime() + ttlMs);
104
+ },
68
105
  async persist(items, _opts) {
69
106
  // `mediaExt` is pure string work but lives in images.ts next to node:fs
70
107
  // imports; the dynamic import keeps that module off a Worker's eager
71
108
  // module graph.
72
109
  const { mediaExt } = await import('../images.js');
110
+ const keyBase = await ownPrefix();
73
111
  const day = now().toISOString().slice(0, 10);
74
112
  const expiresAtMs = now().getTime() + ttlMs;
75
113
  const refs = [];
76
114
  for (const it of items) {
77
- const key = `${prefix}/${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;
115
+ const key = `${keyBase}${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;
78
116
  await bucket.put(key, base64ToBytes(it.base64), { httpMetadata: { contentType: it.mimeType } });
79
117
  refs.push(await describe(key, expiresAtMs));
80
118
  }
@@ -3,6 +3,7 @@ import { McpToolError, textResult } from '@chrischall/mcp-utils';
3
3
  import { withProgressHeartbeat } from './shared.js';
4
4
  import { base64ToBytes, wholeMb } from '../bytes.js';
5
5
  import { fileNameFromUrl } from '../fetch-image.js';
6
+ import { downloadFilename } from '../media-endpoint.js';
6
7
  import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
7
8
  /**
8
9
  * First-class Gemini Files API access.
@@ -70,7 +71,9 @@ export function registerFileTools(server, client) {
70
71
  'Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever ' +
71
72
  'enter the conversation, and it can be reused across many generations until it expires (~48h). ' +
72
73
  'Provide exactly one of `url` (the server downloads it), `data_base64`' +
73
- (onDisk ? ', or `path` (a local file).' : '.') +
74
+ (onDisk
75
+ ? ', or `path` (a local file).'
76
+ : ', or `r2_key` (re-upload media this connector generated, from media[].r2_key in an earlier result).') +
74
77
  (onDisk
75
78
  ? ''
76
79
  : ' This connector also accepts raw bytes over HTTP: POST them to /upload with the same Authorization header ' +
@@ -89,6 +92,16 @@ export function registerFileTools(server, client) {
89
92
  .min(1)
90
93
  .optional()
91
94
  .describe('Raw base64 or a data: URI. Last resort — this is the one form that costs model context (~14k tokens for a modest JPEG).'),
95
+ r2_key: z
96
+ .string()
97
+ .min(1)
98
+ .optional()
99
+ .describe(onDisk
100
+ ? 'Unavailable on this server (media is written to local disk) — pass the file path via `path` instead.'
101
+ : 'An `r2_key` from a previous generation result (media[].r2_key) — re-uploads media THIS connector generated, ' +
102
+ 'so a generated image can become a reference image in one cheap call. The server reads its own bucket directly: ' +
103
+ 'no HTTP request, no signed URL, and no expiry to worry about. Do NOT pass a /media URL as `url` for this — ' +
104
+ 'that would be the connector fetching itself and needs a signature.'),
92
105
  path: z
93
106
  .string()
94
107
  .min(1)
@@ -105,10 +118,10 @@ export function registerFileTools(server, client) {
105
118
  confirm: schemaConfirm,
106
119
  },
107
120
  }, async (args, extra) => {
108
- const sources = [args.url && 'url', args.data_base64 && 'data_base64', args.path && 'path'].filter(Boolean);
121
+ const sources = [args.url && 'url', args.data_base64 && 'data_base64', args.r2_key && 'r2_key', args.path && 'path'].filter(Boolean);
109
122
  if (sources.length !== 1) {
110
123
  throw new McpToolError(sources.length === 0
111
- ? 'Provide exactly one of `url`, `data_base64`' + (onDisk ? ' or `path`.' : '.')
124
+ ? 'Provide exactly one of `url`, `data_base64`' + (onDisk ? ' or `path`.' : ' or `r2_key`.')
112
125
  : `Provide exactly one source, not ${sources.length} (${sources.join(', ')}).`);
113
126
  }
114
127
  if (args.path) {
@@ -131,6 +144,17 @@ export function registerFileTools(server, client) {
131
144
  // this tool takes video and audio too, so 'image' would be a lie.
132
145
  args.display_name ?? fileNameFromUrl(args.url, 'upload'));
133
146
  }
147
+ if (args.r2_key) {
148
+ if (onDisk) {
149
+ throw new McpToolError('`r2_key` is only available on the hosted connector — this server writes media to local disk, ' +
150
+ 'so pass the generated file via `path` instead.');
151
+ }
152
+ // Reading our own bucket rather than fetching our own /media URL. A
153
+ // self-fetch would need a signature the caller cannot mint, which is
154
+ // exactly the 403 loop this parameter exists to remove.
155
+ const stored = await client.readStoredMedia(args.r2_key);
156
+ return client.uploadBytes(stored.bytes, args.mime_type ?? stored.mimeType, args.display_name ?? downloadFilename(args.r2_key));
157
+ }
134
158
  if (args.data_base64) {
135
159
  const { decodeImageInput } = await import('../images.js');
136
160
  const decoded = decodeImageInput(args.data_base64);
@@ -171,6 +195,40 @@ export function registerFileTools(server, client) {
171
195
  : 'No files uploaded (or all have expired — retention is ~48h). Upload one with gemini_upload_file.',
172
196
  });
173
197
  });
198
+ // Hosted connector only. On a disk-backed server the result carries a path
199
+ // that never expires, so a "refresh my link" tool would only ever error —
200
+ // don't advertise it there (mirrors the Worker not registering video).
201
+ if (!onDisk) {
202
+ server.registerTool('gemini_sign_media', {
203
+ description: 'Mint a FRESH signed URL for media this connector generated, from its `r2_key`. ' +
204
+ 'Signed media links expire well before the object behind them is cleaned up, so an expired link is not a dead ' +
205
+ 'end — re-sign it instead of re-generating (and re-paying for) the image. Keywords: expired link, refresh url, ' +
206
+ 're-sign, media url, download again.',
207
+ annotations: { readOnlyHint: true, openWorldHint: false },
208
+ inputSchema: {
209
+ r2_key: z.string().min(1).describe('The `media[].r2_key` from an earlier generation result'),
210
+ },
211
+ }, async (args) => {
212
+ const sink = client.mediaSink;
213
+ if (!sink?.resign) {
214
+ throw new McpToolError('This server does not serve media URLs — generated files are written to disk and the path in the result is already usable.', {
215
+ hint: 'gemini_sign_media applies to the hosted connector only.',
216
+ });
217
+ }
218
+ const fresh = await sink.resign(args.r2_key.trim());
219
+ if (!fresh) {
220
+ // Remediation goes in the MESSAGE: MCP serialization drops hints.
221
+ throw new McpToolError(`No stored media for r2_key "${args.r2_key}" — it has probably passed its retention window. Re-run the generation to get a new one.`, { hint: 'Objects are swept on a retention schedule (MEDIA_TTL_DAYS, default 7 days).' });
222
+ }
223
+ return textResult({
224
+ url: fresh.ref,
225
+ r2_key: fresh.key,
226
+ ...(fresh.expiresAt ? { expires_at: fresh.expiresAt } : {}),
227
+ curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? 'media')} "${fresh.ref}"`,
228
+ hint: 'In a Claude chat client, download this URL into your sandbox and present it as an output file — inline image blocks and markdown embeds do not render for the end user.',
229
+ });
230
+ });
231
+ }
174
232
  server.registerTool('gemini_delete_file', {
175
233
  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 ' +
176
234
  'will then fail with a generic 404, so delete only references you are finished with.',
@@ -66,31 +66,46 @@ export function registerGenerateTools(server, client) {
66
66
  // For count>1, surface the FIRST call's grounding (each call grounds
67
67
  // independently; one representative set of sources beats concatenating N).
68
68
  let capturedGrounding;
69
+ // count>1 is N billed generations in one tools/call, and any one can
70
+ // fail (a safety filter, a transient 5xx). Failures are collected per
71
+ // image so one bad draw cannot lose the ones that succeeded — same
72
+ // contract as gemini_image_set's failed_scenes. Zero successes still
73
+ // throws: an empty success is worse than an error.
74
+ const failed = [];
75
+ let firstError;
69
76
  await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : 'image'} (${model})`, async () => {
70
77
  for (let i = 0; i < count; i++) {
71
- // Distinct seed per image (seed+0 for the single-image case == echoed seed)
72
- // so count>1 yields N *different* images, not N duplicates.
73
- const result = await client.generate({
74
- prompt: args.prompt,
75
- images: refInputs.length > 0 ? refInputs : undefined,
76
- model: args.model,
77
- aspectRatio: args.aspect_ratio,
78
- imageSize: args.image_size,
79
- seed: seed + i,
80
- thinkingLevel: args.thinking_level,
81
- googleSearch: args.google_search,
82
- videoUrl: video.videoUrl,
83
- videoMimeType: video.videoMimeType,
84
- timeoutMs: args.timeout_ms,
85
- });
86
- const { images: [img], text } = result;
87
- if (text && !capturedText)
88
- capturedText = text;
89
- if (result.grounding && !capturedGrounding)
90
- capturedGrounding = result.grounding;
91
- named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, '0')}` });
78
+ try {
79
+ // Distinct seed per image (seed+0 for the single-image case == echoed seed)
80
+ // so count>1 yields N *different* images, not N duplicates.
81
+ const result = await client.generate({
82
+ prompt: args.prompt,
83
+ images: refInputs.length > 0 ? refInputs : undefined,
84
+ model: args.model,
85
+ aspectRatio: args.aspect_ratio,
86
+ imageSize: args.image_size,
87
+ seed: seed + i,
88
+ thinkingLevel: args.thinking_level,
89
+ googleSearch: args.google_search,
90
+ videoUrl: video.videoUrl,
91
+ videoMimeType: video.videoMimeType,
92
+ timeoutMs: args.timeout_ms,
93
+ });
94
+ const { images: [img], text } = result;
95
+ if (text && !capturedText)
96
+ capturedText = text;
97
+ if (result.grounding && !capturedGrounding)
98
+ capturedGrounding = result.grounding;
99
+ named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, '0')}` });
100
+ }
101
+ catch (err) {
102
+ firstError ??= err;
103
+ failed.push({ image: i + 1, error: err instanceof Error ? err.message : String(err) });
104
+ }
92
105
  }
93
106
  });
107
+ if (named.length === 0)
108
+ throw firstError;
94
109
  const meta = buildMeta(model, seed, args);
95
110
  if (capturedText)
96
111
  meta.text = capturedText;
@@ -98,6 +113,12 @@ export function registerGenerateTools(server, client) {
98
113
  meta.grounding = capturedGrounding;
99
114
  if (report)
100
115
  meta.image_inputs = report;
116
+ if (failed.length) {
117
+ meta.failed_images = failed;
118
+ meta.partial =
119
+ `${failed.length} of ${count} image(s) failed; the ${named.length} that succeeded are returned above. ` +
120
+ 'Re-run with count set to the shortfall rather than the whole batch — each failure message is in failed_images.';
121
+ }
101
122
  if (video.videoFileMeta)
102
123
  meta.video_file = video.videoFileMeta;
103
124
  meta.hint = REFINE_HINT;
package/dist/tools/set.js CHANGED
@@ -61,6 +61,8 @@ export function registerSetTools(server, client) {
61
61
  const carried = await resolveImageInputs({ images_url: args.master_images_url, images_file_uris: args.master_images_file_uris }, client);
62
62
  const masterOnly = await resolveImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard }, client);
63
63
  const masterRefInputs = [...masterOnly.inputs, ...carried.inputs];
64
+ /** Scenes that failed, so one bad scene cannot lose the whole batch. */
65
+ const failed = [];
64
66
  const report = carried.report || masterOnly.report
65
67
  ? { ...masterOnly.report, ...carried.report }
66
68
  : undefined;
@@ -79,17 +81,44 @@ export function registerSetTools(server, client) {
79
81
  // 2. scene prompts (explicit, or N repeats of master_prompt for variations)
80
82
  const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
81
83
  const mode = args.reference_mode ?? 'master';
84
+ // A set is N+1 billed generations in one tools/call, and any one of
85
+ // them can fail — a safety filter on a single scene, a transient 5xx.
86
+ // `Promise.all` rejected the whole batch on the first failure, losing
87
+ // the master and every scene that HAD succeeded, and surfaced as an
88
+ // opaque "Error occurred during tool execution" with no indication of
89
+ // which scene or why. Failures are collected per scene instead.
90
+ const sceneName = (i) => `${slug}-${String(i + 1).padStart(2, '0')}`;
91
+ const record = (err, i) => {
92
+ failed.push({
93
+ scene: i + 1,
94
+ prompt: scenePrompts[i],
95
+ error: err instanceof Error ? err.message : String(err),
96
+ });
97
+ };
82
98
  if (mode === 'chain') {
83
99
  let ref = master;
84
100
  for (let i = 0; i < scenePrompts.length; i++) {
85
- const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref, ...carried.inputs], seed: seed + i + 1, ...cfg });
86
- named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` });
87
- ref = img;
101
+ try {
102
+ const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref, ...carried.inputs], seed: seed + i + 1, ...cfg });
103
+ named.push({ image: img, base: sceneName(i) });
104
+ // Chain mode anchors each scene on the previous OUTPUT, so a
105
+ // failure must not advance the reference — the next scene
106
+ // continues from the last image that actually exists.
107
+ ref = img;
108
+ }
109
+ catch (err) {
110
+ record(err, i);
111
+ }
88
112
  }
89
113
  }
90
114
  else {
91
- const scenes = await Promise.all(scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master, ...carried.inputs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0])));
92
- scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` }));
115
+ const settled = await Promise.allSettled(scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master, ...carried.inputs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0])));
116
+ settled.forEach((outcome, i) => {
117
+ if (outcome.status === 'fulfilled')
118
+ named.push({ image: outcome.value, base: sceneName(i) });
119
+ else
120
+ record(outcome.reason, i);
121
+ });
93
122
  }
94
123
  return result;
95
124
  });
@@ -101,6 +130,13 @@ export function registerSetTools(server, client) {
101
130
  meta.grounding = masterResult.grounding;
102
131
  if (report)
103
132
  meta.image_inputs = report;
133
+ if (failed.length) {
134
+ meta.failed_scenes = failed;
135
+ meta.partial =
136
+ `${failed.length} of ${failed.length + named.length - 1} scene(s) failed; the master and the ` +
137
+ `${named.length - 1} scene(s) that succeeded are returned above. Re-run just the failed prompts rather than the whole set — ` +
138
+ 'each failure message is in failed_scenes.';
139
+ }
104
140
  // A set is master + N scenes in one tools/call — the most timeout-prone
105
141
  // tool. Effective image count drives the risk hint.
106
142
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client.mediaSink?.persistsFiles });
@@ -4,6 +4,7 @@ import { resolveVideoPath, videoMimeType } from '../images.js';
4
4
  import { IMAGE_URL_MAX_BYTES } from '../fetch-image.js';
5
5
  import { wholeMb } from '../bytes.js';
6
6
  import { createDiskSink } from '../storage/media.js';
7
+ import { downloadFilename } from '../media-endpoint.js';
7
8
  /**
8
9
  * Fallback sink for call sites that pass no explicit one (every pre-existing
9
10
  * test, and any caller constructed before the sink seam existed). The stdio
@@ -300,6 +301,12 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
300
301
  url: p.ref,
301
302
  ...(p.key ? { r2_key: p.key } : {}),
302
303
  ...(p.expiresAt ? { expires_at: p.expiresAt } : {}),
304
+ // A ready-to-run command, not a description of one. Models copy an
305
+ // exact string far more reliably than they compose one — and in a
306
+ // chat client this download-then-attach flow is the ONLY way the end
307
+ // user actually sees the image (inline MCP blocks and markdown embeds
308
+ // both render for the model and not for them).
309
+ ...(p.key ? { curl_hint: `curl -sS -o ${downloadFilename(p.key)} "${p.ref}"` } : {}),
303
310
  })),
304
311
  }
305
312
  : {};
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Single source of the server version. release-please bumps the literal below. */
2
- export const VERSION = '1.3.0'; // x-release-please-version
2
+ export const VERSION = '1.4.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/gemini-mcp",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "mcpName": "io.github.chrischall/gemini-mcp",
5
5
  "description": "Gemini image-generation MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/gemini-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.3.0",
9
+ "version": "1.4.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "1.3.0",
14
+ "version": "1.4.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },