@chrischall/gemini-mcp 1.0.4 → 1.1.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")
@@ -32808,7 +32808,7 @@ async function loadStdioDotenv() {
32808
32808
  }
32809
32809
 
32810
32810
  // src/version.ts
32811
- var VERSION = "1.0.4";
32811
+ var VERSION = "1.1.0";
32812
32812
 
32813
32813
  // src/tools/models.ts
32814
32814
  init_dist();
@@ -32848,6 +32848,355 @@ function filterImageModels(raw) {
32848
32848
  // src/tools/generate.ts
32849
32849
  init_images();
32850
32850
 
32851
+ // src/inputs.ts
32852
+ init_dist();
32853
+
32854
+ // src/fetch-image.ts
32855
+ init_dist();
32856
+ var IMAGE_URL_MAX_BYTES = 15 * 1024 * 1024;
32857
+ var IMAGE_INLINE_MAX_BYTES = 6 * 1024 * 1024;
32858
+ var MAX_REDIRECTS = 5;
32859
+ var DEFAULT_FETCH_TIMEOUT_MS = 6e4;
32860
+ function ipv4Octets(host) {
32861
+ const parts = host.split(".");
32862
+ if (parts.length !== 4) return void 0;
32863
+ const octets = parts.map((p) => /^\d{1,3}$/.test(p) ? Number(p) : NaN);
32864
+ return octets.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? octets : void 0;
32865
+ }
32866
+ function isPrivateIpv4([a, b]) {
32867
+ if (a === 0 || a === 127) return true;
32868
+ if (a === 10) return true;
32869
+ if (a === 172 && b >= 16 && b <= 31) return true;
32870
+ if (a === 192 && b === 168) return true;
32871
+ if (a === 169 && b === 254) return true;
32872
+ if (a === 100 && b >= 64 && b <= 127) return true;
32873
+ if (a === 192 && b === 0) return true;
32874
+ if (a === 198 && (b === 18 || b === 19)) return true;
32875
+ if (a >= 224) return true;
32876
+ return false;
32877
+ }
32878
+ function ipv6Groups(host) {
32879
+ if (!host.includes(":")) return void 0;
32880
+ let text = host;
32881
+ const tail = [];
32882
+ const dotted = /:((?:\d{1,3}\.){3}\d{1,3})$/.exec(text);
32883
+ if (dotted) {
32884
+ const octets = ipv4Octets(dotted[1]);
32885
+ if (!octets) return void 0;
32886
+ tail.push(octets[0] << 8 | octets[1], octets[2] << 8 | octets[3]);
32887
+ text = text.slice(0, dotted.index);
32888
+ }
32889
+ const halves = text.split("::");
32890
+ if (halves.length > 2) return void 0;
32891
+ const parse3 = (part) => {
32892
+ if (part === "") return [];
32893
+ const out = [];
32894
+ for (const piece of part.split(":")) {
32895
+ if (!/^[0-9a-f]{1,4}$/.test(piece)) return void 0;
32896
+ out.push(parseInt(piece, 16));
32897
+ }
32898
+ return out;
32899
+ };
32900
+ const head = parse3(halves[0]);
32901
+ const rest = halves.length === 2 ? parse3(halves[1]) : [];
32902
+ if (!head || !rest) return void 0;
32903
+ const explicit = [...head, ...rest, ...tail];
32904
+ if (halves.length === 2) {
32905
+ if (explicit.length > 7) return void 0;
32906
+ return [...head, ...Array(8 - explicit.length).fill(0), ...rest, ...tail];
32907
+ }
32908
+ return explicit.length === 8 ? explicit : void 0;
32909
+ }
32910
+ var V4_BEARING_PREFIXES = [
32911
+ [0, 0, 0, 0, 0, 65535],
32912
+ // ::ffff:0:0/96 — IPv4-mapped
32913
+ [0, 0, 0, 0, 0, 0],
32914
+ // ::/96 — IPv4-compatible (deprecated)
32915
+ [0, 0, 0, 0, 65535, 0],
32916
+ // ::ffff:0:0:0/96 — IPv4-translated (deprecated SIIT)
32917
+ [100, 65435, 0, 0, 0, 0]
32918
+ // 64:ff9b::/96 — NAT64 well-known (RFC 6052)
32919
+ ];
32920
+ function isPrivateIpv6(groups) {
32921
+ if (groups.every((g) => g === 0)) return true;
32922
+ if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return true;
32923
+ if ((groups[0] & 65472) === 65152) return true;
32924
+ if ((groups[0] & 65024) === 64512) return true;
32925
+ if (V4_BEARING_PREFIXES.some((prefix) => prefix.every((g, i) => groups[i] === g))) {
32926
+ return isPrivateIpv4([groups[6] >> 8, groups[6] & 255, groups[7] >> 8, groups[7] & 255]);
32927
+ }
32928
+ return false;
32929
+ }
32930
+ function assertPublicHttpsUrl(raw, requested) {
32931
+ let url2;
32932
+ try {
32933
+ url2 = new URL(raw);
32934
+ } catch {
32935
+ throw new McpToolError(`Not a valid URL: ${raw}`, {
32936
+ hint: "images_url entries must be absolute https:// URLs."
32937
+ });
32938
+ }
32939
+ if (url2.protocol !== "https:") {
32940
+ throw new McpToolError(`Refusing to fetch ${describeUrl(raw, requested)}: only https:// URLs are fetched (got "${url2.protocol}").`, {
32941
+ hint: "Use an https:// URL, or send the bytes with images_base64 / gemini_upload_file."
32942
+ });
32943
+ }
32944
+ const rawHost = url2.hostname.toLowerCase();
32945
+ const isIpv6Literal = rawHost.startsWith("[") && rawHost.endsWith("]");
32946
+ const host = isIpv6Literal ? rawHost.slice(1, -1) : rawHost;
32947
+ const v4 = isIpv6Literal ? void 0 : ipv4Octets(host);
32948
+ const v6 = isIpv6Literal ? ipv6Groups(host) : void 0;
32949
+ const blocked = isIpv6Literal ? v6 === void 0 || isPrivateIpv6(v6) : host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || (v4 ? isPrivateIpv4(v4) : false);
32950
+ if (blocked) {
32951
+ throw new McpToolError(
32952
+ `Refusing to fetch ${describeUrl(raw, requested)}: "${url2.hostname}" is a private, loopback or link-local address, not a public host.`,
32953
+ { hint: "images_url is fetched by the server, so it must point at a publicly reachable https:// image." }
32954
+ );
32955
+ }
32956
+ return url2;
32957
+ }
32958
+ function describeUrl(current, requested) {
32959
+ return current === requested ? current : `${current} (redirected from ${requested})`;
32960
+ }
32961
+ async function readCapped(res, maxBytes, url2, requested) {
32962
+ const declared = Number(res.headers.get("content-length"));
32963
+ if (Number.isFinite(declared) && declared > maxBytes) {
32964
+ throw tooBig(url2, requested, declared, maxBytes);
32965
+ }
32966
+ const reader = res.body?.getReader();
32967
+ if (!reader) {
32968
+ const buf = new Uint8Array(await res.arrayBuffer());
32969
+ if (buf.byteLength > maxBytes) throw tooBig(url2, requested, buf.byteLength, maxBytes);
32970
+ return buf;
32971
+ }
32972
+ const chunks = [];
32973
+ let total = 0;
32974
+ try {
32975
+ for (; ; ) {
32976
+ const { done, value } = await reader.read();
32977
+ if (done) break;
32978
+ if (!value) continue;
32979
+ total += value.byteLength;
32980
+ if (total > maxBytes) {
32981
+ await reader.cancel().catch(() => {
32982
+ });
32983
+ throw tooBig(url2, requested, total, maxBytes, true);
32984
+ }
32985
+ chunks.push(value);
32986
+ }
32987
+ } finally {
32988
+ reader.releaseLock?.();
32989
+ }
32990
+ const out = new Uint8Array(total);
32991
+ let offset = 0;
32992
+ for (const c of chunks) {
32993
+ out.set(c, offset);
32994
+ offset += c.byteLength;
32995
+ }
32996
+ return out;
32997
+ }
32998
+ function fetchFailure(err, current, requested, timeoutMs) {
32999
+ const name = err?.name;
33000
+ if (name === "TimeoutError" || name === "AbortError") {
33001
+ return new McpToolError(`Fetching ${describeUrl(current, requested)} timed out after ${timeoutMs}ms.`, {
33002
+ hint: "The server accepted the connection but did not finish sending the image. Try a different URL, or raise timeout_ms."
33003
+ });
33004
+ }
33005
+ return new McpToolError(
33006
+ `Could not fetch ${describeUrl(current, requested)}: ${err instanceof Error ? err.message : String(err)}`,
33007
+ { hint: "Check the URL is reachable from the server and serves the image without authentication." }
33008
+ );
33009
+ }
33010
+ function tooBig(url2, requested, size, max, partial2 = false) {
33011
+ const mb2 = (n) => `${(n / (1024 * 1024)).toFixed(1)} MB`;
33012
+ return new McpToolError(
33013
+ `Image at ${describeUrl(url2, requested)} is ${partial2 ? "over" : ""} ${mb2(size)}, above the ${mb2(max)} limit for images_url.`,
33014
+ { hint: "Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri." }
33015
+ );
33016
+ }
33017
+ async function fetchRemoteImage(requestedUrl, opts = {}) {
33018
+ const doFetch = opts.fetchImpl ?? fetch;
33019
+ const maxBytes = opts.maxBytes ?? IMAGE_URL_MAX_BYTES;
33020
+ const accept = opts.accept ?? ["image/"];
33021
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
33022
+ let current = requestedUrl;
33023
+ for (let hop = 0; ; hop++) {
33024
+ const url2 = assertPublicHttpsUrl(current, requestedUrl);
33025
+ const signal = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : void 0;
33026
+ let res;
33027
+ try {
33028
+ res = await doFetch(url2.toString(), {
33029
+ redirect: "manual",
33030
+ headers: { accept: accept.map((p) => `${p.replace(/\/$/, "")}/*`).join(", ") },
33031
+ ...signal ? { signal } : {}
33032
+ });
33033
+ } catch (err) {
33034
+ throw fetchFailure(err, current, requestedUrl, timeoutMs);
33035
+ }
33036
+ if (res.status >= 300 && res.status < 400) {
33037
+ const location = res.headers.get("location");
33038
+ if (!location) {
33039
+ throw new McpToolError(`Fetching ${describeUrl(current, requestedUrl)} returned HTTP ${res.status} with no Location header.`);
33040
+ }
33041
+ if (hop >= MAX_REDIRECTS) {
33042
+ throw new McpToolError(`Fetching ${requestedUrl} exceeded ${MAX_REDIRECTS} redirects (last hop: ${location}).`);
33043
+ }
33044
+ current = new URL(location, url2).toString();
33045
+ continue;
33046
+ }
33047
+ if (!res.ok) {
33048
+ throw new McpToolError(`Fetching ${describeUrl(current, requestedUrl)} returned HTTP ${res.status} ${res.statusText || ""}`.trim(), {
33049
+ hint: "The URL must be publicly readable \u2014 a login page or signed-URL expiry shows up here as 401/403/404."
33050
+ });
33051
+ }
33052
+ const contentType = (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
33053
+ if (!accept.some((prefix) => contentType.startsWith(prefix))) {
33054
+ const expected = accept.map((p) => `${p.replace(/\/$/, "")}/*`).join(" or ");
33055
+ throw new McpToolError(
33056
+ `${describeUrl(current, requestedUrl)} is not ${accept.length === 1 && accept[0] === "image/" ? "an image" : "usable media"}: the server returned Content-Type "${contentType || "(none)"}", expected ${expected}.`,
33057
+ { hint: "Link directly to the media file, not to the page that displays it." }
33058
+ );
33059
+ }
33060
+ let bytes;
33061
+ try {
33062
+ bytes = await readCapped(res, maxBytes, current, requestedUrl);
33063
+ } catch (err) {
33064
+ if (err instanceof McpToolError) throw err;
33065
+ throw fetchFailure(err, current, requestedUrl, timeoutMs);
33066
+ }
33067
+ if (bytes.byteLength === 0) {
33068
+ throw new McpToolError(`${describeUrl(current, requestedUrl)} returned an empty body.`);
33069
+ }
33070
+ return { bytes, mimeType: contentType, size: bytes.byteLength, finalUrl: current, requestedUrl };
33071
+ }
33072
+ }
33073
+
33074
+ // src/inputs.ts
33075
+ function hasImageInput(args) {
33076
+ return Boolean(
33077
+ args.images?.length || args.images_base64?.length || args.images_url?.length || args.images_file_uris?.length || args.from_clipboard
33078
+ );
33079
+ }
33080
+ var ASSUMED_TTL_MS = 47 * 60 * 60 * 1e3;
33081
+ function expiresAtMs(expirationTime, now) {
33082
+ if (expirationTime) {
33083
+ const t = Date.parse(expirationTime);
33084
+ if (Number.isFinite(t)) return t;
33085
+ }
33086
+ return now + ASSUMED_TTL_MS;
33087
+ }
33088
+ async function resolveImageInputs(args, client2) {
33089
+ const report = {};
33090
+ const inputs = [];
33091
+ const onDisk = client2.mediaSink?.persistsFiles ?? true;
33092
+ if (args.from_clipboard) {
33093
+ inputs.push(await (await Promise.resolve().then(() => (init_clipboard(), clipboard_exports))).readClipboardImage());
33094
+ }
33095
+ for (const path of args.images ?? []) {
33096
+ inputs.push(await resolveLocalPath(path, client2, onDisk, report));
33097
+ }
33098
+ if (args.images_base64?.length) {
33099
+ const { decodeImageInput: decodeImageInput2 } = await Promise.resolve().then(() => (init_images(), images_exports));
33100
+ for (const b64 of args.images_base64) inputs.push(decodeImageInput2(b64));
33101
+ }
33102
+ const fetched = /* @__PURE__ */ new Map();
33103
+ for (const url2 of args.images_url ?? []) {
33104
+ const hit = fetched.get(url2);
33105
+ if (hit) {
33106
+ inputs.push(hit);
33107
+ continue;
33108
+ }
33109
+ const resolved = await resolveUrl(url2, client2, report);
33110
+ fetched.set(url2, resolved);
33111
+ inputs.push(resolved);
33112
+ }
33113
+ const seenUris = /* @__PURE__ */ new Map();
33114
+ for (const ref of args.images_file_uris ?? []) {
33115
+ const hit = seenUris.get(ref);
33116
+ if (hit) {
33117
+ inputs.push(hit);
33118
+ continue;
33119
+ }
33120
+ const file2 = await client2.getFile(ref);
33121
+ const input = { uri: file2.uri, mimeType: file2.mimeType };
33122
+ seenUris.set(ref, input);
33123
+ (report.file_uris ??= []).push(file2.name);
33124
+ inputs.push(input);
33125
+ }
33126
+ return { inputs, report: Object.keys(report).length > 0 ? report : void 0 };
33127
+ }
33128
+ async function resolveLocalPath(path, client2, onDisk, report) {
33129
+ const { readImageAsInline: readImageAsInline2, resolveImagePath: resolveImagePath2 } = await Promise.resolve().then(() => (init_images(), images_exports));
33130
+ if (!onDisk) return readImageAsInline2(path);
33131
+ const { stat: stat3 } = await import("node:fs/promises");
33132
+ const resolved = resolveImagePath2(path);
33133
+ let key;
33134
+ try {
33135
+ const { mtimeMs, size } = await stat3(resolved);
33136
+ key = `path\0${resolved}\0${mtimeMs}\0${size}`;
33137
+ } catch {
33138
+ return readImageAsInline2(path);
33139
+ }
33140
+ const cached2 = client2.session.cachedUpload(key);
33141
+ if (cached2) return { uri: cached2.uri, mimeType: cached2.mimeType };
33142
+ const references = client2.session.countReference(key);
33143
+ const inline = await readImageAsInline2(resolved);
33144
+ if (references < 2) return inline;
33145
+ try {
33146
+ const bytes = Uint8Array.from(atob(inline.base64), (c) => c.charCodeAt(0));
33147
+ const displayName = resolved.split(/[\\/]/).pop() || "image";
33148
+ const uploaded = await client2.uploadBytes(bytes, inline.mimeType, displayName);
33149
+ client2.session.uploadCache.set(key, {
33150
+ name: uploaded.name,
33151
+ uri: uploaded.uri,
33152
+ mimeType: uploaded.mimeType,
33153
+ expiresAtMs: expiresAtMs(uploaded.expirationTime, Date.now())
33154
+ });
33155
+ (report.auto_uploaded ??= []).push({ path: resolved, file_uri: uploaded.name, expires: uploaded.expirationTime });
33156
+ return { uri: uploaded.uri, mimeType: uploaded.mimeType };
33157
+ } catch {
33158
+ return inline;
33159
+ }
33160
+ }
33161
+ async function resolveUrl(url2, client2, report) {
33162
+ const fetched = await client2.fetchRemoteImage(url2);
33163
+ const entry = {
33164
+ url: url2,
33165
+ mime_type: fetched.mimeType,
33166
+ bytes: fetched.size
33167
+ };
33168
+ (report.fetched_urls ??= []).push(entry);
33169
+ if (fetched.size <= IMAGE_INLINE_MAX_BYTES) {
33170
+ return { base64: encodeBase64(fetched.bytes), mimeType: fetched.mimeType };
33171
+ }
33172
+ const displayName = fileNameFromUrl(url2);
33173
+ const uploaded = await client2.uploadBytes(fetched.bytes, fetched.mimeType, displayName);
33174
+ entry.file_uri = uploaded.name;
33175
+ return { uri: uploaded.uri, mimeType: uploaded.mimeType };
33176
+ }
33177
+ function fileNameFromUrl(url2) {
33178
+ try {
33179
+ const name = new URL(url2).pathname.split("/").filter(Boolean).pop();
33180
+ return name ? decodeURIComponent(name).slice(0, 120) : "image";
33181
+ } catch {
33182
+ return "image";
33183
+ }
33184
+ }
33185
+ function encodeBase64(bytes) {
33186
+ let binary = "";
33187
+ const CHUNK = 32768;
33188
+ for (let i = 0; i < bytes.length; i += CHUNK) {
33189
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
33190
+ }
33191
+ return btoa(binary);
33192
+ }
33193
+ function requireImageInput(args) {
33194
+ if (hasImageInput(args)) return;
33195
+ throw new McpToolError(
33196
+ "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`."
33197
+ );
33198
+ }
33199
+
32851
33200
  // src/tools/shared.ts
32852
33201
  init_zod();
32853
33202
  init_dist();
@@ -32883,6 +33232,16 @@ var idempotencyKeySchema = external_exports.string().min(1).optional().describe(
32883
33232
  var asyncSchema = external_exports.boolean().optional().describe(
32884
33233
  "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
33234
  );
33235
+ function imagesUrlSchema(label = "Reference images") {
33236
+ return external_exports.array(external_exports.string().url()).optional().describe(
33237
+ `${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 ${Math.round(IMAGE_URL_MAX_BYTES / (1024 * 1024))}MB each; must be a directly-linked image (Content-Type image/*).`
33238
+ );
33239
+ }
33240
+ function imagesFileUrisSchema(label = "Reference images") {
33241
+ return external_exports.array(external_exports.string().min(1)).optional().describe(
33242
+ `${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.`
33243
+ );
33244
+ }
32886
33245
  var sharedImageSchema = {
32887
33246
  // Bare id only: the model is interpolated into the URL path
32888
33247
  // (`/models/${model}:generateContent`), so slashes/colons/queries must be
@@ -33004,7 +33363,7 @@ function assertLocalInputsAvailable(sink, args) {
33004
33363
  if (args.from_clipboard) unavailable.push("`from_clipboard` (reads the macOS clipboard via child_process)");
33005
33364
  if (args.video_path) unavailable.push("`video_path` (uploads a file streamed from disk)");
33006
33365
  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`.";
33366
+ 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
33367
  throw new McpToolError(
33009
33368
  `${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
33369
  { hint: fix }
@@ -33197,7 +33556,9 @@ function registerGenerateTools(server, client2) {
33197
33556
  count: external_exports.number().int().positive().max(8).optional().describe("Number of independent images (default 1)"),
33198
33557
  filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
33199
33558
  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"),
33559
+ images_url: imagesUrlSchema(),
33560
+ images_file_uris: imagesFileUrisSchema(),
33561
+ 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
33562
  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
33563
  video_path: videoPathSchema,
33203
33564
  confirm: schemaConfirm,
@@ -33221,13 +33582,15 @@ function registerGenerateTools(server, client2) {
33221
33582
  images: args.images,
33222
33583
  images_base64: args.images_base64,
33223
33584
  from_clipboard: args.from_clipboard,
33585
+ images_url: args.images_url,
33586
+ images_file_uris: args.images_file_uris,
33224
33587
  video_url: args.video_url,
33225
33588
  video_path: args.video_path
33226
33589
  });
33227
33590
  return client2.session.jobs.dispatch({ toolName: "gemini_image_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33228
33591
  const seed = pickSeed(args.seed);
33229
33592
  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 });
33593
+ const { inputs: refInputs, report } = await withProgressHeartbeat(extra, "Resolving reference images", () => resolveImageInputs(args, client2));
33231
33594
  const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args, client2));
33232
33595
  const named = [];
33233
33596
  let capturedText;
@@ -33256,6 +33619,7 @@ function registerGenerateTools(server, client2) {
33256
33619
  const meta3 = buildMeta(model, seed, args);
33257
33620
  if (capturedText) meta3.text = capturedText;
33258
33621
  if (capturedGrounding) meta3.grounding = capturedGrounding;
33622
+ if (report) meta3.image_inputs = report;
33259
33623
  if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
33260
33624
  meta3.hint = REFINE_HINT;
33261
33625
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, count, persistsFiles: client2.mediaSink?.persistsFiles });
@@ -33272,7 +33636,9 @@ function registerGenerateTools(server, client2) {
33272
33636
  inputSchema: {
33273
33637
  prompt: external_exports.string().min(1).describe("Instruction describing the edit or composition"),
33274
33638
  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"),
33639
+ images_url: imagesUrlSchema("Input images"),
33640
+ images_file_uris: imagesFileUrisSchema("Input images"),
33641
+ 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
33642
  filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
33277
33643
  confirm: schemaConfirm,
33278
33644
  ...sharedImageSchema
@@ -33280,11 +33646,7 @@ function registerGenerateTools(server, client2) {
33280
33646
  },
33281
33647
  async (args, extra) => {
33282
33648
  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
- }
33649
+ requireImageInput(args);
33288
33650
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
33289
33651
  if (gate) return gate;
33290
33652
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
@@ -33297,12 +33659,14 @@ function registerGenerateTools(server, client2) {
33297
33659
  google_search: args.google_search,
33298
33660
  images: args.images,
33299
33661
  images_base64: args.images_base64,
33300
- from_clipboard: args.from_clipboard
33662
+ from_clipboard: args.from_clipboard,
33663
+ images_url: args.images_url,
33664
+ images_file_uris: args.images_file_uris
33301
33665
  });
33302
33666
  return client2.session.jobs.dispatch({ toolName: "gemini_image_edit", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33303
33667
  const seed = pickSeed(args.seed);
33304
33668
  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 });
33669
+ const { inputs, report } = await withProgressHeartbeat(extra, "Resolving input images", () => resolveImageInputs(args, client2));
33306
33670
  const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client2.generate({
33307
33671
  prompt: args.prompt,
33308
33672
  images: inputs,
@@ -33318,6 +33682,7 @@ function registerGenerateTools(server, client2) {
33318
33682
  const meta3 = buildMeta(model, seed, args);
33319
33683
  if (text) meta3.text = text;
33320
33684
  if (result.grounding) meta3.grounding = result.grounding;
33685
+ if (report) meta3.image_inputs = report;
33321
33686
  meta3.hint = REFINE_HINT;
33322
33687
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: client2.mediaSink?.persistsFiles });
33323
33688
  if (risk) meta3.timeout_risk = risk;
@@ -33344,7 +33709,9 @@ function registerSetTools(server, client2) {
33344
33709
  reference_mode: external_exports.enum(["master", "chain"]).optional().describe("master: every image references the master (default). chain: each references the previous."),
33345
33710
  basename: external_exports.string().optional().describe("Base filename prefix for output images (default: slugified master_prompt)"),
33346
33711
  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"),
33712
+ master_images_url: imagesUrlSchema("Reference images passed to the master AND to every scene call (fetched once)"),
33713
+ master_images_file_uris: imagesFileUrisSchema("Reference images passed to the master AND to every scene call"),
33714
+ 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
33715
  confirm: schemaConfirm,
33349
33716
  ...sharedImageSchema
33350
33717
  }
@@ -33369,13 +33736,24 @@ function registerSetTools(server, client2) {
33369
33736
  google_search: args.google_search,
33370
33737
  master_images: args.master_images,
33371
33738
  master_images_base64: args.master_images_base64,
33372
- from_clipboard: args.from_clipboard
33739
+ from_clipboard: args.from_clipboard,
33740
+ master_images_url: args.master_images_url,
33741
+ master_images_file_uris: args.master_images_file_uris
33373
33742
  });
33374
33743
  return client2.session.jobs.dispatch({ toolName: "gemini_image_set", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33375
33744
  const seed = pickSeed(args.seed);
33376
33745
  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
33746
  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 });
33747
+ const carried = await resolveImageInputs(
33748
+ { images_url: args.master_images_url, images_file_uris: args.master_images_file_uris },
33749
+ client2
33750
+ );
33751
+ const masterOnly = await resolveImageInputs(
33752
+ { images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard },
33753
+ client2
33754
+ );
33755
+ const masterRefInputs = [...masterOnly.inputs, ...carried.inputs];
33756
+ const report = carried.report || masterOnly.report ? { ...masterOnly.report, ...carried.report } : void 0;
33379
33757
  const named = [];
33380
33758
  const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
33381
33759
  const result = await client2.generate({
@@ -33391,13 +33769,13 @@ function registerSetTools(server, client2) {
33391
33769
  if (mode === "chain") {
33392
33770
  let ref = master;
33393
33771
  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 });
33772
+ const { images: [img] } = await client2.generate({ prompt: scenePrompts[i], images: [ref, ...carried.inputs], seed: seed + i + 1, ...cfg });
33395
33773
  named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` });
33396
33774
  ref = img;
33397
33775
  }
33398
33776
  } else {
33399
33777
  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]))
33778
+ scenePrompts.map((p, i) => client2.generate({ prompt: p, images: [master, ...carried.inputs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
33401
33779
  );
33402
33780
  scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` }));
33403
33781
  }
@@ -33407,6 +33785,7 @@ function registerSetTools(server, client2) {
33407
33785
  const meta3 = buildMeta(model, seed, args);
33408
33786
  if (masterText) meta3.text = masterText;
33409
33787
  if (masterResult.grounding) meta3.grounding = masterResult.grounding;
33788
+ if (report) meta3.image_inputs = report;
33410
33789
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client2.mediaSink?.persistsFiles });
33411
33790
  if (risk) meta3.timeout_risk = risk;
33412
33791
  return emit(named, { ...args, sink: client2.mediaSink }, meta3);
@@ -33448,6 +33827,42 @@ var SessionState = class {
33448
33827
  * model against the requested edit.
33449
33828
  */
33450
33829
  writtenOutputs = /* @__PURE__ */ new Set();
33830
+ /**
33831
+ * Files API uploads this session can reuse, keyed by an opaque identity
33832
+ * string (for a local path: absolute path + mtime + size, so an edited file
33833
+ * is a different key rather than a stale hit).
33834
+ *
33835
+ * Backs the stdio "referenced twice → upload once" optimization: the second
33836
+ * time a session sends the same picture, the bytes are replaced by a
33837
+ * `files/…` reference that costs nothing to repeat. Session-scoped, like
33838
+ * everything else here — a uri is minted by ONE user's API key and is only
33839
+ * readable with that key, so sharing this map across the connector's tenants
33840
+ * would hand out references another user cannot use and this user did not
33841
+ * create.
33842
+ */
33843
+ uploadCache = /* @__PURE__ */ new Map();
33844
+ /**
33845
+ * How many times each identity has been referenced this session. The
33846
+ * threshold for auto-uploading is "more than once", so the count has to
33847
+ * outlive the call that first saw it.
33848
+ */
33849
+ referenceCounts = /* @__PURE__ */ new Map();
33850
+ /** Record a reference and return the running total (1 on first sight). */
33851
+ countReference(key) {
33852
+ const next = (this.referenceCounts.get(key) ?? 0) + 1;
33853
+ this.referenceCounts.set(key, next);
33854
+ return next;
33855
+ }
33856
+ /** A cached upload, or `undefined` if absent or past its Files API TTL. */
33857
+ cachedUpload(key, now = Date.now()) {
33858
+ const hit = this.uploadCache.get(key);
33859
+ if (!hit) return void 0;
33860
+ if (hit.expiresAtMs <= now) {
33861
+ this.uploadCache.delete(key);
33862
+ return void 0;
33863
+ }
33864
+ return hit;
33865
+ }
33451
33866
  /** Forget everything. Tests use this; nothing in `src/` calls it. */
33452
33867
  reset() {
33453
33868
  this.jobs.reset();
@@ -33455,6 +33870,8 @@ var SessionState = class {
33455
33870
  this.lastMusicInteractionId = void 0;
33456
33871
  this.lastVideoInteractionId = void 0;
33457
33872
  this.writtenOutputs.clear();
33873
+ this.uploadCache.clear();
33874
+ this.referenceCounts.clear();
33458
33875
  }
33459
33876
  };
33460
33877
 
@@ -33495,6 +33912,22 @@ var ChainedRequest404Error = class extends McpToolError {
33495
33912
  upstreamMessage;
33496
33913
  retries;
33497
33914
  };
33915
+ function generatePart(img) {
33916
+ return img.uri ? { file_data: { file_uri: img.uri, mime_type: img.mimeType } } : { inline_data: { mime_type: img.mimeType, data: img.base64 } };
33917
+ }
33918
+ function interactPart(img) {
33919
+ return img.uri ? { type: "image", uri: img.uri, mime_type: img.mimeType } : { type: "image", mime_type: img.mimeType, data: img.base64 };
33920
+ }
33921
+ function normalizeFileName(ref) {
33922
+ const trimmed = ref.trim();
33923
+ const match = /(?:^|\/)(files\/[\w-]+)\/?$/.exec(trimmed);
33924
+ if (!match) {
33925
+ throw new McpToolError(`Not a Gemini Files API reference: "${ref}"`, {
33926
+ hint: "Pass the `files/<id>` name (or the full https://\u2026/v1beta/files/<id> uri) returned by gemini_upload_file / POST /upload."
33927
+ });
33928
+ }
33929
+ return match[1];
33930
+ }
33498
33931
  var GeminiClient = class {
33499
33932
  /**
33500
33933
  * A key handed in explicitly (the connector's per-session key). When absent
@@ -33583,6 +34016,14 @@ var GeminiClient = class {
33583
34016
  }
33584
34017
  return key;
33585
34018
  }
34019
+ /**
34020
+ * Fetch one `images_url` reference server-side. Routed through the client so
34021
+ * it uses the SAME injected `fetchImpl` as every upstream call — which is
34022
+ * what makes it mockable in tests and correct on the Worker.
34023
+ */
34024
+ async fetchRemoteImage(url2, opts = {}) {
34025
+ return fetchRemoteImage(url2, { ...opts, fetchImpl: this.fetchImpl, timeoutMs: resolveTimeoutMs(opts.timeoutMs) });
34026
+ }
33586
34027
  /** The default model after env override (no per-call arg). */
33587
34028
  defaultModel() {
33588
34029
  return resolveModel(void 0, readEnvVar("GEMINI_IMAGE_MODEL"));
@@ -33614,8 +34055,60 @@ var GeminiClient = class {
33614
34055
  * (enforced locally before any bytes are sent).
33615
34056
  */
33616
34057
  async uploadVideo(path, mimeType) {
33617
- const key = this.requireKey();
33618
34058
  const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: "Video" });
34059
+ return this.uploadToFilesApi(blob, mimeType, basename(path), blob.size);
34060
+ }
34061
+ /**
34062
+ * Upload already-in-memory bytes (a fetched `images_url`, a `data_base64`
34063
+ * payload, an inline `gemini_upload_file` body) to the Files API. Same three
34064
+ * steps as {@link uploadVideo}, minus the filesystem — which is what makes it
34065
+ * usable on the hosted connector, where there is no disk to stream from.
34066
+ */
34067
+ async uploadBytes(bytes, mimeType, displayName) {
34068
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
34069
+ if (view.byteLength > FILE_MAX_BYTES) {
34070
+ throw new McpToolError(`File is ${view.byteLength} bytes, over the Gemini Files API limit of ${FILE_MAX_BYTES} bytes (2 GB).`);
34071
+ }
34072
+ const body = new Blob([view], { type: mimeType });
34073
+ return this.uploadToFilesApi(body, mimeType, displayName, view.byteLength);
34074
+ }
34075
+ /**
34076
+ * Upload a request body straight through to the Files API without buffering
34077
+ * it — the direct-upload HTTP endpoint (`POST /upload` on the Worker) hands
34078
+ * us a `ReadableStream` and a `Content-Length`, and the whole point of that
34079
+ * endpoint is that the bytes never materialize anywhere they'd cost context
34080
+ * or memory.
34081
+ *
34082
+ * `contentLength` is REQUIRED because the resumable protocol's start step
34083
+ * declares it up front (`X-Goog-Upload-Header-Content-Length`); a stream of
34084
+ * unknown length has to be buffered by the caller first.
34085
+ */
34086
+ async uploadStream(stream, mimeType, displayName, contentLength) {
34087
+ if (contentLength > FILE_MAX_BYTES) {
34088
+ throw new McpToolError(`File is ${contentLength} bytes, over the Gemini Files API limit of ${FILE_MAX_BYTES} bytes (2 GB).`);
34089
+ }
34090
+ return this.uploadToFilesApi(stream, mimeType, displayName, contentLength);
34091
+ }
34092
+ /**
34093
+ * The resumable upload itself. Three live-verified steps (docs/GEMINI-API.md
34094
+ * "Files API — local video upload"):
34095
+ *
34096
+ * 1. `POST {UPLOAD_BASE_URL}/files` (command `start`) → the upload session
34097
+ * URL arrives in the `x-goog-upload-url` RESPONSE HEADER (body is empty).
34098
+ * 2. `POST <session url>` (command `upload, finalize`) with the bytes.
34099
+ * Response: `{file:…}`.
34100
+ * 3. Poll `GET /v1beta/files/<id>` while `state` is `PROCESSING`, until
34101
+ * `ACTIVE` (the poll response is the File object UNWRAPPED). Images are
34102
+ * usually `ACTIVE` immediately; video is what processes.
34103
+ *
34104
+ * These two upload calls are raw `fetchImpl` (not `createApiClient`): step 1
34105
+ * needs the response *header* and step 2 posts a binary body — neither fits
34106
+ * `fetchJson`. The poll DOES go through the shared client (timeout + retry).
34107
+ * Uploads deliberately have no abort timeout: a multi-hundred-MB file can
34108
+ * legitimately take longer than any fixed budget.
34109
+ */
34110
+ async uploadToFilesApi(body, mimeType, displayName, contentLength) {
34111
+ const key = this.requireKey();
33619
34112
  const startPath = "/files";
33620
34113
  const startRes = await this.fetchImpl(`${UPLOAD_BASE_URL}${startPath}`, {
33621
34114
  method: "POST",
@@ -33623,11 +34116,11 @@ var GeminiClient = class {
33623
34116
  "x-goog-api-key": key,
33624
34117
  "X-Goog-Upload-Protocol": "resumable",
33625
34118
  "X-Goog-Upload-Command": "start",
33626
- "X-Goog-Upload-Header-Content-Length": String(blob.size),
34119
+ "X-Goog-Upload-Header-Content-Length": String(contentLength),
33627
34120
  "X-Goog-Upload-Header-Content-Type": mimeType,
33628
34121
  "Content-Type": "application/json"
33629
34122
  },
33630
- body: JSON.stringify({ file: { display_name: basename(path) } })
34123
+ body: JSON.stringify({ file: { display_name: displayName } })
33631
34124
  });
33632
34125
  if (!startRes.ok) {
33633
34126
  throw new McpToolError(
@@ -33640,13 +34133,17 @@ var GeminiClient = class {
33640
34133
  hint: "The Files API resumable-upload contract may have changed \u2014 see docs/GEMINI-API.md."
33641
34134
  });
33642
34135
  }
34136
+ const streaming = !(body instanceof Blob);
33643
34137
  const upRes = await this.fetchImpl(uploadUrl, {
33644
34138
  method: "POST",
33645
34139
  headers: {
33646
34140
  "X-Goog-Upload-Offset": "0",
33647
- "X-Goog-Upload-Command": "upload, finalize"
34141
+ "X-Goog-Upload-Command": "upload, finalize",
34142
+ ...streaming ? { "Content-Length": String(contentLength) } : {}
33648
34143
  },
33649
- body: blob
34144
+ body,
34145
+ // Required by undici/workerd to send a ReadableStream body at all.
34146
+ ...streaming ? { duplex: "half" } : {}
33650
34147
  });
33651
34148
  if (!upRes.ok) {
33652
34149
  throw new McpToolError(
@@ -33660,8 +34157,8 @@ var GeminiClient = class {
33660
34157
  }
33661
34158
  for (let attempt = 0; file2.state === "PROCESSING"; attempt++) {
33662
34159
  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."
34160
+ throw new McpToolError(`${SERVICE} file ${name} is still processing after ${FILE_POLL_INTERVAL_MS * FILE_POLL_MAX_ATTEMPTS / 6e4} minutes`, {
34161
+ 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
34162
  });
33666
34163
  }
33667
34164
  await this.sleep(FILE_POLL_INTERVAL_MS);
@@ -33669,14 +34166,78 @@ var GeminiClient = class {
33669
34166
  }
33670
34167
  if (file2.state !== "ACTIVE") {
33671
34168
  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." }
34169
+ `${SERVICE} file processing failed (state ${file2.state ?? "unknown"})${file2.error?.message ? `: ${file2.error.message}` : ""}`,
34170
+ { hint: "Check the file is a supported format/codec and under 2 GB." }
33674
34171
  );
33675
34172
  }
33676
34173
  if (!file2.uri) {
33677
34174
  throw new McpToolError(`${SERVICE} did not return a uri for uploaded file ${name}`);
33678
34175
  }
33679
- return { name, uri: file2.uri, mimeType: file2.mimeType ?? mimeType, expirationTime: file2.expirationTime };
34176
+ return {
34177
+ name,
34178
+ uri: file2.uri,
34179
+ mimeType: file2.mimeType ?? mimeType,
34180
+ expirationTime: file2.expirationTime,
34181
+ sizeBytes: file2.sizeBytes,
34182
+ displayName: file2.displayName
34183
+ };
34184
+ }
34185
+ /**
34186
+ * List the files this API key currently has in the Files API. Retention is
34187
+ * ~48h, so the list is naturally short-lived — an empty list after a day is
34188
+ * expiry, not a bug.
34189
+ */
34190
+ async listFiles(pageSize = 100) {
34191
+ const size = Math.min(Math.max(Math.trunc(pageSize), 1), 100);
34192
+ const data = await this.call("GET", `/files?pageSize=${size}`);
34193
+ return (data.files ?? []).filter((f) => !!f.name && !!f.uri).map((f) => ({
34194
+ name: f.name,
34195
+ uri: f.uri,
34196
+ mimeType: f.mimeType ?? "application/octet-stream",
34197
+ state: f.state,
34198
+ createTime: f.createTime,
34199
+ expirationTime: f.expirationTime,
34200
+ sizeBytes: f.sizeBytes,
34201
+ displayName: f.displayName
34202
+ }));
34203
+ }
34204
+ /**
34205
+ * Fetch one file's metadata. Used to resolve a caller-supplied
34206
+ * `images_file_uris` entry to its real MIME type — and, as a side effect, to
34207
+ * fail fast with a clear "that file is gone" instead of the Interactions
34208
+ * API's generic 404 after a billable request was already built.
34209
+ */
34210
+ async getFile(ref) {
34211
+ const resource = normalizeFileName(ref);
34212
+ const file2 = await this.call("GET", `/${resource}`);
34213
+ if (!file2.uri) {
34214
+ throw new McpToolError(`${SERVICE} returned no uri for ${resource}`, {
34215
+ hint: "Files expire ~48h after upload \u2014 re-upload with gemini_upload_file (or POST /upload) and use the new uri."
34216
+ });
34217
+ }
34218
+ if (file2.state && file2.state !== "ACTIVE") {
34219
+ throw new McpToolError(`${SERVICE} file ${resource} is not usable yet (state ${file2.state}).`, {
34220
+ hint: "Wait for the upload to finish processing, then retry."
34221
+ });
34222
+ }
34223
+ return {
34224
+ name: file2.name ?? resource,
34225
+ uri: file2.uri,
34226
+ mimeType: file2.mimeType ?? "application/octet-stream",
34227
+ expirationTime: file2.expirationTime,
34228
+ sizeBytes: file2.sizeBytes,
34229
+ displayName: file2.displayName
34230
+ };
34231
+ }
34232
+ /**
34233
+ * Delete one uploaded file. `name` is interpolated into the URL path, so it
34234
+ * is validated to a bare `files/<id>` for the same reason the model id is
34235
+ * (see {@link uploadToFilesApi}); a full `https://…/v1beta/files/<id>` uri is
34236
+ * accepted and reduced to that form, since that is what every result echoes.
34237
+ */
34238
+ async deleteFile(name) {
34239
+ const resource = normalizeFileName(name);
34240
+ await this.call("DELETE", `/${resource}`);
33680
34241
  }
33681
34242
  async listModels() {
33682
34243
  const data = await this.call("GET", "/models?pageSize=200");
@@ -33685,9 +34246,7 @@ var GeminiClient = class {
33685
34246
  async generate(opts) {
33686
34247
  const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33687
34248
  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
- }
34249
+ for (const img of opts.images ?? []) parts.push(generatePart(img));
33691
34250
  if (opts.videoUrl) {
33692
34251
  parts.push({ file_data: { file_uri: opts.videoUrl, mime_type: opts.videoMimeType ?? "video/mp4" } });
33693
34252
  }
@@ -33743,9 +34302,7 @@ var GeminiClient = class {
33743
34302
  async interact(opts) {
33744
34303
  const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33745
34304
  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
- }
34305
+ for (const img of opts.images ?? []) inputParts.push(interactPart(img));
33749
34306
  if (opts.videoUrl) {
33750
34307
  inputParts.push({ type: "video", uri: opts.videoUrl, mime_type: opts.videoMimeType ?? "video/mp4" });
33751
34308
  }
@@ -33778,7 +34335,7 @@ var GeminiClient = class {
33778
34335
  async generateVideo(opts) {
33779
34336
  const model = opts.model ?? DEFAULT_VIDEO_MODEL;
33780
34337
  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 });
34338
+ for (const img of opts.images ?? []) inputParts.push(interactPart(img));
33782
34339
  const responseFormat = { type: "video", delivery: "inline" };
33783
34340
  if (opts.aspectRatio) responseFormat.aspect_ratio = opts.aspectRatio;
33784
34341
  const body = { model, input: inputParts, response_format: responseFormat };
@@ -33800,7 +34357,7 @@ var GeminiClient = class {
33800
34357
  async generateMusic(opts) {
33801
34358
  const model = opts.model ?? DEFAULT_MUSIC_MODEL;
33802
34359
  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 });
34360
+ for (const img of opts.images ?? []) inputParts.push(interactPart(img));
33804
34361
  const responseFormat = { type: "audio" };
33805
34362
  if (opts.audioFormat) responseFormat.audio_format = opts.audioFormat;
33806
34363
  const body = { model, input: inputParts, response_format: responseFormat };
@@ -33959,7 +34516,11 @@ function registerInteractTools(server, client2) {
33959
34516
  previous_interaction_id: external_exports.string().optional().describe("ID from a prior gemini_interact call \u2014 continues that multi-turn conversation"),
33960
34517
  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
34518
  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}`),
34519
+ images_url: imagesUrlSchema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
34520
+ images_file_uris: imagesFileUrisSchema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
34521
+ images_base64: external_exports.array(external_exports.string().min(1)).optional().describe(
34522
+ `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}`
34523
+ ),
33963
34524
  model: external_exports.string().optional().describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
33964
34525
  aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Output aspect ratio"),
33965
34526
  image_size: external_exports.enum(IMAGE_SIZES).optional().describe("Output resolution (512 = 0.5K, Flash-only)"),
@@ -34017,11 +34578,13 @@ function registerInteractTools(server, client2) {
34017
34578
  images,
34018
34579
  images_base64: args.images_base64,
34019
34580
  from_clipboard: args.from_clipboard,
34581
+ images_url: args.images_url,
34582
+ images_file_uris: args.images_file_uris,
34020
34583
  video_url: args.video_url,
34021
34584
  video_path: args.video_path
34022
34585
  });
34023
34586
  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 });
34587
+ const { inputs, report } = await resolveImageInputs({ ...args, images }, client2);
34025
34588
  const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args, client2));
34026
34589
  const callOpts = {
34027
34590
  input: args.input,
@@ -34073,6 +34636,7 @@ function registerInteractTools(server, client2) {
34073
34636
  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
34637
  if (r.text) meta3.text = r.text;
34075
34638
  if (r.grounding) meta3.grounding = r.grounding;
34639
+ if (report) meta3.image_inputs = report;
34076
34640
  if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
34077
34641
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: onDisk });
34078
34642
  if (risk) meta3.timeout_risk = risk;
@@ -34278,12 +34842,159 @@ function registerJobTools(server, client2) {
34278
34842
  );
34279
34843
  }
34280
34844
 
34845
+ // src/tools/files.ts
34846
+ init_zod();
34847
+ init_dist();
34848
+ var UPLOAD_URL_MAX_BYTES_DISK = 100 * 1024 * 1024;
34849
+ var UPLOAD_URL_MAX_BYTES_HOSTED = 25 * 1024 * 1024;
34850
+ var mb = (bytes) => Math.round(bytes / (1024 * 1024));
34851
+ var UPLOAD_ACCEPT = ["image/", "video/", "audio/"];
34852
+ 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.";
34853
+ function uploadResult(file2) {
34854
+ return textResult({
34855
+ // The `files/<id>` form is what every tool parameter documents, so it leads.
34856
+ file_uri: file2.name,
34857
+ // The absolute uri, for callers that would rather pass it through verbatim.
34858
+ uri: file2.uri,
34859
+ name: file2.name,
34860
+ mime_type: file2.mimeType,
34861
+ ...file2.sizeBytes ? { size_bytes: Number(file2.sizeBytes) } : {},
34862
+ ...file2.displayName ? { display_name: file2.displayName } : {},
34863
+ expires: file2.expirationTime ?? "~48h from upload",
34864
+ hint: `Pass "${file2.name}" in images_file_uris (or video_url for video) on any image tool. ${RETENTION_NOTE}`
34865
+ });
34866
+ }
34867
+ function registerFileTools(server, client2) {
34868
+ const onDisk = client2.mediaSink?.persistsFiles ?? true;
34869
+ const urlMaxBytes = onDisk ? UPLOAD_URL_MAX_BYTES_DISK : UPLOAD_URL_MAX_BYTES_HOSTED;
34870
+ server.registerTool(
34871
+ "gemini_upload_file",
34872
+ {
34873
+ 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."),
34874
+ annotations: { readOnlyHint: false, openWorldHint: true },
34875
+ inputSchema: {
34876
+ url: external_exports.string().url().optional().describe(
34877
+ `Public https URL the SERVER downloads and uploads (image/video/audio, up to ${mb(urlMaxBytes)}MB). No bytes pass through the conversation.` + (onDisk ? "" : " Larger files: POST the raw bytes to /upload, which streams instead of buffering.")
34878
+ ),
34879
+ 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)."),
34880
+ path: external_exports.string().min(1).optional().describe(
34881
+ 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."
34882
+ ),
34883
+ 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)"),
34884
+ display_name: external_exports.string().min(1).optional().describe("Human-readable name recorded against the upload"),
34885
+ confirm: schemaConfirm
34886
+ }
34887
+ },
34888
+ async (args, extra) => {
34889
+ const sources = [args.url && "url", args.data_base64 && "data_base64", args.path && "path"].filter(Boolean);
34890
+ if (sources.length !== 1) {
34891
+ throw new McpToolError(
34892
+ sources.length === 0 ? "Provide exactly one of `url`, `data_base64`" + (onDisk ? " or `path`." : ".") : `Provide exactly one source, not ${sources.length} (${sources.join(", ")}).`
34893
+ );
34894
+ }
34895
+ if (args.path) {
34896
+ if (!onDisk) {
34897
+ throw new McpToolError(
34898
+ "`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.",
34899
+ { hint: "Use `url`, `data_base64`, or POST /upload." }
34900
+ );
34901
+ }
34902
+ const gate = await previewLocalInputsUnlessConfirmed(
34903
+ args.confirm,
34904
+ "Upload a local file to the Gemini Files API",
34905
+ "/upload/v1beta/files",
34906
+ [args.path]
34907
+ );
34908
+ if (gate) return gate;
34909
+ }
34910
+ const uploaded = await withProgressHeartbeat(extra, "Uploading to the Gemini Files API", async () => {
34911
+ if (args.url) {
34912
+ const fetched = await client2.fetchRemoteImage(args.url, { maxBytes: urlMaxBytes, accept: UPLOAD_ACCEPT });
34913
+ return client2.uploadBytes(
34914
+ fetched.bytes,
34915
+ args.mime_type ?? fetched.mimeType,
34916
+ args.display_name ?? nameFromUrl(args.url)
34917
+ );
34918
+ }
34919
+ if (args.data_base64) {
34920
+ const { decodeImageInput: decodeImageInput2 } = await Promise.resolve().then(() => (init_images(), images_exports));
34921
+ const decoded = decodeImageInput2(args.data_base64);
34922
+ const bytes2 = Uint8Array.from(atob(decoded.base64), (c) => c.charCodeAt(0));
34923
+ return client2.uploadBytes(bytes2, args.mime_type ?? decoded.mimeType, args.display_name ?? "upload");
34924
+ }
34925
+ const { readImageAsInline: readImageAsInline2, resolveImagePath: resolveImagePath2 } = await Promise.resolve().then(() => (init_images(), images_exports));
34926
+ const resolved = resolveImagePath2(args.path);
34927
+ const inline = await readImageAsInline2(resolved);
34928
+ const bytes = Uint8Array.from(atob(inline.base64), (c) => c.charCodeAt(0));
34929
+ return client2.uploadBytes(
34930
+ bytes,
34931
+ args.mime_type ?? inline.mimeType,
34932
+ args.display_name ?? (resolved.split(/[\\/]/).pop() || "upload")
34933
+ );
34934
+ });
34935
+ return uploadResult(uploaded);
34936
+ }
34937
+ );
34938
+ server.registerTool(
34939
+ "gemini_list_files",
34940
+ {
34941
+ 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.",
34942
+ annotations: { readOnlyHint: true, openWorldHint: true },
34943
+ inputSchema: {
34944
+ page_size: external_exports.number().int().positive().max(100).optional().describe("Maximum files to return (1-100, default 100)")
34945
+ }
34946
+ },
34947
+ async (args) => {
34948
+ const files = await client2.listFiles(args.page_size ?? 100);
34949
+ return textResult({
34950
+ count: files.length,
34951
+ files: files.map((f) => ({
34952
+ file_uri: f.name,
34953
+ uri: f.uri,
34954
+ mime_type: f.mimeType,
34955
+ ...f.displayName ? { display_name: f.displayName } : {},
34956
+ ...f.sizeBytes ? { size_bytes: Number(f.sizeBytes) } : {},
34957
+ ...f.state ? { state: f.state } : {},
34958
+ ...f.createTime ? { created: f.createTime } : {},
34959
+ ...f.expirationTime ? { expires: f.expirationTime } : {}
34960
+ })),
34961
+ 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."
34962
+ });
34963
+ }
34964
+ );
34965
+ server.registerTool(
34966
+ "gemini_delete_file",
34967
+ {
34968
+ 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.",
34969
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
34970
+ inputSchema: {
34971
+ file_uri: external_exports.string().min(1).describe("The `files/<id>` reference (or full uri) to delete"),
34972
+ confirm: schemaConfirm
34973
+ }
34974
+ },
34975
+ async (args) => {
34976
+ const gate = previewUnlessConfirmed(args.confirm, "Delete an uploaded Gemini file", "DELETE", `/v1beta/${args.file_uri}`);
34977
+ if (gate) return gate;
34978
+ await client2.deleteFile(args.file_uri);
34979
+ return textResult({ deleted: args.file_uri });
34980
+ }
34981
+ );
34982
+ }
34983
+ function nameFromUrl(url2) {
34984
+ try {
34985
+ const name = new URL(url2).pathname.split("/").filter(Boolean).pop();
34986
+ return name ? decodeURIComponent(name).slice(0, 120) : "upload";
34987
+ } catch {
34988
+ return "upload";
34989
+ }
34990
+ }
34991
+
34281
34992
  // src/index.ts
34282
34993
  await loadStdioDotenv();
34283
34994
  await runMcp({
34284
34995
  name: "gemini-mcp",
34285
34996
  version: VERSION,
34286
34997
  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],
34998
+ tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools, registerFileTools],
34288
34999
  deps: client
34289
35000
  });