@chrischall/gemini-mcp 1.3.0 → 1.5.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.
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.5.0";
32800
32809
 
32801
32810
  // src/tools/models.ts
32802
32811
  init_dist();
@@ -33093,7 +33102,7 @@ async function fetchRemoteImage(requestedUrl, opts = {}) {
33093
33102
  // src/inputs.ts
33094
33103
  function hasImageInput(args) {
33095
33104
  return Boolean(
33096
- args.images?.length || args.images_base64?.length || args.images_url?.length || args.images_file_uris?.length || args.from_clipboard
33105
+ args.images?.length || args.images_base64?.length || args.images_url?.length || args.images_file_uris?.length || args.images_r2_keys?.length || args.from_clipboard
33097
33106
  );
33098
33107
  }
33099
33108
  var ASSUMED_TTL_MS = 47 * 60 * 60 * 1e3;
@@ -33142,6 +33151,32 @@ async function resolveImageInputs(args, client2) {
33142
33151
  (report.file_uris ??= []).push(file2.name);
33143
33152
  inputs.push(input);
33144
33153
  }
33154
+ const seenKeys = /* @__PURE__ */ new Map();
33155
+ for (const key of args.images_r2_keys ?? []) {
33156
+ const hit = seenKeys.get(key);
33157
+ if (hit) {
33158
+ inputs.push(hit);
33159
+ continue;
33160
+ }
33161
+ const stored = await client2.readStoredMedia(key);
33162
+ const entry = {
33163
+ r2_key: key.trim(),
33164
+ mime_type: stored.mimeType,
33165
+ bytes: stored.bytes.byteLength
33166
+ };
33167
+ (report.r2_keys ??= []).push(entry);
33168
+ let input;
33169
+ if (stored.bytes.byteLength <= IMAGE_INLINE_MAX_BYTES) {
33170
+ input = { base64: bytesToBase64(stored.bytes), mimeType: stored.mimeType };
33171
+ } else {
33172
+ const displayName = key.trim().split("/").pop() || "image";
33173
+ const uploaded = await client2.uploadBytes(stored.bytes, stored.mimeType, displayName);
33174
+ entry.file_uri = uploaded.name;
33175
+ input = { uri: uploaded.uri, mimeType: uploaded.mimeType };
33176
+ }
33177
+ seenKeys.set(key, input);
33178
+ inputs.push(input);
33179
+ }
33145
33180
  return { inputs, report: Object.keys(report).length > 0 ? report : void 0 };
33146
33181
  }
