@chrischall/gemini-mcp 1.0.4 → 1.2.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
@@ -1109,11 +1109,11 @@ var init_types = __esm({
1109
1109
  status.dirty();
1110
1110
  }
1111
1111
  } else if (check2.kind === "length") {
1112
- const tooBig = input.data.length > check2.value;
1112
+ const tooBig2 = input.data.length > check2.value;
1113
1113
  const tooSmall = input.data.length < check2.value;
1114
- if (tooBig || tooSmall) {
1114
+ if (tooBig2 || tooSmall) {
1115
1115
  ctx = this._getOrReturnCtx(input, ctx);
1116
- if (tooBig) {
1116
+ if (tooBig2) {
1117
1117
  addIssueToContext(ctx, {
1118
1118
  code: ZodIssueCode.too_big,
1119
1119
  maximum: check2.value,
@@ -1660,8 +1660,8 @@ var init_types = __esm({
1660
1660
  status.dirty();
1661
1661
  }
1662
1662
  } else if (check2.kind === "max") {
1663
- const tooBig = check2.inclusive ? input.data > check2.value : input.data >= check2.value;
1664
- if (tooBig) {
1663
+ const tooBig2 = check2.inclusive ? input.data > check2.value : input.data >= check2.value;
1664
+ if (tooBig2) {
1665
1665
  ctx = this._getOrReturnCtx(input, ctx);
1666
1666
  addIssueToContext(ctx, {
1667
1667
  code: ZodIssueCode.too_big,
@@ -1877,8 +1877,8 @@ var init_types = __esm({
1877
1877
  status.dirty();
1878
1878
  }
1879
1879
  } else if (check2.kind === "max") {
1880
- const tooBig = check2.inclusive ? input.data > check2.value : input.data >= check2.value;
1881
- if (tooBig) {
1880
+ const tooBig2 = check2.inclusive ? input.data > check2.value : input.data >= check2.value;
1881
+ if (tooBig2) {
1882
1882
  ctx = this._getOrReturnCtx(input, ctx);
1883
1883
  addIssueToContext(ctx, {
1884
1884
  code: ZodIssueCode.too_big,
@@ -2292,13 +2292,13 @@ var init_types = __esm({
2292
2292
  return INVALID;
2293
2293
  }
2294
2294
  if (def.exactLength !== null) {
2295
- const tooBig = ctx.data.length > def.exactLength.value;
2295
+ const tooBig2 = ctx.data.length > def.exactLength.value;
2296
2296
  const tooSmall = ctx.data.length < def.exactLength.value;
2297
- if (tooBig || tooSmall) {
2297
+ if (tooBig2 || tooSmall) {
2298
2298
  addIssueToContext(ctx, {
2299
- code: tooBig ? ZodIssueCode.too_big : ZodIssueCode.too_small,
2299
+ code: tooBig2 ? ZodIssueCode.too_big : ZodIssueCode.too_small,
2300
2300
  minimum: tooSmall ? def.exactLength.value : void 0,
2301
- maximum: tooBig ? def.exactLength.value : void 0,
2301
+ maximum: tooBig2 ? def.exactLength.value : void 0,
2302
2302
  type: "array",
2303
2303
  inclusive: true,
2304
2304
  exact: true,
@@ -5462,10 +5462,10 @@ var init_checks = __esm({
5462
5462
  const size = input.size;
5463
5463
  if (size === def.size)
5464
5464
  return;
5465
- const tooBig = size > def.size;
5465
+ const tooBig2 = size > def.size;
5466
5466
  payload.issues.push({
5467
5467
  origin: getSizableOrigin(input),
5468
- ...tooBig ? { code: "too_big", maximum: def.size } : { code: "too_small", minimum: def.size },
5468
+ ...tooBig2 ? { code: "too_big", maximum: def.size } : { code: "too_small", minimum: def.size },
5469
5469
  inclusive: true,
5470
5470
  exact: true,
5471
5471
  input: payload.value,
@@ -5551,10 +5551,10 @@ var init_checks = __esm({
5551
5551
  if (length === def.length)
5552
5552
  return;
5553
5553
  const origin = getLengthableOrigin(input);
5554
- const tooBig = length > def.length;
5554
+ const tooBig2 = length > def.length;
5555
5555
  payload.issues.push({
5556
5556
  origin,
5557
- ...tooBig ? { code: "too_big", maximum: def.length } : { code: "too_small", minimum: def.length },
5557
+ ...tooBig2 ? { code: "too_big", maximum: def.length } : { code: "too_small", minimum: def.length },
5558
5558
  inclusive: true,
5559
5559
  exact: true,
5560
5560
  input: payload.value,
@@ -25669,11 +25669,11 @@ var require_resolve = __commonJS({
25669
25669
  return id ? id.replace(TRAILING_SLASH_HASH, "") : "";
25670
25670
  }
25671
25671
  exports.normalizeId = normalizeId;
25672
- function resolveUrl(resolver, baseId, id) {
25672
+ function resolveUrl2(resolver, baseId, id) {
25673
25673
  id = normalizeId(id);
25674
25674
  return resolver.resolve(baseId, id);
25675
25675
  }
25676
- exports.resolveUrl = resolveUrl;
25676
+ exports.resolveUrl = resolveUrl2;
25677
25677
  var ANCHOR = /^[a-z_][-a-z0-9._]*$/i;
25678
25678
  function getSchemaRefs(schema, baseId) {
25679
25679
  if (typeof schema == "boolean")
@@ -32548,73 +32548,11 @@ var init_dist = __esm({
32548
32548
  }
32549
32549
  });
32550
32550
 
32551
- // src/clipboard.ts
32552
- var clipboard_exports = {};
32553
- __export(clipboard_exports, {
32554
- readClipboardImage: () => readClipboardImage
32555
- });
32556
- import { execFile } from "node:child_process";
32557
- import { promisify } from "node:util";
32558
- import { readFile, unlink } from "node:fs/promises";
32559
- import { tmpdir } from "node:os";
32560
- import { join as join2 } from "node:path";
32561
- async function readClipboardImage(run = defaultRun) {
32562
- if (process.platform !== "darwin") {
32563
- throw new McpToolError("from_clipboard is only supported on macOS", {
32564
- hint: "Save the image to a file and pass it via `images` instead."
32565
- });
32566
- }
32567
- const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
32568
- const png = join2(tmpdir(), `gemini-clip-${id}.png`);
32569
- const jpg = join2(tmpdir(), `gemini-clip-${id}.jpg`);
32570
- try {
32571
- try {
32572
- await run("osascript", [
32573
- "-e",
32574
- `set f to (open for access POSIX file "${png}" with write permission)`,
32575
- "-e",
32576
- "write (the clipboard as \xABclass PNGf\xBB) to f",
32577
- "-e",
32578
- "close access f"
32579
- ]);
32580
- } catch {
32581
- throw new McpToolError("No image found on the clipboard", {
32582
- hint: "Copy an image (\u2318C) first, or pass `images`/`images_base64`."
32583
- });
32584
- }
32585
- try {
32586
- await run("sips", ["-Z", "2048", "-s", "format", "jpeg", png, "--out", jpg]);
32587
- } catch {
32588
- throw new McpToolError("Failed to convert the clipboard image", {
32589
- hint: "The clipboard may contain a non-standard image format."
32590
- });
32591
- }
32592
- const buf = await readFile(jpg);
32593
- return { base64: buf.toString("base64"), mimeType: "image/jpeg" };
32594
- } finally {
32595
- await unlink(png).catch(() => void 0);
32596
- await unlink(jpg).catch(() => void 0);
32597
- }
32598
- }
32599
- var execFileAsync, defaultRun;
32600
- var init_clipboard = __esm({
32601
- "src/clipboard.ts"() {
32602
- "use strict";
32603
- init_dist();
32604
- execFileAsync = promisify(execFile);
32605
- defaultRun = async (cmd, args) => {
32606
- await execFileAsync(cmd, args);
32607
- };
32608
- }
32609
- });
32610
-
32611
32551
  // src/images.ts
32612
32552
  var images_exports = {};
32613
32553
  __export(images_exports, {
32614
32554
  baseName: () => baseName,
32615
32555
  decodeImageInput: () => decodeImageInput,
32616
- gatherImageInputs: () => gatherImageInputs,
32617
- loadImageInputs: () => loadImageInputs,
32618
32556
  mediaExt: () => mediaExt,
32619
32557
  previewImageInput: () => previewImageInput,
32620
32558
  previewVideoInput: () => previewVideoInput,
@@ -32629,9 +32567,9 @@ __export(images_exports, {
32629
32567
  writeMedia: () => writeMedia,
32630
32568
  writeSidecar: () => writeSidecar
32631
32569
  });
32632
- import { mkdir, readFile as readFile2, writeFile, access, stat, open } from "node:fs/promises";
32570
+ import { mkdir, readFile, writeFile, access, stat, open } from "node:fs/promises";
32633
32571
  import { existsSync } from "node:fs";
32634
- import { join as join3, resolve, isAbsolute } from "node:path";
32572
+ import { join as join2, resolve, isAbsolute } from "node:path";
32635
32573
  function slugify2(text, max = 40) {
32636
32574
  const s = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/g, "");
32637
32575
  return s || "image";
@@ -32645,10 +32583,10 @@ async function exists(p) {
32645
32583
  }
32646
32584
  }
32647
32585
  async function uniquePath(dir, base, ext) {
32648
- let candidate = join3(dir, `${base}.${ext}`);
32586
+ let candidate = join2(dir, `${base}.${ext}`);
32649
32587
  let n = 2;
32650
32588
  while (await exists(candidate)) {
32651
- candidate = join3(dir, `${base}-${n}.${ext}`);
32589
+ candidate = join2(dir, `${base}-${n}.${ext}`);
32652
32590
  n++;
32653
32591
  }
32654
32592
  return candidate;
@@ -32694,7 +32632,7 @@ function resolveInputPath(p, kind) {
32694
32632
  if (isAbsolute(p) && existsSync(p)) return p;
32695
32633
  const inputDir = readEnvVar("GEMINI_INPUT_DIR");
32696
32634
  if (inputDir) {
32697
- const candidate = join3(inputDir, p);
32635
+ const candidate = join2(inputDir, p);
32698
32636
  if (existsSync(candidate)) return resolve(candidate);
32699
32637
  }
32700
32638
  const cwd = resolve(p);
@@ -32740,7 +32678,7 @@ async function previewVideoInput(path) {
32740
32678
  }
32741
32679
  async function readImageAsInline(path) {
32742
32680
  const resolved = resolveImagePath(path);
32743
- const buf = await readFile2(resolved);
32681
+ const buf = await readFile(resolved);
32744
32682
  const mimeType = sniffMimeBytes(buf);
32745
32683
  return { base64: buf.toString("base64"), mimeType };
32746
32684
  }
@@ -32757,16 +32695,6 @@ function decodeImageInput(input) {
32757
32695
  const buf = Buffer.from(trimmed, "base64");
32758
32696
  return { base64: trimmed, mimeType: sniffMimeBytes(buf) };
32759
32697
  }
32760
- async function loadImageInputs(paths = [], base64s = []) {
32761
- const fromPaths = await Promise.all(paths.map((p) => readImageAsInline(p)));
32762
- const fromBase64s = base64s.map((b) => decodeImageInput(b));
32763
- return [...fromPaths, ...fromBase64s];
32764
- }
32765
- async function gatherImageInputs(opts) {
32766
- const clipboardImages = opts.from_clipboard ? [await (await Promise.resolve().then(() => (init_clipboard(), clipboard_exports))).readClipboardImage()] : [];
32767
- const fileImages = await loadImageInputs(opts.images, opts.images_base64);
32768
- return [...clipboardImages, ...fileImages];
32769
- }
32770
32698
  function baseName(name) {
32771
32699
  const stripped = name.replace(/\.(png|jpe?g|webp|mp4|mov|webm|mp3|wav|ogg)$/i, "");
32772
32700
  const safe = stripped.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
@@ -32795,6 +32723,66 @@ var init_images = __esm({
32795
32723
  }
32796
32724
  });
32797
32725
 
32726
+ // src/clipboard.ts
32727
+ var clipboard_exports = {};
32728
+ __export(clipboard_exports, {
32729
+ readClipboardImage: () => readClipboardImage
32730
+ });
32731
+ import { execFile } from "node:child_process";
32732
+ import { promisify } from "node:util";
32733
+ import { readFile as readFile2, unlink } from "node:fs/promises";
32734
+ import { tmpdir } from "node:os";
32735
+ import { join as join3 } from "node:path";
32736
+ async function readClipboardImage(run = defaultRun) {
32737
+ if (process.platform !== "darwin") {
32738
+ throw new McpToolError("from_clipboard is only supported on macOS", {
32739
+ hint: "Save the image to a file and pass it via `images` instead."
32740
+ });
32741
+ }
32742
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
32743
+ const png = join3(tmpdir(), `gemini-clip-${id}.png`);
32744
+ const jpg = join3(tmpdir(), `gemini-clip-${id}.jpg`);
32745
+ try {
32746
+ try {
32747
+ await run("osascript", [
32748
+ "-e",
32749
+ `set f to (open for access POSIX file "${png}" with write permission)`,
32750
+ "-e",
32751
+ "write (the clipboard as \xABclass PNGf\xBB) to f",
32752
+ "-e",
32753
+ "close access f"
32754
+ ]);
32755
+ } catch {
32756
+ throw new McpToolError("No image found on the clipboard", {
32757
+ hint: "Copy an image (\u2318C) first, or pass `images`/`images_base64`."
32758
+ });
32759
+ }
32760
+ try {
32761
+ await run("sips", ["-Z", "2048", "-s", "format", "jpeg", png, "--out", jpg]);
32762
+ } catch {
32763
+ throw new McpToolError("Failed to convert the clipboard image", {
32764
+ hint: "The clipboard may contain a non-standard image format."
32765
+ });
32766
+ }
32767
+ const buf = await readFile2(jpg);
32768
+ return { base64: buf.toString("base64"), mimeType: "image/jpeg" };
32769
+ } finally {
32770
+ await unlink(png).catch(() => void 0);
32771
+ await unlink(jpg).catch(() => void 0);
32772
+ }
32773
+ }
32774
+ var execFileAsync, defaultRun;
32775
+ var init_clipboard = __esm({
32776
+ "src/clipboard.ts"() {
32777
+ "use strict";
32778
+ init_dist();
32779
+ execFileAsync = promisify(execFile);
32780
+ defaultRun = async (cmd, args) => {
32781
+ await execFileAsync(cmd, args);
32782
+ };
32783
+ }
32784
+ });
32785
+
32798
32786
  // src/index.ts
32799
32787
  init_dist();
32800
32788
 
@@ -32808,7 +32796,7 @@ async function loadStdioDotenv() {
32808
32796
  }
32809
32797
 
32810
32798
  // src/version.ts
32811
- var VERSION = "1.0.4";
32799
+ var VERSION = "1.2.0";
32812
32800
 
32813
32801
  // src/tools/models.ts
32814
32802
  init_dist();
@@ -32848,6 +32836,370 @@ function filterImageModels(raw) {
32848
32836
  // src/tools/generate.ts
32849
32837
  init_images();
32850
32838
 
32839
+ // src/inputs.ts
32840
+ init_dist();
32841
+
32842
+ // src/fetch-image.ts
32843
+ init_dist();
32844
+
32845
+ // src/bytes.ts
32846
+ function base64ToBytes(base643) {
32847
+ const binary = atob(base643);
32848
+ const bytes = new Uint8Array(binary.length);
32849
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
32850
+ return bytes;
32851
+ }
32852
+ function bytesToBase64(bytes) {
32853
+ const CHUNK = 32768;
32854
+ let binary = "";
32855
+ for (let i = 0; i < bytes.length; i += CHUNK) {
32856
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
32857
+ }
32858
+ return btoa(binary);
32859
+ }
32860
+ function wholeMb(bytes) {
32861
+ return Math.round(bytes / (1024 * 1024));
32862
+ }
32863
+ function formatMb(bytes) {
32864
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
32865
+ }
32866
+
32867
+ // src/fetch-image.ts
32868
+ var IMAGE_URL_MAX_BYTES = 15 * 1024 * 1024;
32869
+ var IMAGE_INLINE_MAX_BYTES = 6 * 1024 * 1024;
32870
+ var MAX_REDIRECTS = 5;
32871
+ var DEFAULT_FETCH_TIMEOUT_MS = 6e4;
32872
+ function ipv4Octets(host) {
32873
+ const parts = host.split(".");
32874
+ if (parts.length !== 4) return void 0;
32875
+ const octets = parts.map((p) => /^\d{1,3}$/.test(p) ? Number(p) : NaN);
32876
+ return octets.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? octets : void 0;
32877
+ }
32878
+ function isPrivateIpv4([a, b]) {
32879
+ if (a === 0 || a === 127) return true;
32880
+ if (a === 10) return true;
32881
+ if (a === 172 && b >= 16 && b <= 31) return true;
32882
+ if (a === 192 && b === 168) return true;
32883
+ if (a === 169 && b === 254) return true;
32884
+ if (a === 100 && b >= 64 && b <= 127) return true;
32885
+ if (a === 192 && b === 0) return true;
32886
+ if (a === 198 && (b === 18 || b === 19)) return true;
32887
+ if (a >= 224) return true;
32888
+ return false;
32889
+ }
32890
+ function ipv6Groups(host) {
32891
+ if (!host.includes(":")) return void 0;
32892
+ let text = host;
32893
+ const tail = [];
32894
+ const dotted = /:((?:\d{1,3}\.){3}\d{1,3})$/.exec(text);
32895
+ if (dotted) {
32896
+ const octets = ipv4Octets(dotted[1]);
32897
+ if (!octets) return void 0;
32898
+ tail.push(octets[0] << 8 | octets[1], octets[2] << 8 | octets[3]);
32899
+ text = text.slice(0, dotted.index);
32900
+ }
32901
+ const halves = text.split("::");
32902
+ if (halves.length > 2) return void 0;
32903
+ const parse3 = (part) => {
32904
+ if (part === "") return [];
32905
+ const out = [];
32906
+ for (const piece of part.split(":")) {
32907
+ if (!/^[0-9a-f]{1,4}$/.test(piece)) return void 0;
32908
+ out.push(parseInt(piece, 16));
32909
+ }
32910
+ return out;
32911
+ };
32912
+ const head = parse3(halves[0]);
32913
+ const rest = halves.length === 2 ? parse3(halves[1]) : [];
32914
+ if (!head || !rest) return void 0;
32915
+ const explicit = [...head, ...rest, ...tail];
32916
+ if (halves.length === 2) {
32917
+ if (explicit.length > 7) return void 0;
32918
+ return [...head, ...Array(8 - explicit.length).fill(0), ...rest, ...tail];
32919
+ }
32920
+ return explicit.length === 8 ? explicit : void 0;
32921
+ }
32922
+ var V4_BEARING_PREFIXES = [
32923
+ [0, 0, 0, 0, 0, 65535],
32924
+ // ::ffff:0:0/96 — IPv4-mapped
32925
+ [0, 0, 0, 0, 0, 0],
32926
+ // ::/96 — IPv4-compatible (deprecated)
32927
+ [0, 0, 0, 0, 65535, 0],
32928
+ // ::ffff:0:0:0/96 — IPv4-translated (deprecated SIIT)
32929
+ [100, 65435, 0, 0, 0, 0]
32930
+ // 64:ff9b::/96 — NAT64 well-known (RFC 6052)
32931
+ ];
32932
+ function isPrivateIpv6(groups) {
32933
+ if (groups.every((g) => g === 0)) return true;
32934
+ if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return true;
32935
+ if ((groups[0] & 65472) === 65152) return true;
32936
+ if ((groups[0] & 65024) === 64512) return true;
32937
+ if (V4_BEARING_PREFIXES.some((prefix) => prefix.every((g, i) => groups[i] === g))) {
32938
+ return isPrivateIpv4([groups[6] >> 8, groups[6] & 255, groups[7] >> 8, groups[7] & 255]);
32939
+ }
32940
+ return false;
32941
+ }
32942
+ function assertPublicHttpsUrl(raw, requested) {
32943
+ let url2;
32944
+ try {
32945
+ url2 = new URL(raw);
32946
+ } catch {
32947
+ throw new McpToolError(`Not a valid URL: ${raw}`, {
32948
+ hint: "images_url entries must be absolute https:// URLs."
32949
+ });
32950
+ }
32951
+ if (url2.protocol !== "https:") {
32952
+ throw new McpToolError(`Refusing to fetch ${describeUrl(raw, requested)}: only https:// URLs are fetched (got "${url2.protocol}").`, {
32953
+ hint: "Use an https:// URL, or send the bytes with images_base64 / gemini_upload_file."
32954
+ });
32955
+ }
32956
+ const rawHost = url2.hostname.toLowerCase();
32957
+ const isIpv6Literal = rawHost.startsWith("[") && rawHost.endsWith("]");
32958
+ const host = isIpv6Literal ? rawHost.slice(1, -1) : rawHost;
32959
+ const v4 = isIpv6Literal ? void 0 : ipv4Octets(host);
32960
+ const v6 = isIpv6Literal ? ipv6Groups(host) : void 0;
32961
+ const blocked = isIpv6Literal ? v6 === void 0 || isPrivateIpv6(v6) : host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || (v4 ? isPrivateIpv4(v4) : false);
32962
+ if (blocked) {
32963
+ throw new McpToolError(
32964
+ `Refusing to fetch ${describeUrl(raw, requested)}: "${url2.hostname}" is a private, loopback or link-local address, not a public host.`,
32965
+ { hint: "images_url is fetched by the server, so it must point at a publicly reachable https:// image." }
32966
+ );
32967
+ }
32968
+ return url2;
32969
+ }
32970
+ function fileNameFromUrl(url2, fallback) {
32971
+ try {
32972
+ const name = new URL(url2).pathname.split("/").filter(Boolean).pop();
32973
+ return name ? decodeURIComponent(name).slice(0, 120) : fallback;
32974
+ } catch {
32975
+ return fallback;
32976
+ }
32977
+ }
32978
+ function describeUrl(current, requested) {
32979
+ return current === requested ? current : `${current} (redirected from ${requested})`;
32980
+ }
32981
+ async function readCapped(res, maxBytes, url2, requested) {
32982
+ const declared = Number(res.headers.get("content-length"));
32983
+ if (Number.isFinite(declared) && declared > maxBytes) {
32984
+ throw tooBig(url2, requested, declared, maxBytes);
32985
+ }
32986
+ const reader = res.body?.getReader();
32987
+ if (!reader) {
32988
+ const buf = new Uint8Array(await res.arrayBuffer());
32989
+ if (buf.byteLength > maxBytes) throw tooBig(url2, requested, buf.byteLength, maxBytes);
32990
+ return buf;
32991
+ }
32992
+ const chunks = [];
32993
+ let total = 0;
32994
+ try {
32995
+ for (; ; ) {
32996
+ const { done, value } = await reader.read();
32997
+ if (done) break;
32998
+ if (!value) continue;
32999
+ total += value.byteLength;
33000
+ if (total > maxBytes) {
33001
+ await reader.cancel().catch(() => {
33002
+ });
33003
+ throw tooBig(url2, requested, total, maxBytes, true);
33004
+ }
33005
+ chunks.push(value);
33006
+ }
33007
+ } finally {
33008
+ reader.releaseLock?.();
33009
+ }
33010
+ const out = new Uint8Array(total);
33011
+ let offset = 0;
33012
+ for (const c of chunks) {
33013
+ out.set(c, offset);
33014
+ offset += c.byteLength;
33015
+ }
33016
+ return out;
33017
+ }
33018
+ function fetchFailure(err, current, requested, timeoutMs) {
33019
+ const name = err?.name;
33020
+ if (name === "TimeoutError" || name === "AbortError") {
33021
+ return new McpToolError(`Fetching ${describeUrl(current, requested)} timed out after ${timeoutMs}ms.`, {
33022
+ hint: "The server accepted the connection but did not finish sending the image. Try a different URL, or raise timeout_ms."
33023
+ });
33024
+ }
33025
+ return new McpToolError(
33026
+ `Could not fetch ${describeUrl(current, requested)}: ${err instanceof Error ? err.message : String(err)}`,
33027
+ { hint: "Check the URL is reachable from the server and serves the image without authentication." }
33028
+ );
33029
+ }
33030
+ function tooBig(url2, requested, size, max, partial2 = false) {
33031
+ return new McpToolError(
33032
+ `Image at ${describeUrl(url2, requested)} is ${partial2 ? "over" : ""} ${formatMb(size)}, above the ${formatMb(max)} limit for images_url.`,
33033
+ { hint: "Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri." }
33034
+ );
33035
+ }
33036
+ async function fetchRemoteImage(requestedUrl, opts = {}) {
33037
+ const doFetch = opts.fetchImpl ?? fetch;
33038
+ const maxBytes = opts.maxBytes ?? IMAGE_URL_MAX_BYTES;
33039
+ const accept = opts.accept ?? ["image/"];
33040
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
33041
+ let current = requestedUrl;
33042
+ for (let hop = 0; ; hop++) {
33043
+ const url2 = assertPublicHttpsUrl(current, requestedUrl);
33044
+ const signal = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : void 0;
33045
+ let res;
33046
+ try {
33047
+ res = await doFetch(url2.toString(), {
33048
+ redirect: "manual",
33049
+ headers: { accept: accept.map((p) => `${p.replace(/\/$/, "")}/*`).join(", ") },
33050
+ ...signal ? { signal } : {}
33051
+ });
33052
+ } catch (err) {
33053
+ throw fetchFailure(err, current, requestedUrl, timeoutMs);
33054
+ }
33055
+ if (res.status >= 300 && res.status < 400) {
33056
+ const location = res.headers.get("location");
33057
+ if (!location) {
33058
+ throw new McpToolError(`Fetching ${describeUrl(current, requestedUrl)} returned HTTP ${res.status} with no Location header.`);
33059
+ }
33060
+ if (hop >= MAX_REDIRECTS) {
33061
+ throw new McpToolError(`Fetching ${requestedUrl} exceeded ${MAX_REDIRECTS} redirects (last hop: ${location}).`);
33062
+ }
33063
+ current = new URL(location, url2).toString();
33064
+ continue;
33065
+ }
33066
+ if (!res.ok) {
33067
+ throw new McpToolError(`Fetching ${describeUrl(current, requestedUrl)} returned HTTP ${res.status} ${res.statusText || ""}`.trim(), {
33068
+ hint: "The URL must be publicly readable \u2014 a login page or signed-URL expiry shows up here as 401/403/404."
33069
+ });
33070
+ }
33071
+ const contentType = (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
33072
+ if (!accept.some((prefix) => contentType.startsWith(prefix))) {
33073
+ const expected = accept.map((p) => `${p.replace(/\/$/, "")}/*`).join(" or ");
33074
+ throw new McpToolError(
33075
+ `${describeUrl(current, requestedUrl)} is not ${accept.length === 1 && accept[0] === "image/" ? "an image" : "usable media"}: the server returned Content-Type "${contentType || "(none)"}", expected ${expected}.`,
33076
+ { hint: "Link directly to the media file, not to the page that displays it." }
33077
+ );
33078
+ }
33079
+ let bytes;
33080
+ try {
33081
+ bytes = await readCapped(res, maxBytes, current, requestedUrl);
33082
+ } catch (err) {
33083
+ if (err instanceof McpToolError) throw err;
33084
+ throw fetchFailure(err, current, requestedUrl, timeoutMs);
33085
+ }
33086
+ if (bytes.byteLength === 0) {
33087
+ throw new McpToolError(`${describeUrl(current, requestedUrl)} returned an empty body.`);
33088
+ }
33089
+ return { bytes, mimeType: contentType, size: bytes.byteLength, finalUrl: current, requestedUrl };
33090
+ }
33091
+ }
33092
+
33093
+ // src/inputs.ts
33094
+ function hasImageInput(args) {
33095
+ return Boolean(
33096
+ args.images?.length || args.images_base64?.length || args.images_url?.length || args.images_file_uris?.length || args.from_clipboard
33097
+ );
33098
+ }
33099
+ var ASSUMED_TTL_MS = 47 * 60 * 60 * 1e3;
33100
+ function expiresAtMs(expirationTime, now) {
33101
+ if (expirationTime) {
33102
+ const t = Date.parse(expirationTime);
33103
+ if (Number.isFinite(t)) return t;
33104
+ }
33105
+ return now + ASSUMED_TTL_MS;
33106
+ }
33107
+ async function resolveImageInputs(args, client2) {
33108
+ const report = {};
33109
+ const inputs = [];
33110
+ const onDisk = client2.mediaSink?.persistsFiles ?? true;
33111
+ if (args.from_clipboard) {
33112
+ inputs.push(await (await Promise.resolve().then(() => (init_clipboard(), clipboard_exports))).readClipboardImage());
33113
+ }
33114
+ for (const path of args.images ?? []) {
33115
+ inputs.push(await resolveLocalPath(path, client2, onDisk, report));
33116
+ }
33117
+ if (args.images_base64?.length) {
33118
+ const { decodeImageInput: decodeImageInput2 } = await Promise.resolve().then(() => (init_images(), images_exports));
33119
+ for (const b64 of args.images_base64) inputs.push(decodeImageInput2(b64));
33120
+ }
33121
+ const fetched = /* @__PURE__ */ new Map();
33122
+ for (const url2 of args.images_url ?? []) {
33123
+ const hit = fetched.get(url2);
33124
+ if (hit) {
33125
+ inputs.push(hit);
33126
+ continue;
33127
+ }
33128
+ const resolved = await resolveUrl(url2, client2, report);
33129
+ fetched.set(url2, resolved);
33130
+ inputs.push(resolved);
33131
+ }
33132
+ const seenUris = /* @__PURE__ */ new Map();
33133
+ for (const ref of args.images_file_uris ?? []) {
33134
+ const hit = seenUris.get(ref);
33135
+ if (hit) {
33136
+ inputs.push(hit);
33137
+ continue;
33138
+ }
33139
+ const file2 = await client2.getFile(ref);
33140
+ const input = { uri: file2.uri, mimeType: file2.mimeType };
33141
+ seenUris.set(ref, input);
33142
+ (report.file_uris ??= []).push(file2.name);
33143
+ inputs.push(input);
33144
+ }
33145
+ return { inputs, report: Object.keys(report).length > 0 ? report : void 0 };
33146
+ }
33147
+ async function resolveLocalPath(path, client2, onDisk, report) {
33148
+ const { readImageAsInline: readImageAsInline2, resolveImagePath: resolveImagePath2 } = await Promise.resolve().then(() => (init_images(), images_exports));
33149
+ if (!onDisk) return readImageAsInline2(path);
33150
+ const { stat: stat3 } = await import("node:fs/promises");
33151
+ const resolved = resolveImagePath2(path);
33152
+ let key;
33153
+ try {
33154
+ const { mtimeMs, size } = await stat3(resolved);
33155
+ key = `path\0${resolved}\0${mtimeMs}\0${size}`;
33156
+ } catch {
33157
+ return readImageAsInline2(path);
33158
+ }
33159
+ const cached2 = client2.session.cachedUpload(key);
33160
+ if (cached2) return { uri: cached2.uri, mimeType: cached2.mimeType };
33161
+ const references = client2.session.countReference(key);
33162
+ const inline = await readImageAsInline2(resolved);
33163
+ if (references < 2) return inline;
33164
+ try {
33165
+ const bytes = base64ToBytes(inline.base64);
33166
+ const displayName = resolved.split(/[\\/]/).pop() || "image";
33167
+ const uploaded = await client2.uploadBytes(bytes, inline.mimeType, displayName);
33168
+ client2.session.uploadCache.set(key, {
33169
+ name: uploaded.name,
33170
+ uri: uploaded.uri,
33171
+ mimeType: uploaded.mimeType,
33172
+ expiresAtMs: expiresAtMs(uploaded.expirationTime, Date.now())
33173
+ });
33174
+ (report.auto_uploaded ??= []).push({ path: resolved, file_uri: uploaded.name, expires: uploaded.expirationTime });
33175
+ return { uri: uploaded.uri, mimeType: uploaded.mimeType };
33176
+ } catch {
33177
+ return inline;
33178
+ }
33179
+ }
33180
+ async function resolveUrl(url2, client2, report) {
33181
+ const fetched = await client2.fetchRemoteImage(url2);
33182
+ const entry = {
33183
+ url: url2,
33184
+ mime_type: fetched.mimeType,
33185
+ bytes: fetched.size
33186
+ };
33187
+ (report.fetched_urls ??= []).push(entry);
33188
+ if (fetched.size <= IMAGE_INLINE_MAX_BYTES) {
33189
+ return { base64: bytesToBase64(fetched.bytes), mimeType: fetched.mimeType };
33190
+ }
33191
+ const displayName = fileNameFromUrl(url2, "image");
33192
+ const uploaded = await client2.uploadBytes(fetched.bytes, fetched.mimeType, displayName);
33193
+ entry.file_uri = uploaded.name;
33194
+ return { uri: uploaded.uri, mimeType: uploaded.mimeType };
33195
+ }
33196
+ function requireImageInput(args) {
33197
+ if (hasImageInput(args)) return;
33198
+ 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`."
33200
+ );
33201
+ }
33202
+
32851
33203
  // src/tools/shared.ts
32852
33204
  init_zod();
32853
33205
  init_dist();
@@ -32883,6 +33235,16 @@ var idempotencyKeySchema = external_exports.string().min(1).optional().describe(
32883
33235
  var asyncSchema = external_exports.boolean().optional().describe(
32884
33236
  "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)."
32885
33237
  );
33238
+ function imagesUrlSchema(label = "Reference images") {
33239
+ return external_exports.array(external_exports.string().url()).optional().describe(
33240
+ `${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/*).`
33241
+ );
33242
+ }
33243
+ function imagesFileUrisSchema(label = "Reference images") {
33244
+ return external_exports.array(external_exports.string().min(1)).optional().describe(
33245
+ `${label} by Gemini Files API reference ("files/<id>", or the full uri) from gemini_upload_file or POST /upload. Upload once, then reference it across as many calls as you like \u2014 no bytes are re-sent and none enter the conversation. Files are retained ~48h, after which the reference stops resolving.`
33246
+ );
33247
+ }
32886
33248
  var sharedImageSchema = {
32887
33249
  // Bare id only: the model is interpolated into the URL path
32888
33250
  // (`/models/${model}:generateContent`), so slashes/colons/queries must be
@@ -33004,7 +33366,7 @@ function assertLocalInputsAvailable(sink, args) {
33004
33366
  if (args.from_clipboard) unavailable.push("`from_clipboard` (reads the macOS clipboard via child_process)");
33005
33367
  if (args.video_path) unavailable.push("`video_path` (uploads a file streamed from disk)");
33006
33368
  if (unavailable.length === 0) return;
33007
- const fix = "Send image bytes as `images_base64` (raw base64 or a data: URI) instead of `images`/`master_images`/`from_clipboard`, and reference video by `video_url` (a public YouTube URL, or a files/\u2026 uri uploaded elsewhere) instead of `video_path`.";
33369
+ const fix = "Reference images WITHOUT putting bytes in the conversation, in order of preference: (1) `images_url` \u2014 public https URLs the server fetches itself; (2) `images_file_uris` \u2014 upload once with `gemini_upload_file` (it takes a `url` or `data_base64`) and reuse the returned files/\u2026 uri; (3) `POST /upload` on this connector with the raw bytes as the body (`curl -X POST <connector-url>/upload -H 'Authorization: Bearer <token>' -H 'Content-Type: image/jpeg' --data-binary @photo.jpg`), then pass the returned file_uri. As a last resort `images_base64` still works, but it costs ~14k tokens per photo and breaks on a truncated read. Reference video by `video_url` (a public YouTube URL, or a files/\u2026 uri uploaded elsewhere) instead of `video_path`.";
33008
33370
  throw new McpToolError(
33009
33371
  `${unavailable.join(", ")} ${unavailable.length > 1 ? "are" : "is"} unavailable on the hosted connector: it runs on a Cloudflare Worker, which has no filesystem and no local clipboard. ${fix}`,
33010
33372
  { hint: fix }
@@ -33197,7 +33559,9 @@ function registerGenerateTools(server, client2) {
33197
33559
  count: external_exports.number().int().positive().max(8).optional().describe("Number of independent images (default 1)"),
33198
33560
  filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
33199
33561
  images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to reference input images (image-conditioned generation)"),
33200
- images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs"),
33562
+ images_url: imagesUrlSchema(),
33563
+ images_file_uris: imagesFileUrisSchema(),
33564
+ 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"),
33201
33565
  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)"),
33202
33566
  video_path: videoPathSchema,
33203
33567
  confirm: schemaConfirm,
@@ -33221,13 +33585,15 @@ function registerGenerateTools(server, client2) {
33221
33585
  images: args.images,
33222
33586
  images_base64: args.images_base64,
33223
33587
  from_clipboard: args.from_clipboard,
33588
+ images_url: args.images_url,
33589
+ images_file_uris: args.images_file_uris,
33224
33590
  video_url: args.video_url,
33225
33591
  video_path: args.video_path
33226
33592
  });
33227
33593
  return client2.session.jobs.dispatch({ toolName: "gemini_image_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33228
33594
  const seed = pickSeed(args.seed);
33229
33595
  const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33230
- const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33596
+ const { inputs: refInputs, report } = await withProgressHeartbeat(extra, "Resolving reference images", () => resolveImageInputs(args, client2));
33231
33597
  const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args, client2));
33232
33598
  const named = [];
33233
33599
  let capturedText;
@@ -33256,6 +33622,7 @@ function registerGenerateTools(server, client2) {
33256
33622
  const meta3 = buildMeta(model, seed, args);
33257
33623
  if (capturedText) meta3.text = capturedText;
33258
33624
  if (capturedGrounding) meta3.grounding = capturedGrounding;
33625
+ if (report) meta3.image_inputs = report;
33259
33626
  if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
33260
33627
  meta3.hint = REFINE_HINT;
33261
33628
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, count, persistsFiles: client2.mediaSink?.persistsFiles });
@@ -33272,7 +33639,9 @@ function registerGenerateTools(server, client2) {
33272
33639
  inputSchema: {
33273
33640
  prompt: external_exports.string().min(1).describe("Instruction describing the edit or composition"),
33274
33641
  images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to input image file(s) (1 = edit, 2+ = compose)"),
33275
- images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Input images as base64 strings or data URIs"),
33642
+ images_url: imagesUrlSchema("Input images"),
33643
+ images_file_uris: imagesFileUrisSchema("Input images"),
33644
+ 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"),
33276
33645
  filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
33277
33646
  confirm: schemaConfirm,
33278
33647
  ...sharedImageSchema
@@ -33280,11 +33649,7 @@ function registerGenerateTools(server, client2) {
33280
33649
  },
33281
33650
  async (args, extra) => {
33282
33651
  assertLocalInputsAvailable(client2.mediaSink, args);
33283
- const hasPaths = (args.images?.length ?? 0) > 0;
33284
- const hasBase64 = (args.images_base64?.length ?? 0) > 0;
33285
- if (!hasPaths && !hasBase64 && !args.from_clipboard) {
33286
- throw new McpToolError("Provide at least one input image via `images`, `images_base64`, or `from_clipboard`.");
33287
- }
33652
+ requireImageInput(args);
33288
33653
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
33289
33654
  if (gate) return gate;
33290
33655
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
@@ -33297,12 +33662,14 @@ function registerGenerateTools(server, client2) {
33297
33662
  google_search: args.google_search,
33298
33663
  images: args.images,
33299
33664
  images_base64: args.images_base64,
33300
- from_clipboard: args.from_clipboard
33665
+ from_clipboard: args.from_clipboard,
33666
+ images_url: args.images_url,
33667
+ images_file_uris: args.images_file_uris
33301
33668
  });
33302
33669
  return client2.session.jobs.dispatch({ toolName: "gemini_image_edit", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33303
33670
  const seed = pickSeed(args.seed);
33304
33671
  const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33305
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33672
+ const { inputs, report } = await withProgressHeartbeat(extra, "Resolving input images", () => resolveImageInputs(args, client2));
33306
33673
  const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client2.generate({
33307
33674
  prompt: args.prompt,
33308
33675
  images: inputs,
@@ -33318,6 +33685,7 @@ function registerGenerateTools(server, client2) {
33318
33685
  const meta3 = buildMeta(model, seed, args);
33319
33686
  if (text) meta3.text = text;
33320
33687
  if (result.grounding) meta3.grounding = result.grounding;
33688
+ if (report) meta3.image_inputs = report;
33321
33689
  meta3.hint = REFINE_HINT;
33322
33690
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: client2.mediaSink?.persistsFiles });
33323
33691
  if (risk) meta3.timeout_risk = risk;
@@ -33344,7 +33712,9 @@ function registerSetTools(server, client2) {
33344
33712
  reference_mode: external_exports.enum(["master", "chain"]).optional().describe("master: every image references the master (default). chain: each references the previous."),
33345
33713
  basename: external_exports.string().optional().describe("Base filename prefix for output images (default: slugified master_prompt)"),
33346
33714
  master_images: external_exports.array(external_exports.string().min(1)).optional().describe("Reference image paths passed to the master generation call"),
33347
- master_images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs for master generation"),
33715
+ master_images_url: imagesUrlSchema("Reference images passed to the master AND to every scene call (fetched once)"),
33716
+ master_images_file_uris: imagesFileUrisSchema("Reference images passed to the master AND to every scene call"),
33717
+ 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"),
33348
33718
  confirm: schemaConfirm,
33349
33719
  ...sharedImageSchema
33350
33720
  }
@@ -33369,13 +33739,24 @@ function registerSetTools(server, client2) {
33369
33739
  google_search: args.google_search,
33370
33740
  master_images: args.master_images,
33371
33741
  master_images_base64: args.master_images_base64,
33372
- from_clipboard: args.from_clipboard
33742
+ from_clipboard: args.from_clipboard,
33743
+ master_images_url: args.master_images_url,
33744
+ master_images_file_uris: args.master_images_file_uris
33373
33745
  });
33374
33746
  return client2.session.jobs.dispatch({ toolName: "gemini_image_set", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33375
33747
  const seed = pickSeed(args.seed);
33376
33748
  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 };
33377
33749
  const slug = args.basename ? baseName(args.basename) : slugify2(args.master_prompt);
33378
- const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
33750
+ const carried = await resolveImageInputs(
33751
+ { images_url: args.master_images_url, images_file_uris: args.master_images_file_uris },
33752
+ client2
33753
+ );
33754
+ const masterOnly = await resolveImageInputs(
33755
+ { images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard },
33756
+ client2
33757
+ );
33758
+ const masterRefInputs = [...masterOnly.inputs, ...carried.inputs];
33759
+ const report = carried.report || masterOnly.report ? { ...masterOnly.report, ...carried.report } : void 0;
33379
33760
  const named = [];
33380
33761
  const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
33381
33762
  const result = await client2.generate({
@@ -33391,13 +33772,13 @@ function registerSetTools(server, client2) {
33391
33772
  if (mode === "chain") {
33392
33773
  let ref = master;
33393
33774
  for (let i = 0; i < scenePrompts.length; i++) {
33394
- const { images: [img] } = await client2.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
33775
+ const { images: [img] } = await client2.generate({ prompt: scenePrompts[i], images: [ref, ...carried.inputs], seed: seed + i + 1, ...cfg });
33395
33776
  named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` });
33396
33777
  ref = img;
33397
33778
  }
33398
33779
  } else {
33399
33780
  const scenes = await Promise.all(
33400
- scenePrompts.map((p, i) => client2.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
33781
+ scenePrompts.map((p, i) => client2.generate({ prompt: p, images: [master, ...carried.inputs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
33401
33782
  );
33402
33783
  scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` }));
33403
33784
  }
@@ -33407,6 +33788,7 @@ function registerSetTools(server, client2) {
33407
33788
  const meta3 = buildMeta(model, seed, args);
33408
33789
  if (masterText) meta3.text = masterText;
33409
33790
  if (masterResult.grounding) meta3.grounding = masterResult.grounding;
33791
+ if (report) meta3.image_inputs = report;
33410
33792
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client2.mediaSink?.persistsFiles });
33411
33793
  if (risk) meta3.timeout_risk = risk;
33412
33794
  return emit(named, { ...args, sink: client2.mediaSink }, meta3);
@@ -33448,6 +33830,42 @@ var SessionState = class {
33448
33830
  * model against the requested edit.
33449
33831
  */
33450
33832
  writtenOutputs = /* @__PURE__ */ new Set();
33833
+ /**
33834
+ * Files API uploads this session can reuse, keyed by an opaque identity
33835
+ * string (for a local path: absolute path + mtime + size, so an edited file
33836
+ * is a different key rather than a stale hit).
33837
+ *
33838
+ * Backs the stdio "referenced twice → upload once" optimization: the second
33839
+ * time a session sends the same picture, the bytes are replaced by a
33840
+ * `files/…` reference that costs nothing to repeat. Session-scoped, like
33841
+ * everything else here — a uri is minted by ONE user's API key and is only
33842
+ * readable with that key, so sharing this map across the connector's tenants
33843
+ * would hand out references another user cannot use and this user did not
33844
+ * create.
33845
+ */
33846
+ uploadCache = /* @__PURE__ */ new Map();
33847
+ /**
33848
+ * How many times each identity has been referenced this session. The
33849
+ * threshold for auto-uploading is "more than once", so the count has to
33850
+ * outlive the call that first saw it.
33851
+ */
33852
+ referenceCounts = /* @__PURE__ */ new Map();
33853
+ /** Record a reference and return the running total (1 on first sight). */
33854
+ countReference(key) {
33855
+ const next = (this.referenceCounts.get(key) ?? 0) + 1;
33856
+ this.referenceCounts.set(key, next);
33857
+ return next;
33858
+ }
33859
+ /** A cached upload, or `undefined` if absent or past its Files API TTL. */
33860
+ cachedUpload(key, now = Date.now()) {
33861
+ const hit = this.uploadCache.get(key);
33862
+ if (!hit) return void 0;
33863
+ if (hit.expiresAtMs <= now) {
33864
+ this.uploadCache.delete(key);
33865
+ return void 0;
33866
+ }
33867
+ return hit;
33868
+ }
33451
33869
  /** Forget everything. Tests use this; nothing in `src/` calls it. */
33452
33870
  reset() {
33453
33871
  this.jobs.reset();
@@ -33455,6 +33873,8 @@ var SessionState = class {
33455
33873
  this.lastMusicInteractionId = void 0;
33456
33874
  this.lastVideoInteractionId = void 0;
33457
33875
  this.writtenOutputs.clear();
33876
+ this.uploadCache.clear();
33877
+ this.referenceCounts.clear();
33458
33878
  }
33459
33879
  };
33460
33880
 
@@ -33495,6 +33915,22 @@ var ChainedRequest404Error = class extends McpToolError {
33495
33915
  upstreamMessage;
33496
33916
  retries;
33497
33917
  };
33918
+ function generatePart(img) {
33919
+ return img.uri ? { file_data: { file_uri: img.uri, mime_type: img.mimeType } } : { inline_data: { mime_type: img.mimeType, data: img.base64 } };
33920
+ }
33921
+ function interactPart(img) {
33922
+ return img.uri ? { type: "image", uri: img.uri, mime_type: img.mimeType } : { type: "image", mime_type: img.mimeType, data: img.base64 };
33923
+ }
33924
+ function normalizeFileName(ref) {
33925
+ const trimmed = ref.trim();
33926
+ const match = /(?:^|\/)(files\/[\w-]+)\/?$/.exec(trimmed);
33927
+ if (!match) {
33928
+ throw new McpToolError(`Not a Gemini Files API reference: "${ref}"`, {
33929
+ hint: "Pass the `files/<id>` name (or the full https://\u2026/v1beta/files/<id> uri) returned by gemini_upload_file / POST /upload."
33930
+ });
33931
+ }
33932
+ return match[1];
33933
+ }
33498
33934
  var GeminiClient = class {
33499
33935
  /**
33500
33936
  * A key handed in explicitly (the connector's per-session key). When absent
@@ -33583,6 +34019,14 @@ var GeminiClient = class {
33583
34019
  }
33584
34020
  return key;
33585
34021
  }
34022
+ /**
34023
+ * Fetch one `images_url` reference server-side. Routed through the client so
34024
+ * it uses the SAME injected `fetchImpl` as every upstream call — which is
34025
+ * what makes it mockable in tests and correct on the Worker.
34026
+ */
34027
+ async fetchRemoteImage(url2, opts = {}) {
34028
+ return fetchRemoteImage(url2, { ...opts, fetchImpl: this.fetchImpl, timeoutMs: resolveTimeoutMs(opts.timeoutMs) });
34029
+ }
33586
34030
  /** The default model after env override (no per-call arg). */
33587
34031
  defaultModel() {
33588
34032
  return resolveModel(void 0, readEnvVar("GEMINI_IMAGE_MODEL"));
@@ -33614,8 +34058,60 @@ var GeminiClient = class {
33614
34058
  * (enforced locally before any bytes are sent).
33615
34059
  */
33616
34060
  async uploadVideo(path, mimeType) {
33617
- const key = this.requireKey();
33618
34061
  const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: "Video" });
34062
+ return this.uploadToFilesApi(blob, mimeType, basename(path), blob.size);
34063
+ }
34064
+ /**
34065
+ * Upload already-in-memory bytes (a fetched `images_url`, a `data_base64`
34066
+ * payload, an inline `gemini_upload_file` body) to the Files API. Same three
34067
+ * steps as {@link uploadVideo}, minus the filesystem — which is what makes it
34068
+ * usable on the hosted connector, where there is no disk to stream from.
34069
+ */
34070
+ async uploadBytes(bytes, mimeType, displayName) {
34071
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
34072
+ if (view.byteLength > FILE_MAX_BYTES) {
34073
+ throw new McpToolError(`File is ${view.byteLength} bytes, over the Gemini Files API limit of ${FILE_MAX_BYTES} bytes (2 GB).`);
34074
+ }
34075
+ const body = new Blob([view], { type: mimeType });
34076
+ return this.uploadToFilesApi(body, mimeType, displayName, view.byteLength);
34077
+ }
34078
+ /**
34079
+ * Upload a request body straight through to the Files API without buffering
34080
+ * it — the direct-upload HTTP endpoint (`POST /upload` on the Worker) hands
34081
+ * us a `ReadableStream` and a `Content-Length`, and the whole point of that
34082
+ * endpoint is that the bytes never materialize anywhere they'd cost context
34083
+ * or memory.
34084
+ *
34085
+ * `contentLength` is REQUIRED because the resumable protocol's start step
34086
+ * declares it up front (`X-Goog-Upload-Header-Content-Length`); a stream of
34087
+ * unknown length has to be buffered by the caller first.
34088
+ */
34089
+ async uploadStream(stream, mimeType, displayName, contentLength) {
34090
+ if (contentLength > FILE_MAX_BYTES) {
34091
+ throw new McpToolError(`File is ${contentLength} bytes, over the Gemini Files API limit of ${FILE_MAX_BYTES} bytes (2 GB).`);
34092
+ }
34093
+ return this.uploadToFilesApi(stream, mimeType, displayName, contentLength);
34094
+ }
34095
+ /**
34096
+ * The resumable upload itself. Three live-verified steps (docs/GEMINI-API.md
34097
+ * "Files API — local video upload"):
34098
+ *
34099
+ * 1. `POST {UPLOAD_BASE_URL}/files` (command `start`) → the upload session
34100
+ * URL arrives in the `x-goog-upload-url` RESPONSE HEADER (body is empty).
34101
+ * 2. `POST <session url>` (command `upload, finalize`) with the bytes.
34102
+ * Response: `{file:…}`.
34103
+ * 3. Poll `GET /v1beta/files/<id>` while `state` is `PROCESSING`, until
34104
+ * `ACTIVE` (the poll response is the File object UNWRAPPED). Images are
34105
+ * usually `ACTIVE` immediately; video is what processes.
34106
+ *
34107
+ * These two upload calls are raw `fetchImpl` (not `createApiClient`): step 1
34108
+ * needs the response *header* and step 2 posts a binary body — neither fits
34109
+ * `fetchJson`. The poll DOES go through the shared client (timeout + retry).
34110
+ * Uploads deliberately have no abort timeout: a multi-hundred-MB file can
34111
+ * legitimately take longer than any fixed budget.
34112
+ */
34113
+ async uploadToFilesApi(body, mimeType, displayName, contentLength) {
34114
+ const key = this.requireKey();
33619
34115
  const startPath = "/files";
33620
34116
  const startRes = await this.fetchImpl(`${UPLOAD_BASE_URL}${startPath}`, {
33621
34117
  method: "POST",
@@ -33623,11 +34119,11 @@ var GeminiClient = class {
33623
34119
  "x-goog-api-key": key,
33624
34120
  "X-Goog-Upload-Protocol": "resumable",
33625
34121
  "X-Goog-Upload-Command": "start",
33626
- "X-Goog-Upload-Header-Content-Length": String(blob.size),
34122
+ "X-Goog-Upload-Header-Content-Length": String(contentLength),
33627
34123
  "X-Goog-Upload-Header-Content-Type": mimeType,
33628
34124
  "Content-Type": "application/json"
33629
34125
  },
33630
- body: JSON.stringify({ file: { display_name: basename(path) } })
34126
+ body: JSON.stringify({ file: { display_name: displayName } })
33631
34127
  });
33632
34128
  if (!startRes.ok) {
33633
34129
  throw new McpToolError(
@@ -33640,13 +34136,17 @@ var GeminiClient = class {
33640
34136
  hint: "The Files API resumable-upload contract may have changed \u2014 see docs/GEMINI-API.md."
33641
34137
  });
33642
34138
  }
34139
+ const streaming = !(body instanceof Blob);
33643
34140
  const upRes = await this.fetchImpl(uploadUrl, {
33644
34141
  method: "POST",
33645
34142
  headers: {
33646
34143
  "X-Goog-Upload-Offset": "0",
33647
- "X-Goog-Upload-Command": "upload, finalize"
34144
+ "X-Goog-Upload-Command": "upload, finalize",
34145
+ ...streaming ? { "Content-Length": String(contentLength) } : {}
33648
34146
  },
33649
- body: blob
34147
+ body,
34148
+ // Required by undici/workerd to send a ReadableStream body at all.
34149
+ ...streaming ? { duplex: "half" } : {}
33650
34150
  });
33651
34151
  if (!upRes.ok) {
33652
34152
  throw new McpToolError(
@@ -33660,8 +34160,8 @@ var GeminiClient = class {
33660
34160
  }
33661
34161
  for (let attempt = 0; file2.state === "PROCESSING"; attempt++) {
33662
34162
  if (attempt >= FILE_POLL_MAX_ATTEMPTS) {
33663
- throw new McpToolError(`${SERVICE} video file ${name} is still processing after ${FILE_POLL_INTERVAL_MS * FILE_POLL_MAX_ATTEMPTS / 6e4} minutes`, {
33664
- hint: "Very long videos can take a while to process \u2014 retry later by passing the file uri as video_url."
34163
+ throw new McpToolError(`${SERVICE} file ${name} is still processing after ${FILE_POLL_INTERVAL_MS * FILE_POLL_MAX_ATTEMPTS / 6e4} minutes`, {
34164
+ hint: "Very long videos can take a while to process \u2014 retry later by passing the file uri as video_url / images_file_uris."
33665
34165
  });
33666
34166
  }
33667
34167
  await this.sleep(FILE_POLL_INTERVAL_MS);
@@ -33669,14 +34169,78 @@ var GeminiClient = class {
33669
34169
  }
33670
34170
  if (file2.state !== "ACTIVE") {
33671
34171
  throw new McpToolError(
33672
- `${SERVICE} video file processing failed (state ${file2.state ?? "unknown"})${file2.error?.message ? `: ${file2.error.message}` : ""}`,
33673
- { hint: "Check the video is a supported format/codec and under 2 GB." }
34172
+ `${SERVICE} file processing failed (state ${file2.state ?? "unknown"})${file2.error?.message ? `: ${file2.error.message}` : ""}`,
34173
+ { hint: "Check the file is a supported format/codec and under 2 GB." }
33674
34174
  );
33675
34175
  }
33676
34176
  if (!file2.uri) {
33677
34177
  throw new McpToolError(`${SERVICE} did not return a uri for uploaded file ${name}`);
33678
34178
  }
33679
- return { name, uri: file2.uri, mimeType: file2.mimeType ?? mimeType, expirationTime: file2.expirationTime };
34179
+ return {
34180
+ name,
34181
+ uri: file2.uri,
34182
+ mimeType: file2.mimeType ?? mimeType,
34183
+ expirationTime: file2.expirationTime,
34184
+ sizeBytes: file2.sizeBytes,
34185
+ displayName: file2.displayName
34186
+ };
34187
+ }
34188
+ /**
34189
+ * List the files this API key currently has in the Files API. Retention is
34190
+ * ~48h, so the list is naturally short-lived — an empty list after a day is
34191
+ * expiry, not a bug.
34192
+ */
34193
+ async listFiles(pageSize = 100) {
34194
+ const size = Math.min(Math.max(Math.trunc(pageSize), 1), 100);
34195
+ const data = await this.call("GET", `/files?pageSize=${size}`);
34196
+ return (data.files ?? []).filter((f) => !!f.name && !!f.uri).map((f) => ({
34197
+ name: f.name,
34198
+ uri: f.uri,
34199
+ mimeType: f.mimeType ?? "application/octet-stream",
34200
+ state: f.state,
34201
+ createTime: f.createTime,
34202
+ expirationTime: f.expirationTime,
34203
+ sizeBytes: f.sizeBytes,
34204
+ displayName: f.displayName
34205
+ }));
34206
+ }
34207
+ /**
34208
+ * Fetch one file's metadata. Used to resolve a caller-supplied
34209
+ * `images_file_uris` entry to its real MIME type — and, as a side effect, to
34210
+ * fail fast with a clear "that file is gone" instead of the Interactions
34211
+ * API's generic 404 after a billable request was already built.
34212
+ */
34213
+ async getFile(ref) {
34214
+ const resource = normalizeFileName(ref);
34215
+ const file2 = await this.call("GET", `/${resource}`);
34216
+ if (!file2.uri) {
34217
+ throw new McpToolError(`${SERVICE} returned no uri for ${resource}`, {
34218
+ hint: "Files expire ~48h after upload \u2014 re-upload with gemini_upload_file (or POST /upload) and use the new uri."
34219
+ });
34220
+ }
34221
+ if (file2.state && file2.state !== "ACTIVE") {
34222
+ throw new McpToolError(`${SERVICE} file ${resource} is not usable yet (state ${file2.state}).`, {
34223
+ hint: "Wait for the upload to finish processing, then retry."
34224
+ });
34225
+ }
34226
+ return {
34227
+ name: file2.name ?? resource,
34228
+ uri: file2.uri,
34229
+ mimeType: file2.mimeType ?? "application/octet-stream",
34230
+ expirationTime: file2.expirationTime,
34231
+ sizeBytes: file2.sizeBytes,
34232
+ displayName: file2.displayName
34233
+ };
34234
+ }
34235
+ /**
34236
+ * Delete one uploaded file. `name` is interpolated into the URL path, so it
34237
+ * is validated to a bare `files/<id>` for the same reason the model id is
34238
+ * (see {@link uploadToFilesApi}); a full `https://…/v1beta/files/<id>` uri is
34239
+ * accepted and reduced to that form, since that is what every result echoes.
34240
+ */
34241
+ async deleteFile(name) {
34242
+ const resource = normalizeFileName(name);
34243
+ await this.call("DELETE", `/${resource}`);
33680
34244
  }
33681
34245
  async listModels() {
33682
34246
  const data = await this.call("GET", "/models?pageSize=200");
@@ -33685,9 +34249,7 @@ var GeminiClient = class {
33685
34249
  async generate(opts) {
33686
34250
  const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33687
34251
  const parts = [{ text: opts.prompt }];
33688
- for (const img of opts.images ?? []) {
33689
- parts.push({ inline_data: { mime_type: img.mimeType, data: img.base64 } });
33690
- }
34252
+ for (const img of opts.images ?? []) parts.push(generatePart(img));
33691
34253
  if (opts.videoUrl) {
33692
34254
  parts.push({ file_data: { file_uri: opts.videoUrl, mime_type: opts.videoMimeType ?? "video/mp4" } });
33693
34255
  }
@@ -33743,9 +34305,7 @@ var GeminiClient = class {
33743
34305
  async interact(opts) {
33744
34306
  const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33745
34307
  const inputParts = [{ type: "text", text: opts.input }];
33746
- for (const img of opts.images ?? []) {
33747
- inputParts.push({ type: "image", mime_type: img.mimeType, data: img.base64 });
33748
- }
34308
+ for (const img of opts.images ?? []) inputParts.push(interactPart(img));
33749
34309
  if (opts.videoUrl) {
33750
34310
  inputParts.push({ type: "video", uri: opts.videoUrl, mime_type: opts.videoMimeType ?? "video/mp4" });
33751
34311
  }
@@ -33778,7 +34338,7 @@ var GeminiClient = class {
33778
34338
  async generateVideo(opts) {
33779
34339
  const model = opts.model ?? DEFAULT_VIDEO_MODEL;
33780
34340
  const inputParts = [{ type: "text", text: opts.input }];
33781
- for (const img of opts.images ?? []) inputParts.push({ type: "image", mime_type: img.mimeType, data: img.base64 });
34341
+ for (const img of opts.images ?? []) inputParts.push(interactPart(img));
33782
34342
  const responseFormat = { type: "video", delivery: "inline" };
33783
34343
  if (opts.aspectRatio) responseFormat.aspect_ratio = opts.aspectRatio;
33784
34344
  const body = { model, input: inputParts, response_format: responseFormat };
@@ -33800,7 +34360,7 @@ var GeminiClient = class {
33800
34360
  async generateMusic(opts) {
33801
34361
  const model = opts.model ?? DEFAULT_MUSIC_MODEL;
33802
34362
  const inputParts = [{ type: "text", text: opts.input }];
33803
- for (const img of opts.images ?? []) inputParts.push({ type: "image", mime_type: img.mimeType, data: img.base64 });
34363
+ for (const img of opts.images ?? []) inputParts.push(interactPart(img));
33804
34364
  const responseFormat = { type: "audio" };
33805
34365
  if (opts.audioFormat) responseFormat.audio_format = opts.audioFormat;
33806
34366
  const body = { model, input: inputParts, response_format: responseFormat };
@@ -33959,7 +34519,11 @@ function registerInteractTools(server, client2) {
33959
34519
  previous_interaction_id: external_exports.string().optional().describe("ID from a prior gemini_interact call \u2014 continues that multi-turn conversation"),
33960
34520
  continue_last: external_exports.boolean().optional().describe("Continue from the most recent interaction this server created (convenience for previous_interaction_id; an explicit id wins). Survives a server restart by falling back to the newest <image>.json sidecar in the output dir."),
33961
34521
  images: external_exports.array(external_exports.string().min(1)).optional().describe(`Paths to reference input images. ${NEW_REFERENCES_ONLY}`),
33962
- images_base64: external_exports.array(external_exports.string().min(1)).optional().describe(`Reference images as base64 strings or data URIs. ${NEW_REFERENCES_ONLY}`),
34522
+ images_url: imagesUrlSchema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
34523
+ images_file_uris: imagesFileUrisSchema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
34524
+ images_base64: external_exports.array(external_exports.string().min(1)).optional().describe(
34525
+ `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. ${NEW_REFERENCES_ONLY}`
34526
+ ),
33963
34527
  model: external_exports.string().optional().describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
33964
34528
  aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Output aspect ratio"),
33965
34529
  image_size: external_exports.enum(IMAGE_SIZES).optional().describe("Output resolution (512 = 0.5K, Flash-only)"),
@@ -34017,11 +34581,13 @@ function registerInteractTools(server, client2) {
34017
34581
  images,
34018
34582
  images_base64: args.images_base64,
34019
34583
  from_clipboard: args.from_clipboard,
34584
+ images_url: args.images_url,
34585
+ images_file_uris: args.images_file_uris,
34020
34586
  video_url: args.video_url,
34021
34587
  video_path: args.video_path
34022
34588
  });
34023
34589
  return client2.session.jobs.dispatch({ toolName: "gemini_interact", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
34024
- const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
34590
+ const { inputs, report } = await resolveImageInputs({ ...args, images }, client2);
34025
34591
  const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args, client2));
34026
34592
  const callOpts = {
34027
34593
  input: args.input,
@@ -34073,6 +34639,7 @@ function registerInteractTools(server, client2) {
34073
34639
  meta3.hint = `To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true) \u2014 do NOT re-attach the returned image as an input; the interaction already contains it.`;
34074
34640
  if (r.text) meta3.text = r.text;
34075
34641
  if (r.grounding) meta3.grounding = r.grounding;
34642
+ if (report) meta3.image_inputs = report;
34076
34643
  if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
34077
34644
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: onDisk });
34078
34645
  if (risk) meta3.timeout_risk = risk;
@@ -34111,7 +34678,9 @@ function registerVideoTools(server, client2) {
34111
34678
  aspect_ratio: external_exports.enum(VIDEO_ASPECT_RATIOS).optional().describe("Output aspect ratio (omni: 16:9 or 9:16)"),
34112
34679
  task: external_exports.enum(VIDEO_TASKS).optional().describe("text_to_video (default), image_to_video / reference_to_video (need image input), or edit (needs previous_interaction_id)"),
34113
34680
  images: external_exports.array(external_exports.string().min(1)).optional().describe("Reference image path(s) for image_to_video / reference_to_video"),
34114
- images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs"),
34681
+ images_url: imagesUrlSchema("Reference stills"),
34682
+ images_file_uris: imagesFileUrisSchema("Reference stills"),
34683
+ 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"),
34115
34684
  from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS clipboard as a reference"),
34116
34685
  filename: external_exports.string().optional().describe("Base filename for the output video (extension stripped; default: slugified prompt)"),
34117
34686
  output_dir: external_exports.string().optional().describe("Directory to write the video to (default: $GEMINI_OUTPUT_DIR or cwd)"),
@@ -34146,10 +34715,12 @@ function registerVideoTools(server, client2) {
34146
34715
  images: args.images,
34147
34716
  images_base64: args.images_base64,
34148
34717
  from_clipboard: args.from_clipboard,
34718
+ images_url: args.images_url,
34719
+ images_file_uris: args.images_file_uris,
34149
34720
  previous_interaction_id: previousInteractionId
34150
34721
  });
34151
34722
  return client2.session.jobs.dispatch({ toolName: "gemini_video_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
34152
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
34723
+ const { inputs, report } = await resolveImageInputs(args, client2);
34153
34724
  const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client2.generateVideo({
34154
34725
  input: args.prompt,
34155
34726
  images: inputs.length ? inputs : void 0,
@@ -34163,6 +34734,7 @@ function registerVideoTools(server, client2) {
34163
34734
  const meta3 = { model, interaction_id: r.id };
34164
34735
  if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
34165
34736
  if (r.text) meta3.text = r.text;
34737
+ if (report) meta3.image_inputs = report;
34166
34738
  meta3.hint = `To edit this video, call gemini_video_generate again with task: "edit" and previous_interaction_id: "${r.id}" (or continue_last: true).`;
34167
34739
  const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
34168
34740
  const named = r.videos.map((media, i) => ({
@@ -34195,7 +34767,9 @@ function registerMusicTools(server, client2) {
34195
34767
  model: external_exports.enum(MUSIC_MODELS).optional().describe(`Lyria model (default: ${DEFAULT_MUSIC_MODEL}). Pro is longer-form and supports WAV.`),
34196
34768
  audio_format: external_exports.enum(AUDIO_FORMATS).optional().describe("Output format (default mp3). wav is lyria-3-pro-preview-only."),
34197
34769
  images: external_exports.array(external_exports.string().min(1)).optional().describe("Optional reference image path(s) to condition the music"),
34198
- images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs"),
34770
+ images_url: imagesUrlSchema("Reference images"),
34771
+ images_file_uris: imagesFileUrisSchema("Reference images"),
34772
+ 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"),
34199
34773
  from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS clipboard as a reference"),
34200
34774
  filename: external_exports.string().optional().describe("Base filename for the output audio (extension stripped; default: slugified prompt)"),
34201
34775
  output_dir: external_exports.string().optional().describe("Directory to write audio to (default: $GEMINI_OUTPUT_DIR or cwd)"),
@@ -34234,10 +34808,12 @@ function registerMusicTools(server, client2) {
34234
34808
  images: args.images,
34235
34809
  images_base64: args.images_base64,
34236
34810
  from_clipboard: args.from_clipboard,
34811
+ images_url: args.images_url,
34812
+ images_file_uris: args.images_file_uris,
34237
34813
  previous_interaction_id: previousInteractionId
34238
34814
  });
34239
34815
  return client2.session.jobs.dispatch({ toolName: "gemini_music_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
34240
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
34816
+ const { inputs, report } = await resolveImageInputs(args, client2);
34241
34817
  const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client2.generateMusic({
34242
34818
  input: args.prompt,
34243
34819
  images: inputs.length ? inputs : void 0,
@@ -34250,6 +34826,7 @@ function registerMusicTools(server, client2) {
34250
34826
  const meta3 = { model, interaction_id: r.id };
34251
34827
  if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
34252
34828
  if (r.text) meta3.text = r.text;
34829
+ if (report) meta3.image_inputs = report;
34253
34830
  meta3.hint = `To continue this track, call gemini_music_generate again with previous_interaction_id: "${r.id}" (or continue_last: true).`;
34254
34831
  const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
34255
34832
  const named = r.audios.map((media, i) => ({
@@ -34278,12 +34855,152 @@ function registerJobTools(server, client2) {
34278
34855
  );
34279
34856
  }
34280
34857
 
34858
+ // src/tools/files.ts
34859
+ init_zod();
34860
+ init_dist();
34861
+ var UPLOAD_URL_MAX_BYTES_DISK = 100 * 1024 * 1024;
34862
+ var UPLOAD_URL_MAX_BYTES_HOSTED = 25 * 1024 * 1024;
34863
+ var UPLOAD_ACCEPT = ["image/", "video/", "audio/"];
34864
+ var RETENTION_NOTE = "Gemini retains uploaded files for ~48h. Until then this reference can be reused across any number of calls (images_file_uris on gemini_image_generate / _edit / _set / gemini_interact, or video_url) without re-sending the bytes.";
34865
+ function uploadResult(file2) {
34866
+ return textResult({
34867
+ // The `files/<id>` form is what every tool parameter documents, so it leads.
34868
+ file_uri: file2.name,
34869
+ // The absolute uri, for callers that would rather pass it through verbatim.
34870
+ uri: file2.uri,
34871
+ name: file2.name,
34872
+ mime_type: file2.mimeType,
34873
+ ...file2.sizeBytes ? { size_bytes: Number(file2.sizeBytes) } : {},
34874
+ ...file2.displayName ? { display_name: file2.displayName } : {},
34875
+ expires: file2.expirationTime ?? "~48h from upload",
34876
+ hint: `Pass "${file2.name}" in images_file_uris (or video_url for video) on any image tool. ${RETENTION_NOTE}`
34877
+ });
34878
+ }
34879
+ function registerFileTools(server, client2) {
34880
+ const onDisk = client2.mediaSink?.persistsFiles ?? true;
34881
+ const urlMaxBytes = onDisk ? UPLOAD_URL_MAX_BYTES_DISK : UPLOAD_URL_MAX_BYTES_HOSTED;
34882
+ server.registerTool(
34883
+ "gemini_upload_file",
34884
+ {
34885
+ description: "Upload an image (or video/audio) to the Gemini Files API ONCE and get back a reusable `files/<id>` reference. Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever enter the conversation, and it can be reused across many generations until it expires (~48h). Provide exactly one of `url` (the server downloads it), `data_base64`" + (onDisk ? ", or `path` (a local file)." : ".") + (onDisk ? "" : " This connector also accepts raw bytes over HTTP: POST them to /upload with the same Authorization header and a Content-Type of image/*, which avoids base64 entirely."),
34886
+ annotations: { readOnlyHint: false, openWorldHint: true },
34887
+ inputSchema: {
34888
+ url: external_exports.string().url().optional().describe(
34889
+ `Public https URL the SERVER downloads and uploads (image/video/audio, up to ${wholeMb(urlMaxBytes)}MB). No bytes pass through the conversation.` + (onDisk ? "" : " Larger files: POST the raw bytes to /upload, which streams instead of buffering.")
34890
+ ),
34891
+ 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)."),
34892
+ path: external_exports.string().min(1).optional().describe(
34893
+ onDisk ? "Path to a local file (absolute, or resolved against $GEMINI_INPUT_DIR). Confirm-gated like every other local-file input." : "Unavailable on this hosted connector (no filesystem) \u2014 use `url`, `data_base64`, or POST /upload."
34894
+ ),
34895
+ mime_type: external_exports.string().regex(/^[\w.+-]+\/[\w.+-]+$/, "must be a MIME type like image/png").optional().describe("Override the detected MIME type (sniffed from the bytes / taken from the server response otherwise)"),
34896
+ display_name: external_exports.string().min(1).optional().describe("Human-readable name recorded against the upload"),
34897
+ confirm: schemaConfirm
34898
+ }
34899
+ },
34900
+ async (args, extra) => {
34901
+ const sources = [args.url && "url", args.data_base64 && "data_base64", args.path && "path"].filter(Boolean);
34902
+ if (sources.length !== 1) {
34903
+ throw new McpToolError(
34904
+ sources.length === 0 ? "Provide exactly one of `url`, `data_base64`" + (onDisk ? " or `path`." : ".") : `Provide exactly one source, not ${sources.length} (${sources.join(", ")}).`
34905
+ );
34906
+ }
34907
+ if (args.path) {
34908
+ if (!onDisk) {
34909
+ throw new McpToolError(
34910
+ "`path` is unavailable on the hosted connector: it runs on a Cloudflare Worker, which has no filesystem. Use `url` (the server downloads it), `data_base64`, or POST the raw bytes to /upload with the same Authorization header.",
34911
+ { hint: "Use `url`, `data_base64`, or POST /upload." }
34912
+ );
34913
+ }
34914
+ const gate = await previewLocalInputsUnlessConfirmed(
34915
+ args.confirm,
34916
+ "Upload a local file to the Gemini Files API",
34917
+ "/upload/v1beta/files",
34918
+ [args.path]
34919
+ );
34920
+ if (gate) return gate;
34921
+ }
34922
+ const uploaded = await withProgressHeartbeat(extra, "Uploading to the Gemini Files API", async () => {
34923
+ if (args.url) {
34924
+ const fetched = await client2.fetchRemoteImage(args.url, { maxBytes: urlMaxBytes, accept: UPLOAD_ACCEPT });
34925
+ return client2.uploadBytes(
34926
+ fetched.bytes,
34927
+ args.mime_type ?? fetched.mimeType,
34928
+ // 'upload', matching the data_base64 and path branches below —
34929
+ // this tool takes video and audio too, so 'image' would be a lie.
34930
+ args.display_name ?? fileNameFromUrl(args.url, "upload")
34931
+ );
34932
+ }
34933
+ if (args.data_base64) {
34934
+ const { decodeImageInput: decodeImageInput2 } = await Promise.resolve().then(() => (init_images(), images_exports));
34935
+ const decoded = decodeImageInput2(args.data_base64);
34936
+ const bytes2 = base64ToBytes(decoded.base64);
34937
+ return client2.uploadBytes(bytes2, args.mime_type ?? decoded.mimeType, args.display_name ?? "upload");
34938
+ }
34939
+ const { readImageAsInline: readImageAsInline2, resolveImagePath: resolveImagePath2 } = await Promise.resolve().then(() => (init_images(), images_exports));
34940
+ const resolved = resolveImagePath2(args.path);
34941
+ const inline = await readImageAsInline2(resolved);
34942
+ const bytes = base64ToBytes(inline.base64);
34943
+ return client2.uploadBytes(
34944
+ bytes,
34945
+ args.mime_type ?? inline.mimeType,
34946
+ args.display_name ?? (resolved.split(/[\\/]/).pop() || "upload")
34947
+ );
34948
+ });
34949
+ return uploadResult(uploaded);
34950
+ }
34951
+ );
34952
+ server.registerTool(
34953
+ "gemini_list_files",
34954
+ {
34955
+ description: "List the files currently uploaded to the Gemini Files API under this API key, with their `files/<id>` references, MIME types and expiry times. Retention is ~48h, so an entry that has vanished has expired rather than failed.",
34956
+ annotations: { readOnlyHint: true, openWorldHint: true },
34957
+ inputSchema: {
34958
+ page_size: external_exports.number().int().positive().max(100).optional().describe("Maximum files to return (1-100, default 100)")
34959
+ }
34960
+ },
34961
+ async (args) => {
34962
+ const files = await client2.listFiles(args.page_size ?? 100);
34963
+ return textResult({
34964
+ count: files.length,
34965
+ files: files.map((f) => ({
34966
+ file_uri: f.name,
34967
+ uri: f.uri,
34968
+ mime_type: f.mimeType,
34969
+ ...f.displayName ? { display_name: f.displayName } : {},
34970
+ ...f.sizeBytes ? { size_bytes: Number(f.sizeBytes) } : {},
34971
+ ...f.state ? { state: f.state } : {},
34972
+ ...f.createTime ? { created: f.createTime } : {},
34973
+ ...f.expirationTime ? { expires: f.expirationTime } : {}
34974
+ })),
34975
+ hint: files.length ? `Pass any file_uri above in images_file_uris on an image tool. ${RETENTION_NOTE}` : "No files uploaded (or all have expired \u2014 retention is ~48h). Upload one with gemini_upload_file."
34976
+ });
34977
+ }
34978
+ );
34979
+ server.registerTool(
34980
+ "gemini_delete_file",
34981
+ {
34982
+ description: "Delete an uploaded file from the Gemini Files API before its ~48h expiry. Any tool call still referencing it will then fail with a generic 404, so delete only references you are finished with.",
34983
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
34984
+ inputSchema: {
34985
+ file_uri: external_exports.string().min(1).describe("The `files/<id>` reference (or full uri) to delete"),
34986
+ confirm: schemaConfirm
34987
+ }
34988
+ },
34989
+ async (args) => {
34990
+ const gate = previewUnlessConfirmed(args.confirm, "Delete an uploaded Gemini file", "DELETE", `/v1beta/${args.file_uri}`);
34991
+ if (gate) return gate;
34992
+ await client2.deleteFile(args.file_uri);
34993
+ return textResult({ deleted: args.file_uri });
34994
+ }
34995
+ );
34996
+ }
34997
+
34281
34998
  // src/index.ts
34282
34999
  await loadStdioDotenv();
34283
35000
  await runMcp({
34284
35001
  name: "gemini-mcp",
34285
35002
  version: VERSION,
34286
35003
  banner: "[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.",
34287
- tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools],
35004
+ tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools, registerFileTools],
34288
35005
  deps: client
34289
35006
  });