33147
33182
  async function resolveLocalPath(path, client2, onDisk, report) {
@@ -33196,7 +33231,7 @@ async function resolveUrl(url2, client2, report) {
33196
33231
  function requireImageInput(args) {
33197
33232
  if (hasImageInput(args)) return;
33198
33233
  throw new McpToolError(
33199
- "Provide at least one input image via `images_url` (an https URL the server fetches), `images_file_uris` (from gemini_upload_file), `images` (a local path), `images_base64`, or `from_clipboard`."
33234
+ "Provide at least one input image via `images_url` (an https URL the server fetches), `images_file_uris` (from gemini_upload_file), `images_r2_keys` (a hosted-connector upload or earlier generation), `images` (a local path), `images_base64`, or `from_clipboard`."
33200
33235
  );
33201
33236
  }
33202
33237
 
@@ -33221,6 +33256,104 @@ function createDiskSink() {
33221
33256
  };
33222
33257
  }
33223
33258
 
33259
+ // src/media-endpoint.ts
33260
+ init_media_url();
33261
+ function downloadFilename(key) {
33262
+ const last = key.split("/").pop() || "media";
33263
+ const stripped = last.replace(/^[0-9a-f]{6,12}-/i, "");
33264
+ return stripped || last;
33265
+ }
33266
+
33267
+ // src/zip.ts
33268
+ var CRC_TABLE = (() => {
33269
+ const table = new Uint32Array(256);
33270
+ for (let n = 0; n < 256; n++) {
33271
+ let c = n;
33272
+ for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
33273
+ table[n] = c >>> 0;
33274
+ }
33275
+ return table;
33276
+ })();
33277
+ function crc32(bytes) {
33278
+ let crc = 4294967295;
33279
+ for (let i = 0; i < bytes.length; i++) {
33280
+ crc = CRC_TABLE[(crc ^ bytes[i]) & 255] ^ crc >>> 8;
33281
+ }
33282
+ return (crc ^ 4294967295) >>> 0;
33283
+ }
33284
+ function dosDateTime(at) {
33285
+ const year = Math.max(1980, at.getUTCFullYear());
33286
+ return {
33287
+ time: at.getUTCHours() << 11 | at.getUTCMinutes() << 5 | at.getUTCSeconds() >> 1,
33288
+ date: year - 1980 << 9 | at.getUTCMonth() + 1 << 5 | at.getUTCDate()
33289
+ };
33290
+ }
33291
+ var LOCAL_HEADER = 67324752;
33292
+ var CENTRAL_HEADER = 33639248;
33293
+ var EOCD = 101010256;
33294
+ var VERSION2 = 20;
33295
+ var FLAG_UTF8 = 2048;
33296
+ function buildZip(entries, modifiedAt = new Date(Date.UTC(1980, 0, 1))) {
33297
+ const { time: time3, date: date5 } = dosDateTime(modifiedAt);
33298
+ const encoder = new TextEncoder();
33299
+ const parts = [];
33300
+ const central = [];
33301
+ let offset = 0;
33302
+ for (const entry of entries) {
33303
+ const name = encoder.encode(entry.name);
33304
+ const crc = crc32(entry.bytes);
33305
+ const size = entry.bytes.byteLength;
33306
+ const local = new Uint8Array(30 + name.byteLength);
33307
+ const lv = new DataView(local.buffer);
33308
+ lv.setUint32(0, LOCAL_HEADER, true);
33309
+ lv.setUint16(4, VERSION2, true);
33310
+ lv.setUint16(6, FLAG_UTF8, true);
33311
+ lv.setUint16(8, 0, true);
33312
+ lv.setUint16(10, time3, true);
33313
+ lv.setUint16(12, date5, true);
33314
+ lv.setUint32(14, crc, true);
33315
+ lv.setUint32(18, size, true);
33316
+ lv.setUint32(22, size, true);
33317
+ lv.setUint16(26, name.byteLength, true);
33318
+ lv.setUint16(28, 0, true);
33319
+ local.set(name, 30);
33320
+ const dir = new Uint8Array(46 + name.byteLength);
33321
+ const dv = new DataView(dir.buffer);
33322
+ dv.setUint32(0, CENTRAL_HEADER, true);
33323
+ dv.setUint16(4, VERSION2, true);
33324
+ dv.setUint16(6, VERSION2, true);
33325
+ dv.setUint16(8, FLAG_UTF8, true);
33326
+ dv.setUint16(10, 0, true);
33327
+ dv.setUint16(12, time3, true);
33328
+ dv.setUint16(14, date5, true);
33329
+ dv.setUint32(16, crc, true);
33330
+ dv.setUint32(20, size, true);
33331
+ dv.setUint32(24, size, true);
33332
+ dv.setUint16(28, name.byteLength, true);
33333
+ dv.setUint32(42, offset, true);
33334
+ dir.set(name, 46);
33335
+ parts.push(local, entry.bytes);
33336
+ central.push(dir);
33337
+ offset += local.byteLength + size;
33338
+ }
33339
+ const centralSize = central.reduce((n, c) => n + c.byteLength, 0);
33340
+ const eocd = new Uint8Array(22);
33341
+ const ev = new DataView(eocd.buffer);
33342
+ ev.setUint32(0, EOCD, true);
33343
+ ev.setUint16(8, entries.length, true);
33344
+ ev.setUint16(10, entries.length, true);
33345
+ ev.setUint32(12, centralSize, true);
33346
+ ev.setUint32(16, offset, true);
33347
+ const total = offset + centralSize + eocd.byteLength;
33348
+ const out = new Uint8Array(total);
33349
+ let cursor = 0;
33350
+ for (const part of [...parts, ...central, eocd]) {
33351
+ out.set(part, cursor);
33352
+ cursor += part.byteLength;
33353
+ }
33354
+ return out;
33355
+ }
33356
+
33224
33357
  // src/tools/shared.ts
33225
33358
  var DEFAULT_SINK = createDiskSink();
33226
33359
  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"];
@@ -33235,6 +33368,77 @@ var idempotencyKeySchema = external_exports.string().min(1).optional().describe(
33235
33368
  var asyncSchema = external_exports.boolean().optional().describe(
33236
33369
  "Run in the background and return a job_id immediately instead of the image, so a long (Pro/4K) generation cannot hit the host tools/call timeout (-32001). Poll gemini_get_result with the job_id to fetch the result (jobs are per-process and expire ~10 min after completion)."
33237
33370
  );
33371
+ var maxWaitMsSchema = external_exports.number().int().positive().max(6e5).optional().describe(
33372
+ 'Wait up to this many ms for the result; if generation is still running when the budget expires, return { job_id, status: "running" } immediately instead (poll gemini_get_result). Keeps fast results in-band while a slow batch can never trip the host tools/call timeout (-32001) \u2014 e.g. 20000 for multi-image sets. Ignored when async is set.'
33373
+ );
33374
+ function imagesR2KeysSchema(label = "Reference images") {
33375
+ return external_exports.array(external_exports.string().min(1)).optional().describe(
33376
+ `${label} by r2_key from THIS connector's store: a signed upload (gemini_get_upload_url \u2192 curl PUT) or an earlier generation's media[].r2_key. The server reads its own bucket directly \u2014 no bytes in the conversation, no signed URL, no ~48h Files API expiry. Hosted connector only.`
33377
+ );
33378
+ }
33379
+ var charactersSchema = external_exports.array(external_exports.string().min(1)).max(8).optional().describe(
33380
+ "Names of saved characters (see gemini_list_characters / gemini_save_character): each one's reference image and description are attached to the request automatically, keeping recurring subjects consistent without re-sending anything. Hosted connector only."
33381
+ );
33382
+ var styleSchema = external_exports.string().min(1).optional().describe(
33383
+ "Name of a saved style preset (see gemini_list_styles / gemini_save_style): its prompt fragment \u2014 and reference image, if it has one \u2014 is applied to the request automatically. Hosted connector only."
33384
+ );
33385
+ async function resolveCharacterRefs(client2, names, styleName) {
33386
+ if (!names?.length && !styleName) return { inputs: [] };
33387
+ const library = client2.library;
33388
+ if (!library) {
33389
+ throw new McpToolError(
33390
+ "`characters` / `style` reference this account's saved library, which exists on the hosted connector only. On this server pass the reference images directly (images / images_url / images_file_uris) and put the style text in the prompt."
33391
+ );
33392
+ }
33393
+ const inputs = [];
33394
+ const described = [];
33395
+ const toInput = async (image, displayName) => {
33396
+ if (image.bytes.byteLength <= IMAGE_INLINE_MAX_BYTES) {
33397
+ return { base64: bytesToBase64(image.bytes), mimeType: image.mimeType };
33398
+ }
33399
+ const uploaded = await client2.uploadBytes(image.bytes, image.mimeType, displayName);
33400
+ return { uri: uploaded.uri, mimeType: uploaded.mimeType };
33401
+ };
33402
+ for (const name of names ?? []) {
33403
+ const record2 = await library.getCharacter(name);
33404
+ const image = record2 && await library.readCharacterImage(name);
33405
+ if (!record2 || !image) {
33406
+ const available = (await library.listCharacters()).map((c) => c.name);
33407
+ throw new McpToolError(
33408
+ `No saved character "${name}". ` + (available.length ? `Saved characters: ${available.join(", ")}. ` : "None are saved yet. ") + "Save one with gemini_save_character."
33409
+ );
33410
+ }
33411
+ inputs.push(await toInput(image, name));
33412
+ described.push(`${record2.name} \u2014 ${record2.description}`);
33413
+ }
33414
+ let styleFragment;
33415
+ let styleImageAttached = false;
33416
+ if (styleName) {
33417
+ const record2 = await library.getStyle(styleName);
33418
+ if (!record2) {
33419
+ const available = (await library.listStyles()).map((s) => s.name);
33420
+ throw new McpToolError(
33421
+ `No saved style "${styleName}". ` + (available.length ? `Saved styles: ${available.join(", ")}. ` : "None are saved yet. ") + "Save one with gemini_save_style."
33422
+ );
33423
+ }
33424
+ styleFragment = record2.prompt_fragment;
33425
+ if (record2.image_key) {
33426
+ const image = await library.readStyleImage(styleName);
33427
+ if (image) {
33428
+ inputs.push(await toInput(image, styleName));
33429
+ styleImageAttached = true;
33430
+ }
33431
+ }
33432
+ }
33433
+ const characterLine = described.length ? `Use the attached reference images for these characters, in order: ${described.map((d, i) => `(${i + 1}) ${d}`).join("; ")}. Keep each character visually consistent with their reference image.` + (styleImageAttached ? ` Attached image (${described.length + 1}) is a style reference, not a character.` : "") : styleImageAttached ? "The first attached image is a style reference." : void 0;
33434
+ const meta3 = {};
33435
+ if (names?.length) meta3.characters = names;
33436
+ if (styleName) meta3.style = styleName;
33437
+ return { inputs, characterLine, styleFragment, meta: Object.keys(meta3).length ? meta3 : void 0 };
33438
+ }
33439
+ function composePrompt(prompt, refs) {
33440
+ return [refs.characterLine, prompt, refs.styleFragment ? `Style: ${refs.styleFragment}` : void 0].filter(Boolean).join("\n\n");
33441
+ }
33238
33442
  function imagesUrlSchema(label = "Reference images") {
33239
33443
  return external_exports.array(external_exports.string().url()).optional().describe(
33240
33444
  `${label} as public https URLs \u2014 the SERVER downloads them, so no image bytes travel through the conversation. Preferred over images_base64, which costs ~14k tokens per photo and breaks if a file read was truncated. Max ${wholeMb(IMAGE_URL_MAX_BYTES)}MB each; must be a directly-linked image (Content-Type image/*).`
@@ -33260,7 +33464,8 @@ var sharedImageSchema = {
33260
33464
  from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)"),
33261
33465
  timeout_ms: timeoutMsSchema,
33262
33466
  idempotency_key: idempotencyKeySchema,
33263
- async: asyncSchema
33467
+ async: asyncSchema,
33468
+ max_wait_ms: maxWaitMsSchema
33264
33469
  };
33265
33470
  async function resolveVideoInput(args, client2) {
33266
33471
  if (args.video_url && args.video_path) {
@@ -33347,7 +33552,8 @@ async function emitMedia(named, kind, opts, meta3, onWritten) {
33347
33552
  }
33348
33553
  const sink = opts.sink ?? DEFAULT_SINK;
33349
33554
  const persisted = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
33350
- output_dir: opts.output_dir
33555
+ output_dir: opts.output_dir,
33556
+ urlTtlMs: opts.urlTtlMs
33351
33557
  });
33352
33558
  const refs = persisted.map((p) => p.ref);
33353
33559
  if (sink.persistsFiles) await onWritten?.(refs);
@@ -33361,7 +33567,13 @@ async function emitMedia(named, kind, opts, meta3, onWritten) {
33361
33567
  media: persisted.map((p) => ({
33362
33568
  url: p.ref,
33363
33569
  ...p.key ? { r2_key: p.key } : {},
33364
- ...p.expiresAt ? { expires_at: p.expiresAt } : {}
33570
+ ...p.expiresAt ? { expires_at: p.expiresAt } : {},
33571
+ // A ready-to-run command, not a description of one. Models copy an
33572
+ // exact string far more reliably than they compose one — and in a
33573
+ // chat client this download-then-attach flow is the ONLY way the end
33574
+ // user actually sees the image (inline MCP blocks and markdown embeds
33575
+ // both render for the model and not for them).
33576
+ ...p.key ? { curl_hint: `curl -sS -o ${downloadFilename(p.key)} "${p.ref}"` } : {}
33365
33577
  }))
33366
33578
  } : {};
33367
33579
  const storage = sink.persistsFiles ? {} : { storage: sink.kind, storage_note: storageNote };
@@ -33370,6 +33582,40 @@ async function emitMedia(named, kind, opts, meta3, onWritten) {
33370
33582
  async function emit(named, opts, meta3, onWritten) {
33371
33583
  return emitMedia(named.map((n) => ({ media: n.image, base: n.base })), "image", opts, meta3, onWritten);
33372
33584
  }
33585
+ var SET_URL_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
33586
+ var BUNDLE_MAX_TOTAL_BYTES = 24 * 1024 * 1024;
33587
+ async function persistBundle(named, sink, base) {
33588
+ if (!sink || sink.persistsFiles || named.length < 2) return {};
33589
+ const totalBytes = named.reduce((sum, n) => sum + Math.floor(n.image.base64.length * 3 / 4), 0);
33590
+ if (totalBytes > BUNDLE_MAX_TOTAL_BYTES) {
33591
+ return {
33592
+ bundle_skipped: `The set totals ~${wholeMb(totalBytes)}MB decoded, over the ${wholeMb(BUNDLE_MAX_TOTAL_BYTES)}MB bundling cap (zipping peaks at ~4x that in Worker memory). Download the images individually via their media urls / curl_hints.`
33593
+ };
33594
+ }
33595
+ try {
33596
+ const { mediaExt: mediaExt2 } = await Promise.resolve().then(() => (init_images(), images_exports));
33597
+ const zip = buildZip(named.map((n) => ({ name: `${n.base}.${mediaExt2(n.image.mimeType)}`, bytes: base64ToBytes(n.image.base64) })));
33598
+ const [persisted] = await sink.persist(
33599
+ [{ base: `${base}-bundle`, base64: bytesToBase64(zip), mimeType: "application/zip" }],
33600
+ { urlTtlMs: SET_URL_TTL_MS }
33601
+ );
33602
+ if (!persisted || persisted.unavailable) {
33603
+ return { bundle_skipped: "The bundle zip could not be stored; the images themselves are unaffected \u2014 download them individually via their media urls / curl_hints." };
33604
+ }
33605
+ return {
33606
+ bundle_url: persisted.ref,
33607
+ bundle: {
33608
+ url: persisted.ref,
33609
+ ...persisted.key ? { r2_key: persisted.key } : {},
33610
+ ...persisted.expiresAt ? { expires_at: persisted.expiresAt } : {},
33611
+ files: named.map((n) => n.base),
33612
+ ...persisted.key ? { curl_hint: `curl -sS -o ${downloadFilename(persisted.key)} "${persisted.ref}"` } : {}
33613
+ }
33614
+ };
33615
+ } catch {
33616
+ return { bundle_skipped: "Bundling failed; the images themselves are unaffected \u2014 download them individually via their media urls / curl_hints." };
33617
+ }
33618
+ }
33373
33619
  function assertLocalInputsAvailable(sink, args) {
33374
33620
  if ((sink ?? DEFAULT_SINK).persistsFiles) return;
33375
33621
  const unavailable = [];
@@ -33396,6 +33642,64 @@ function isExpired(entry, now) {
33396
33642
  function fingerprintRequest(toolName, parts) {
33397
33643
  return createHash("sha256").update(`${toolName}\0${JSON.stringify(parts)}`).digest("hex");
33398
33644
  }
33645
+ async function refreshMedia(result, sink) {
33646
+ const first = result.content?.[0];
33647
+ if (!sink?.resign || !first || first.type !== "text") return result;
33648
+ let body;
33649
+ try {
33650
+ body = JSON.parse(first.text);
33651
+ } catch {
33652
+ return result;
33653
+ }
33654
+ const entries = Array.isArray(body.media) ? body.media : [];
33655
+ if (!entries.some((e) => typeof e.r2_key === "string")) return result;
33656
+ const refreshed = await Promise.all(entries.map(async (e) => {
33657
+ if (typeof e.r2_key !== "string") return void 0;
33658
+ try {
33659
+ return await sink.resign(e.r2_key);
33660
+ } catch {
33661
+ return void 0;
33662
+ }
33663
+ }));
33664
+ if (refreshed.every((r) => r === void 0)) return result;
33665
+ const mediaKeyName = ["images", "videos", "audios"].find((k) => Array.isArray(body[k]));
33666
+ const refs = [];
33667
+ refreshed.forEach((fresh, i) => {
33668
+ if (!fresh) {
33669
+ refs.push(String(entries[i]?.url ?? ""));
33670
+ return;
33671
+ }
33672
+ const { expires_at: _stale, ...rest } = entries[i];
33673
+ entries[i] = {
33674
+ ...rest,
33675
+ url: fresh.ref,
33676
+ ...fresh.expiresAt ? { expires_at: fresh.expiresAt } : {},
33677
+ curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? "media")} "${fresh.ref}"`
33678
+ };
33679
+ refs.push(fresh.ref);
33680
+ });
33681
+ body.media = entries;
33682
+ if (mediaKeyName) body[mediaKeyName] = refs;
33683
+ const bundle = body.bundle;
33684
+ if (bundle && typeof bundle.r2_key === "string") {
33685
+ try {
33686
+ const fresh = await sink.resign(bundle.r2_key);
33687
+ if (fresh) {
33688
+ const { expires_at: _staleBundle, ...rest } = bundle;
33689
+ body.bundle = {
33690
+ ...rest,
33691
+ url: fresh.ref,
33692
+ ...fresh.expiresAt ? { expires_at: fresh.expiresAt } : {},
33693
+ curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? "media")} "${fresh.ref}"`
33694
+ };
33695
+ body.bundle_url = fresh.ref;
33696
+ }
33697
+ } catch {
33698
+ }
33699
+ }
33700
+ body.media_urls_refreshed = true;
33701
+ return { ...result, content: [{ ...first, text: JSON.stringify(body, null, 2) }, ...(result.content ?? []).slice(1)] };
33702
+ }
33399
33703
  function annotateReused(result, jobId) {
33400
33704
  const content = result.content ?? [];
33401
33705
  const first = content[0];
@@ -33422,7 +33726,29 @@ function jobHandle(jobId, status) {
33422
33726
  hint: `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`
33423
33727
  });
33424
33728
  }
33729
+ function awaitWithBudget(promise2, waitMs) {
33730
+ return new Promise((resolve2, reject) => {
33731
+ const timer = setTimeout(() => resolve2(void 0), waitMs);
33732
+ timer.unref?.();
33733
+ promise2.then(
33734
+ (result) => {
33735
+ clearTimeout(timer);
33736
+ resolve2(result);
33737
+ },
33738
+ (err) => {
33739
+ clearTimeout(timer);
33740
+ reject(err);
33741
+ }
33742
+ );
33743
+ });
33744
+ }
33425
33745
  var JobRegistry = class {
33746
+ /**
33747
+ * Lets a replayed result have its media URLs re-minted. Set by the session
33748
+ * that owns this registry; absent on stdio, where refs are file paths that
33749
+ * never expire.
33750
+ */
33751
+ resigner;
33426
33752
  jobs = /* @__PURE__ */ new Map();
33427
33753
  byKey = /* @__PURE__ */ new Map();
33428
33754
  // idempotencyKey -> jobId
@@ -33468,7 +33794,7 @@ var JobRegistry = class {
33468
33794
  * different registry and therefore a genuinely separate generation.
33469
33795
  */
33470
33796
  async dispatch(opts, work) {
33471
- const { toolName, fingerprint, idempotencyKey, async } = opts;
33797
+ const { toolName, fingerprint, idempotencyKey, async, waitMs } = opts;
33472
33798
  const now = Date.now();
33473
33799
  let hit;
33474
33800
  if (idempotencyKey !== void 0) {
@@ -33482,7 +33808,9 @@ var JobRegistry = class {
33482
33808
  }
33483
33809
  if (hit) {
33484
33810
  if (async) return jobHandle(hit.jobId, hit.status);
33485
- return annotateReused(await hit.promise, hit.jobId);
33811
+ const settled = waitMs !== void 0 && hit.status === "running" ? await awaitWithBudget(hit.promise, waitMs) : await hit.promise;
33812
+ if (settled === void 0) return jobHandle(hit.jobId, "running");
33813
+ return annotateReused(await refreshMedia(settled, this.resigner), hit.jobId);
33486
33814
  }
33487
33815
  const jobId = randomUUID();
33488
33816
  this.evict(now);
@@ -33506,6 +33834,10 @@ var JobRegistry = class {
33506
33834
  if (this.runningByFingerprint.get(fingerprint) === jobId) this.runningByFingerprint.delete(fingerprint);
33507
33835
  });
33508
33836
  if (async) return jobHandle(jobId, "running");
33837
+ if (waitMs !== void 0) {
33838
+ const settled = await awaitWithBudget(promise2, waitMs);
33839
+ return settled ?? jobHandle(jobId, "running");
33840
+ }
33509
33841
  return promise2;
33510
33842
  }
33511
33843
  /**
@@ -33573,7 +33905,10 @@ function registerGenerateTools(server, client2) {
33573
33905
  images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to reference input images (image-conditioned generation)"),
33574
33906
  images_url: imagesUrlSchema(),
33575
33907
  images_file_uris: imagesFileUrisSchema(),
33908
+ images_r2_keys: imagesR2KeysSchema(),
33576
33909
  images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation"),
33910
+ characters: charactersSchema,
33911
+ style: styleSchema,
33577
33912
  video_url: external_exports.string().url().optional().describe("Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video\u2192image; use a Flash model e.g. gemini-3.1-flash-image)"),
33578
33913
  video_path: videoPathSchema,
33579
33914
  confirm: schemaConfirm,
@@ -33599,42 +33934,61 @@ function registerGenerateTools(server, client2) {
33599
33934
  from_clipboard: args.from_clipboard,
33600
33935
  images_url: args.images_url,
33601
33936
  images_file_uris: args.images_file_uris,
33937
+ images_r2_keys: args.images_r2_keys,
33938
+ characters: args.characters,
33939
+ style: args.style,
33602
33940
  video_url: args.video_url,
33603
33941
  video_path: args.video_path
33604
33942
  });
33605
- return client2.session.jobs.dispatch({ toolName: "gemini_image_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33943
+ return client2.session.jobs.dispatch({ toolName: "gemini_image_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
33606
33944
  const seed = pickSeed(args.seed);
33607
33945
  const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33608
- const { inputs: refInputs, report } = await withProgressHeartbeat(extra, "Resolving reference images", () => resolveImageInputs(args, client2));
33946
+ const charRefs = await resolveCharacterRefs(client2, args.characters, args.style);
33947
+ const prompt = composePrompt(args.prompt, charRefs);
33948
+ const { inputs: directInputs, report } = await withProgressHeartbeat(extra, "Resolving reference images", () => resolveImageInputs(args, client2));
33949
+ const refInputs = [...charRefs.inputs, ...directInputs];
33609
33950
  const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args, client2));
33610
33951
  const named = [];
33611
33952
  let capturedText;
33612
33953
  let capturedGrounding;
33954
+ const failed = [];
33955
+ let firstError;
33613
33956
  await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : "image"} (${model})`, async () => {
33614
33957
  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")}` });
33958
+ try {
33959
+ const result = await client2.generate({
33960
+ prompt,
33961
+ images: refInputs.length > 0 ? refInputs : void 0,
33962
+ model: args.model,
33963
+ aspectRatio: args.aspect_ratio,
33964
+ imageSize: args.image_size,
33965
+ seed: seed + i,
33966
+ thinkingLevel: args.thinking_level,
33967
+ googleSearch: args.google_search,
33968
+ videoUrl: video.videoUrl,
33969
+ videoMimeType: video.videoMimeType,
33970
+ timeoutMs: args.timeout_ms
33971
+ });
33972
+ const { images: [img], text } = result;
33973
+ if (text && !capturedText) capturedText = text;
33974
+ if (result.grounding && !capturedGrounding) capturedGrounding = result.grounding;
33975
+ named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, "0")}` });
33976
+ } catch (err) {
33977
+ firstError ??= err;
33978
+ failed.push({ image: i + 1, error: err instanceof Error ? err.message : String(err) });
33979
+ }
33632
33980
  }
33633
33981
  });
33982
+ if (named.length === 0) throw firstError;
33634
33983
  const meta3 = buildMeta(model, seed, args);
33635
33984
  if (capturedText) meta3.text = capturedText;
33636
33985
  if (capturedGrounding) meta3.grounding = capturedGrounding;
33637
33986
  if (report) meta3.image_inputs = report;
33987
+ if (charRefs.meta) Object.assign(meta3, charRefs.meta);
33988
+ if (failed.length) {
33989
+ meta3.failed_images = failed;
33990
+ 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.`;
33991
+ }
33638
33992
  if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
33639
33993
  meta3.hint = REFINE_HINT;
33640
33994
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, count, persistsFiles: client2.mediaSink?.persistsFiles });
@@ -33653,7 +34007,10 @@ function registerGenerateTools(server, client2) {
33653
34007
  images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to input image file(s) (1 = edit, 2+ = compose)"),
33654
34008
  images_url: imagesUrlSchema("Input images"),
33655
34009
  images_file_uris: imagesFileUrisSchema("Input images"),
34010
+ images_r2_keys: imagesR2KeysSchema("Input images"),
33656
34011
  images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Input images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation"),
34012
+ characters: charactersSchema,
34013
+ style: styleSchema,
33657
34014
  filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
33658
34015
  confirm: schemaConfirm,
33659
34016
  ...sharedImageSchema
@@ -33661,7 +34018,7 @@ function registerGenerateTools(server, client2) {
33661
34018
  },
33662
34019
  async (args, extra) => {
33663
34020
  assertLocalInputsAvailable(client2.mediaSink, args);
33664
- requireImageInput(args);
34021
+ if (!args.characters?.length) requireImageInput(args);
33665
34022
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
33666
34023
  if (gate) return gate;
33667
34024
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
@@ -33676,14 +34033,20 @@ function registerGenerateTools(server, client2) {
33676
34033
  images_base64: args.images_base64,
33677
34034
  from_clipboard: args.from_clipboard,
33678
34035
  images_url: args.images_url,
33679
- images_file_uris: args.images_file_uris
34036
+ images_file_uris: args.images_file_uris,
34037
+ images_r2_keys: args.images_r2_keys,
34038
+ characters: args.characters,
34039
+ style: args.style
33680
34040
  });
33681
- return client2.session.jobs.dispatch({ toolName: "gemini_image_edit", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
34041
+ return client2.session.jobs.dispatch({ toolName: "gemini_image_edit", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
33682
34042
  const seed = pickSeed(args.seed);
33683
34043
  const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33684
- const { inputs, report } = await withProgressHeartbeat(extra, "Resolving input images", () => resolveImageInputs(args, client2));
34044
+ const charRefs = await resolveCharacterRefs(client2, args.characters, args.style);
34045
+ const prompt = composePrompt(args.prompt, charRefs);
34046
+ const { inputs: directInputs, report } = await withProgressHeartbeat(extra, "Resolving input images", () => resolveImageInputs(args, client2));
34047
+ const inputs = [...charRefs.inputs, ...directInputs];
33685
34048
  const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client2.generate({
33686
- prompt: args.prompt,
34049
+ prompt,
33687
34050
  images: inputs,
33688
34051
  model: args.model,
33689
34052
  aspectRatio: args.aspect_ratio,
@@ -33698,6 +34061,7 @@ function registerGenerateTools(server, client2) {
33698
34061
  if (text) meta3.text = text;
33699
34062
  if (result.grounding) meta3.grounding = result.grounding;
33700
34063
  if (report) meta3.image_inputs = report;
34064
+ if (charRefs.meta) Object.assign(meta3, charRefs.meta);
33701
34065
  meta3.hint = REFINE_HINT;
33702
34066
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: client2.mediaSink?.persistsFiles });
33703
34067
  if (risk) meta3.timeout_risk = risk;
@@ -33715,7 +34079,7 @@ function registerSetTools(server, client2) {
33715
34079
  server.registerTool(
33716
34080
  "gemini_image_set",
33717
34081
  {
33718
- description: "Generate a consistent SET of images: a master image from master_prompt, then one image per scene that references the master so the subject/style stays consistent. Provide `scenes` (explicit per-image prompts) OR `count` (variations of the master).",
34082
+ description: 'Generate a consistent SET of images: a master image from master_prompt, then one image per scene that references the master so the subject/style stays consistent. Provide `scenes` (explicit per-image prompts) OR `count` (variations of the master). Scene generations run in parallel (reference_mode "master", the default). On the hosted connector: saved `characters` and a saved `style` can seed the whole set by name, multi-image results include a `bundle_url` zip of every image (one curl instead of N), and `max_wait_ms` returns a pollable job handle if the batch runs long.',
33719
34083
  annotations: { readOnlyHint: false, openWorldHint: true },
33720
34084
  inputSchema: {
33721
34085
  master_prompt: external_exports.string().min(1).describe("Prompt for the master/reference image"),
@@ -33726,6 +34090,9 @@ function registerSetTools(server, client2) {
33726
34090
  master_images: external_exports.array(external_exports.string().min(1)).optional().describe("Reference image paths passed to the master generation call"),
33727
34091
  master_images_url: imagesUrlSchema("Reference images passed to the master AND to every scene call (fetched once)"),
33728
34092
  master_images_file_uris: imagesFileUrisSchema("Reference images passed to the master AND to every scene call"),
34093
+ master_images_r2_keys: imagesR2KeysSchema("Reference images passed to the master AND to every scene call"),
34094
+ characters: charactersSchema,
34095
+ style: styleSchema,
33729
34096
  master_images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs for master generation. Last resort: prefer master_images_url or master_images_file_uris, which keep image bytes out of the conversation"),
33730
34097
  confirm: schemaConfirm,
33731
34098
  ...sharedImageSchema
@@ -33753,26 +34120,34 @@ function registerSetTools(server, client2) {
33753
34120
  master_images_base64: args.master_images_base64,
33754
34121
  from_clipboard: args.from_clipboard,
33755
34122
  master_images_url: args.master_images_url,
33756
- master_images_file_uris: args.master_images_file_uris
34123
+ master_images_file_uris: args.master_images_file_uris,
34124
+ master_images_r2_keys: args.master_images_r2_keys,
34125
+ characters: args.characters,
34126
+ style: args.style
33757
34127
  });
33758
- return client2.session.jobs.dispatch({ toolName: "gemini_image_set", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
34128
+ return client2.session.jobs.dispatch({ toolName: "gemini_image_set", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
33759
34129
  const seed = pickSeed(args.seed);
33760
34130
  const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
33761
34131
  const slug = args.basename ? baseName(args.basename) : slugify2(args.master_prompt);
34132
+ const charRefs = await resolveCharacterRefs(client2, args.characters, args.style);
34133
+ const masterPrompt = composePrompt(args.master_prompt, charRefs);
34134
+ const styleOnly = { styleFragment: charRefs.styleFragment };
33762
34135
  const carried = await resolveImageInputs(
33763
- { images_url: args.master_images_url, images_file_uris: args.master_images_file_uris },
34136
+ { images_url: args.master_images_url, images_file_uris: args.master_images_file_uris, images_r2_keys: args.master_images_r2_keys },
33764
34137
  client2
33765
34138
  );
34139
+ const carriedRefs = [...charRefs.inputs, ...carried.inputs];
33766
34140
  const masterOnly = await resolveImageInputs(
33767
34141
  { images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard },
33768
34142
  client2
33769
34143
  );
33770
- const masterRefInputs = [...masterOnly.inputs, ...carried.inputs];
34144
+ const masterRefInputs = [...charRefs.inputs, ...masterOnly.inputs, ...carried.inputs];
34145
+ const failed = [];
33771
34146
  const report = carried.report || masterOnly.report ? { ...masterOnly.report, ...carried.report } : void 0;
33772
34147
  const named = [];
33773
34148
  const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
33774
34149
  const result = await client2.generate({
33775
- prompt: args.master_prompt,
34150
+ prompt: masterPrompt,
33776
34151
  images: masterRefInputs.length > 0 ? masterRefInputs : void 0,
33777
34152
  seed,
33778
34153
  ...cfg
@@ -33781,18 +34156,33 @@ function registerSetTools(server, client2) {
33781
34156
  named.push({ image: master, base: `${slug}-master` });
33782
34157
  const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
33783
34158
  const mode = args.reference_mode ?? "master";
34159
+ const sceneName = (i) => `${slug}-${String(i + 1).padStart(2, "0")}`;
34160
+ const record2 = (err, i) => {
34161
+ failed.push({
34162
+ scene: i + 1,
34163
+ prompt: scenePrompts[i],
34164
+ error: err instanceof Error ? err.message : String(err)
34165
+ });
34166
+ };
33784
34167
  if (mode === "chain") {
33785
34168
  let ref = master;
33786
34169
  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;
34170
+ try {
34171
+ const { images: [img] } = await client2.generate({ prompt: composePrompt(scenePrompts[i], styleOnly), images: [ref, ...carriedRefs], seed: seed + i + 1, ...cfg });
34172
+ named.push({ image: img, base: sceneName(i) });
34173
+ ref = img;
34174
+ } catch (err) {
34175
+ record2(err, i);
34176
+ }
33790
34177
  }
33791
34178
  } else {
33792
- const scenes = await Promise.all(
33793
- scenePrompts.map((p, i) => client2.generate({ prompt: p, images: [master, ...carried.inputs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
34179
+ const settled = await Promise.allSettled(
34180
+ scenePrompts.map((p, i) => client2.generate({ prompt: composePrompt(p, styleOnly), images: [master, ...carriedRefs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
33794
34181
  );
33795
- scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` }));
34182
+ settled.forEach((outcome, i) => {
34183
+ if (outcome.status === "fulfilled") named.push({ image: outcome.value, base: sceneName(i) });
34184
+ else record2(outcome.reason, i);
34185
+ });
33796
34186
  }
33797
34187
  return result;
33798
34188
  });
@@ -33801,9 +34191,15 @@ function registerSetTools(server, client2) {
33801
34191
  if (masterText) meta3.text = masterText;
33802
34192
  if (masterResult.grounding) meta3.grounding = masterResult.grounding;
33803
34193
  if (report) meta3.image_inputs = report;
34194
+ if (charRefs.meta) Object.assign(meta3, charRefs.meta);
34195
+ if (failed.length) {
34196
+ meta3.failed_scenes = failed;
34197
+ 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.`;
34198
+ }
33804
34199
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client2.mediaSink?.persistsFiles });
33805
34200
  if (risk) meta3.timeout_risk = risk;
33806
- return emit(named, { ...args, sink: client2.mediaSink }, meta3);
34201
+ if (!args.inline) Object.assign(meta3, await persistBundle(named, client2.mediaSink, slug));
34202
+ return emit(named, { ...args, sink: client2.mediaSink, urlTtlMs: SET_URL_TTL_MS }, meta3);
33807
34203
  });
33808
34204
  }
33809
34205
  );
@@ -33968,8 +34364,15 @@ var GeminiClient = class {
33968
34364
  * server is single-user, so its one singleton client is one session.
33969
34365
  */
33970
34366
  session = new SessionState();
34367
+ /** Per-account character/style library — hosted connector only (see options). */
34368
+ library;
34369
+ /** Signed upload-URL minter — hosted connector only (see options). */
34370
+ uploadUrls;
33971
34371
  constructor(opts = {}) {
33972
34372
  this.mediaSink = opts.mediaSink ?? createDiskSink();
34373
+ this.library = opts.library;
34374
+ this.uploadUrls = opts.uploadUrls;
34375
+ this.session.jobs.resigner = this.mediaSink;
33973
34376
  this.explicitApiKey = opts.apiKey?.trim() || void 0;
33974
34377
  this.fetchImpl = opts.fetchImpl ?? fetch;
33975
34378
  this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
@@ -34039,6 +34442,22 @@ var GeminiClient = class {
34039
34442
  async fetchRemoteImage(url2, opts = {}) {
34040
34443
  return fetchRemoteImage(url2, { ...opts, fetchImpl: this.fetchImpl, timeoutMs: resolveTimeoutMs(opts.timeoutMs) });
34041
34444
  }
34445
+ /**
34446
+ * Read back media this connector generated, straight out of its own store.
34447
+ *
34448
+ * The alternative — fetching our own `/media` URL — needs a signature the
34449
+ * caller cannot mint and returns 403, which is the loop this exists to close.
34450
+ */
34451
+ async readStoredMedia(key) {
34452
+ const stored = await this.mediaSink.read?.(key.trim());
34453
+ if (!stored) {
34454
+ throw new McpToolError(
34455
+ `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\`.`,
34456
+ { hint: "Use the exact media[].r2_key from a generation result." }
34457
+ );
34458
+ }
34459
+ return stored;
34460
+ }
34042
34461
  /** The default model after env override (no per-call arg). */
34043
34462
  defaultModel() {
34044
34463
  return resolveModel(void 0, readEnvVar("GEMINI_IMAGE_MODEL"));
@@ -34552,6 +34971,7 @@ function registerInteractTools(server, client2) {
34552
34971
  timeout_ms: timeoutMsSchema,
34553
34972
  idempotency_key: idempotencyKeySchema,
34554
34973
  async: asyncSchema,
34974
+ max_wait_ms: maxWaitMsSchema,
34555
34975
  from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)"),
34556
34976
  confirm: schemaConfirm
34557
34977
  }
@@ -34598,7 +35018,7 @@ function registerInteractTools(server, client2) {
34598
35018
  video_url: args.video_url,
34599
35019
  video_path: args.video_path
34600
35020
  });
34601
- return client2.session.jobs.dispatch({ toolName: "gemini_interact", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
35021
+ return client2.session.jobs.dispatch({ toolName: "gemini_interact", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
34602
35022
  const { inputs, report } = await resolveImageInputs({ ...args, images }, client2);
34603
35023
  const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args, client2));
34604
35024
  const callOpts = {
@@ -34702,6 +35122,7 @@ function registerVideoTools(server, client2) {
34702
35122
  timeout_ms: timeoutMsSchema,
34703
35123
  idempotency_key: idempotencyKeySchema,
34704
35124
  async: asyncSchema,
35125
+ max_wait_ms: maxWaitMsSchema,
34705
35126
  confirm: schemaConfirm
34706
35127
  }
34707
35128
  },
@@ -34731,7 +35152,7 @@ function registerVideoTools(server, client2) {
34731
35152
  images_file_uris: args.images_file_uris,
34732
35153
  previous_interaction_id: previousInteractionId
34733
35154
  });
34734
- return client2.session.jobs.dispatch({ toolName: "gemini_video_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
35155
+ return client2.session.jobs.dispatch({ toolName: "gemini_video_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
34735
35156
  const { inputs, report } = await resolveImageInputs(args, client2);
34736
35157
  const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client2.generateVideo({
34737
35158
  input: args.prompt,
@@ -34791,6 +35212,7 @@ function registerMusicTools(server, client2) {
34791
35212
  timeout_ms: timeoutMsSchema,
34792
35213
  idempotency_key: idempotencyKeySchema,
34793
35214
  async: asyncSchema,
35215
+ max_wait_ms: maxWaitMsSchema,
34794
35216
  confirm: schemaConfirm
34795
35217
  }
34796
35218
  },
@@ -34824,7 +35246,7 @@ function registerMusicTools(server, client2) {
34824
35246
  images_file_uris: args.images_file_uris,
34825
35247
  previous_interaction_id: previousInteractionId
34826
35248
  });
34827
- return client2.session.jobs.dispatch({ toolName: "gemini_music_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
35249
+ return client2.session.jobs.dispatch({ toolName: "gemini_music_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
34828
35250
  const { inputs, report } = await resolveImageInputs(args, client2);
34829
35251
  const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client2.generateMusic({
34830
35252
  input: args.prompt,
@@ -34894,13 +35316,16 @@ function registerFileTools(server, client2) {
34894
35316
  server.registerTool(
34895
35317
  "gemini_upload_file",
34896
35318
  {
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."),
35319
+ 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
35320
  annotations: { readOnlyHint: false, openWorldHint: true },
34899
35321
  inputSchema: {
34900
35322
  url: external_exports.string().url().optional().describe(
34901
35323
  `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
35324
  ),
34903
35325
  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)."),
35326
+ r2_key: external_exports.string().min(1).optional().describe(
35327
+ 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."
35328
+ ),
34904
35329
  path: external_exports.string().min(1).optional().describe(
34905
35330
  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
35331
  ),
@@ -34910,10 +35335,10 @@ function registerFileTools(server, client2) {
34910
35335
  }
34911
35336
  },
34912
35337
  async (args, extra) => {
34913
- const sources = [args.url && "url", args.data_base64 && "data_base64", args.path && "path"].filter(Boolean);
35338
+ const sources = [args.url && "url", args.data_base64 && "data_base64", args.r2_key && "r2_key", args.path && "path"].filter(Boolean);
34914
35339
  if (sources.length !== 1) {
34915
35340
  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(", ")}).`
35341
+ 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
35342
  );
34918
35343
  }
34919
35344
  if (args.path) {
@@ -34942,6 +35367,19 @@ function registerFileTools(server, client2) {
34942
35367
  args.display_name ?? fileNameFromUrl(args.url, "upload")
34943
35368
  );
34944
35369
  }
35370
+ if (args.r2_key) {
35371
+ if (onDisk) {
35372
+ throw new McpToolError(
35373
+ "`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."
35374
+ );
35375
+ }
35376
+ const stored = await client2.readStoredMedia(args.r2_key);
35377
+ return client2.uploadBytes(
35378
+ stored.bytes,
35379
+ args.mime_type ?? stored.mimeType,
35380
+ args.display_name ?? downloadFilename(args.r2_key)
35381
+ );
35382
+ }
34945
35383
  if (args.data_base64) {
34946
35384
  const { decodeImageInput: decodeImageInput2 } = await Promise.resolve().then(() => (init_images(), images_exports));
34947
35385
  const decoded = decodeImageInput2(args.data_base64);
@@ -34988,6 +35426,40 @@ function registerFileTools(server, client2) {
34988
35426
  });
34989
35427
  }
34990
35428
  );
35429
+ if (!onDisk) {
35430
+ server.registerTool(
35431
+ "gemini_sign_media",
35432
+ {
35433
+ 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.",
35434
+ annotations: { readOnlyHint: true, openWorldHint: false },
35435
+ inputSchema: {
35436
+ r2_key: external_exports.string().min(1).describe("The `media[].r2_key` from an earlier generation result")
35437
+ }
35438
+ },
35439
+ async (args) => {
35440
+ const sink = client2.mediaSink;
35441
+ if (!sink?.resign) {
35442
+ 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.", {
35443
+ hint: "gemini_sign_media applies to the hosted connector only."
35444
+ });
35445
+ }
35446
+ const fresh = await sink.resign(args.r2_key.trim());
35447
+ if (!fresh) {
35448
+ throw new McpToolError(
35449
+ `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.`,
35450
+ { hint: "Objects are swept on a retention schedule (MEDIA_TTL_DAYS, default 7 days)." }
35451
+ );
35452
+ }
35453
+ return textResult({
35454
+ url: fresh.ref,
35455
+ r2_key: fresh.key,
35456
+ ...fresh.expiresAt ? { expires_at: fresh.expiresAt } : {},
35457
+ curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? "media")} "${fresh.ref}"`,
35458
+ 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."
35459
+ });
35460
+ }
35461
+ );
35462
+ }
34991
35463
  server.registerTool(
34992
35464
  "gemini_delete_file",
34993
35465
  {