@chrischall/gemini-mcp 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Google Gemini image generation and editing — generate and edit images via the Gemini API",
10
- "version": "1.1.0"
10
+ "version": "1.3.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Gemini Images",
16
16
  "source": "./",
17
17
  "description": "Generate and edit images with Google Gemini image models (Nano Banana / Nano Banana Pro) via natural language prompts",
18
- "version": "1.1.0",
18
+ "version": "1.3.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gemini-mcp",
3
3
  "displayName": "Gemini Images",
4
- "version": "1.1.0",
4
+ "version": "1.3.0",
5
5
  "description": "MCP server for Google Gemini image generation and editing — generate and edit images via the Gemini API",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/README.md CHANGED
@@ -87,10 +87,71 @@ progress), two guards make re-issuing safe and unnecessary:
87
87
  | `gemini_list_files` | List the files currently uploaded under this API key, with MIME types and expiry times |
88
88
  | `gemini_delete_file` | Delete an uploaded file before its ~48h expiry (confirm-gated) |
89
89
 
90
+ ## Seeing your images (hosted connector)
91
+
92
+ On the hosted connector there is no filesystem, so a generated image has to come back as
93
+ something you can *open*. It does: **every result includes a URL**, with no configuration.
94
+
95
+ ```jsonc
96
+ {
97
+ "images": ["https://connector.example.com/media/gen/2026-07-29/ab12cd34-a-cat.png?exp=…&sig=…"],
98
+ "media": [{ "url": "https://…", "r2_key": "gen/2026-07-29/ab12cd34-a-cat.png",
99
+ "expires_at": "2026-07-31T12:00:00.000Z" }]
100
+ }
101
+ ```
102
+
103
+ Those links need no auth header — the signature is in the URL — so they work in a browser, in
104
+ `curl`, and in a chat message. They expire (48h by default) and the objects behind them are
105
+ swept on a retention schedule.
106
+
107
+ **Why this matters:** MCP's inline image content blocks (`inline: true`) are visible to the
108
+ *assistant* but many chat clients never render them to the user, and the assistant cannot
109
+ extract bytes back out of its own context to save them elsewhere. A generation could bill
110
+ successfully and be invisible. A URL is the portable answer; `inline` remains available, but
111
+ it is no longer the only way to receive media.
112
+
113
+ ### Three ways to serve the bytes
114
+
115
+ | | setup | URL shape |
116
+ |---|---|---|
117
+ | **Worker route** (default) | none — works out of the box | `https://<worker-host>/media/<key>?exp=&sig=` |
118
+ | **r2.dev public bucket** | enable public access on the bucket, set `MEDIA_PUBLIC_BASE_URL` | `https://pub-….r2.dev/<key>` |
119
+ | **Custom domain** (best) | attach a domain to the bucket in Cloudflare, set `MEDIA_PUBLIC_BASE_URL` | `https://media.example.com/<key>` |
120
+
121
+ The built-in Worker route is the zero-config path: the connector streams the object out of R2
122
+ itself. The other two are worth setting up if you want plain, non-expiring URLs or want media
123
+ traffic served by R2 rather than by the Worker — r2.dev is easiest but rate-limited and fine
124
+ for personal use; a custom domain is the best of the three.
125
+
126
+ **Auth on `/media` is a signed, expiring URL rather than an unlisted key.** Random keys would
127
+ be simpler, but they never expire and never revoke: anything that ever logged or forwarded the
128
+ link keeps working forever. A signature scopes access to one object with a deadline, and
129
+ rotating `MEDIA_URL_SECRET` invalidates every outstanding link at once. The tradeoff is that
130
+ links are long and cannot be shortened by hand.
131
+
132
+ ### For assistants relaying a result
133
+
134
+ Show the user the URL. If your sandbox has network egress to the connector host, fetching it
135
+ and attaching the bytes as a file gives the nicest result; otherwise present the link itself.
136
+ Whether a given client renders `![](url)` markdown inline varies by client — a bare URL is the
137
+ safe form, and a markdown link is a reasonable enhancement where you know it renders.
138
+
139
+ ### Retention
140
+
141
+ | Variable | Default | Effect |
142
+ |---|---|---|
143
+ | `MEDIA_TTL_DAYS` | `7` | Objects older than this are deleted by a daily cron (both the current `gen/` prefix and the legacy `media/` one) |
144
+ | `MEDIA_URL_SECRET` | generated | HMAC key for `/media` links; rotate to revoke all outstanding URLs |
145
+ | `MEDIA_PUBLIC_BASE_URL` | unset | Serve from R2 directly instead of the Worker route |
146
+
147
+ Signed-URL lifetime is clamped to `MEDIA_TTL_DAYS`, so a link never outlives the object it
148
+ points at.
149
+
90
150
  ## Sending reference images without burning context
91
151
 
92
- Every image tool (`gemini_image_generate`, `gemini_image_edit`, `gemini_image_set`,
93
- `gemini_interact`) takes reference images four ways. Only one of them costs model context:
152
+ Every tool that takes a reference image — `gemini_image_generate`, `gemini_image_edit`,
153
+ `gemini_image_set`, `gemini_interact`, plus `gemini_video_generate` (reference stills) and
154
+ `gemini_music_generate` — accepts them four ways. Only one of them costs model context:
94
155
 
95
156
  | Parameter | Where the bytes travel | Context cost |
96
157
  |---|---|---|
package/dist/bundle.js CHANGED
@@ -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.1.0";
32799
+ var VERSION = "1.3.0";
32812
32800
 
32813
32801
  // src/tools/models.ts
32814
32802
  init_dist();
@@ -32853,6 +32841,30 @@ init_dist();
32853
32841
 
32854
32842
  // src/fetch-image.ts
32855
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
32856
32868
  var IMAGE_URL_MAX_BYTES = 15 * 1024 * 1024;
32857
32869
  var IMAGE_INLINE_MAX_BYTES = 6 * 1024 * 1024;
32858
32870
  var MAX_REDIRECTS = 5;
@@ -32955,6 +32967,14 @@ function assertPublicHttpsUrl(raw, requested) {
32955
32967
  }
32956
32968
  return url2;
32957
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
+ }
32958
32978
  function describeUrl(current, requested) {
32959
32979
  return current === requested ? current : `${current} (redirected from ${requested})`;
32960
32980
  }
@@ -33008,9 +33028,8 @@ function fetchFailure(err, current, requested, timeoutMs) {
33008
33028
  );
33009
33029
  }
33010
33030
  function tooBig(url2, requested, size, max, partial2 = false) {
33011
- const mb2 = (n) => `${(n / (1024 * 1024)).toFixed(1)} MB`;
33012
33031
  return new McpToolError(
33013
- `Image at ${describeUrl(url2, requested)} is ${partial2 ? "over" : ""} ${mb2(size)}, above the ${mb2(max)} limit for images_url.`,
33032
+ `Image at ${describeUrl(url2, requested)} is ${partial2 ? "over" : ""} ${formatMb(size)}, above the ${formatMb(max)} limit for images_url.`,
33014
33033
  { hint: "Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri." }
33015
33034
  );
33016
33035
  }
@@ -33143,7 +33162,7 @@ async function resolveLocalPath(path, client2, onDisk, report) {
33143
33162
  const inline = await readImageAsInline2(resolved);
33144
33163
  if (references < 2) return inline;
33145
33164
  try {
33146
- const bytes = Uint8Array.from(atob(inline.base64), (c) => c.charCodeAt(0));
33165
+ const bytes = base64ToBytes(inline.base64);
33147
33166
  const displayName = resolved.split(/[\\/]/).pop() || "image";
33148
33167
  const uploaded = await client2.uploadBytes(bytes, inline.mimeType, displayName);
33149
33168
  client2.session.uploadCache.set(key, {
@@ -33167,29 +33186,13 @@ async function resolveUrl(url2, client2, report) {
33167
33186
  };
33168
33187
  (report.fetched_urls ??= []).push(entry);
33169
33188
  if (fetched.size <= IMAGE_INLINE_MAX_BYTES) {
33170
- return { base64: encodeBase64(fetched.bytes), mimeType: fetched.mimeType };
33189
+ return { base64: bytesToBase64(fetched.bytes), mimeType: fetched.mimeType };
33171
33190
  }
33172
- const displayName = fileNameFromUrl(url2);
33191
+ const displayName = fileNameFromUrl(url2, "image");
33173
33192
  const uploaded = await client2.uploadBytes(fetched.bytes, fetched.mimeType, displayName);
33174
33193
  entry.file_uri = uploaded.name;
33175
33194
  return { uri: uploaded.uri, mimeType: uploaded.mimeType };
33176
33195
  }
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
33196
  function requireImageInput(args) {
33194
33197
  if (hasImageInput(args)) return;
33195
33198
  throw new McpToolError(
@@ -33211,7 +33214,7 @@ function createDiskSink() {
33211
33214
  const { writeMedia: writeMedia2, resolveOutputDir: resolveOutputDir2 } = await Promise.resolve().then(() => (init_images(), images_exports));
33212
33215
  const dir = resolveOutputDir2(opts.output_dir);
33213
33216
  const refs = [];
33214
- for (const it of items) refs.push(await writeMedia2(dir, it.base, it.base64, it.mimeType));
33217
+ for (const it of items) refs.push({ ref: await writeMedia2(dir, it.base, it.base64, it.mimeType) });
33215
33218
  return refs;
33216
33219
  },
33217
33220
  note: () => void 0
@@ -33234,7 +33237,7 @@ var asyncSchema = external_exports.boolean().optional().describe(
33234
33237
  );
33235
33238
  function imagesUrlSchema(label = "Reference images") {
33236
33239
  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/*).`
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/*).`
33238
33241
  );
33239
33242
  }
33240
33243
  function imagesFileUrisSchema(label = "Reference images") {
@@ -33343,14 +33346,26 @@ async function emitMedia(named, kind, opts, meta3, onWritten) {
33343
33346
  return { content: blocks };
33344
33347
  }
33345
33348
  const sink = opts.sink ?? DEFAULT_SINK;
33346
- const refs = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
33349
+ const persisted = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
33347
33350
  output_dir: opts.output_dir
33348
33351
  });
33352
+ const refs = persisted.map((p) => p.ref);
33349
33353
  if (sink.persistsFiles) await onWritten?.(refs);
33350
33354
  const note = opts.inline && kind === "video" ? { inline_unsupported: "video has no MCP inline content type \u2014 written to disk instead" } : {};
33351
33355
  const storageNote = sink.note();
33356
+ const unavailable = persisted.some((p) => p.unavailable) ? {
33357
+ media_url_unavailable: "The media was stored but no public URL could be generated for it, so the values above are bare object keys, not links. This is a connector misconfiguration: set MEDIA_PUBLIC_BASE_URL, or use inline: true to receive the bytes directly."
33358
+ } : {};
33359
+ const detailed = persisted.filter((p) => p.key !== void 0);
33360
+ const media = detailed.length === persisted.length && detailed.length > 0 ? {
33361
+ media: persisted.map((p) => ({
33362
+ url: p.ref,
33363
+ ...p.key ? { r2_key: p.key } : {},
33364
+ ...p.expiresAt ? { expires_at: p.expiresAt } : {}
33365
+ }))
33366
+ } : {};
33352
33367
  const storage = sink.persistsFiles ? {} : { storage: sink.kind, storage_note: storageNote };
33353
- return textResult({ [`${kind}s`]: refs, ...note, ...storage, ...meta3 });
33368
+ return textResult({ [`${kind}s`]: refs, ...media, ...unavailable, ...note, ...storage, ...meta3 });
33354
33369
  }
33355
33370
  async function emit(named, opts, meta3, onWritten) {
33356
33371
  return emitMedia(named.map((n) => ({ media: n.image, base: n.base })), "image", opts, meta3, onWritten);
@@ -34505,7 +34520,7 @@ function splitReattachedOutputs(images, writtenOutputs) {
34505
34520
  }
34506
34521
  function registerInteractTools(server, client2) {
34507
34522
  const onDiskRuntime = client2.mediaSink?.persistsFiles ?? true;
34508
- const recoveryDescription = onDiskRuntime ? "If a call times out on the client side, the generation usually still completes: the image plus a `<image>.json` sidecar recording its interaction id land in the output dir, and `continue_last: true` still resumes that interaction \u2014 check the output dir before re-issuing (a re-issue is a second billable generation). If a chained call 404s, this tool re-anchors itself on the prior output image and re-issues un-chained: success is reported as `chain_recovered` (the chain was the problem, and you get your image anyway); a second 404 is reported as the interaction id NOT being the cause (check the model id / files uri instead). " : "This connector has no filesystem: there is no output dir and no `<image>.json` sidecar, so a lost response cannot be recovered from disk and a 404 on a chained call cannot be re-anchored automatically. Capture the returned `interaction_id` from every result, and use `async: true` + gemini_get_result for long generations. ";
34523
+ const recoveryDescription = onDiskRuntime ? "If a call times out on the client side, the generation usually still completes: the image plus a `<image>.json` sidecar recording its interaction id land in the output dir, and `continue_last: true` still resumes that interaction \u2014 check the output dir before re-issuing (a re-issue is a second billable generation). If a chained call 404s, this tool re-anchors itself on the prior output image and re-issues un-chained: success is reported as `chain_recovered` (the chain was the problem, and you get your image anyway); a second 404 is reported as the interaction id NOT being the cause (check the model id / files uri instead). " : "On this connector each result returns an image URL you can open or share directly (see `media[].url`) \u2014 show it to the user rather than assuming they can see the image. There is no filesystem, so no output dir and no `<image>.json` sidecar: a lost response cannot be recovered from disk and a 404 on a chained call cannot be re-anchored automatically. Capture the returned `interaction_id` from every result, and use `async: true` + gemini_get_result for long generations. ";
34509
34524
  server.registerTool(
34510
34525
  "gemini_interact",
34511
34526
  {
@@ -34675,7 +34690,9 @@ function registerVideoTools(server, client2) {
34675
34690
  aspect_ratio: external_exports.enum(VIDEO_ASPECT_RATIOS).optional().describe("Output aspect ratio (omni: 16:9 or 9:16)"),
34676
34691
  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)"),
34677
34692
  images: external_exports.array(external_exports.string().min(1)).optional().describe("Reference image path(s) for image_to_video / reference_to_video"),
34678
- images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs"),
34693
+ images_url: imagesUrlSchema("Reference stills"),
34694
+ images_file_uris: imagesFileUrisSchema("Reference stills"),
34695
+ 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"),
34679
34696
  from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS clipboard as a reference"),
34680
34697
  filename: external_exports.string().optional().describe("Base filename for the output video (extension stripped; default: slugified prompt)"),
34681
34698
  output_dir: external_exports.string().optional().describe("Directory to write the video to (default: $GEMINI_OUTPUT_DIR or cwd)"),
@@ -34710,10 +34727,12 @@ function registerVideoTools(server, client2) {
34710
34727
  images: args.images,
34711
34728
  images_base64: args.images_base64,
34712
34729
  from_clipboard: args.from_clipboard,
34730
+ images_url: args.images_url,
34731
+ images_file_uris: args.images_file_uris,
34713
34732
  previous_interaction_id: previousInteractionId
34714
34733
  });
34715
34734
  return client2.session.jobs.dispatch({ toolName: "gemini_video_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
34716
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
34735
+ const { inputs, report } = await resolveImageInputs(args, client2);
34717
34736
  const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client2.generateVideo({
34718
34737
  input: args.prompt,
34719
34738
  images: inputs.length ? inputs : void 0,
@@ -34727,6 +34746,7 @@ function registerVideoTools(server, client2) {
34727
34746
  const meta3 = { model, interaction_id: r.id };
34728
34747
  if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
34729
34748
  if (r.text) meta3.text = r.text;
34749
+ if (report) meta3.image_inputs = report;
34730
34750
  meta3.hint = `To edit this video, call gemini_video_generate again with task: "edit" and previous_interaction_id: "${r.id}" (or continue_last: true).`;
34731
34751
  const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
34732
34752
  const named = r.videos.map((media, i) => ({
@@ -34759,7 +34779,9 @@ function registerMusicTools(server, client2) {
34759
34779
  model: external_exports.enum(MUSIC_MODELS).optional().describe(`Lyria model (default: ${DEFAULT_MUSIC_MODEL}). Pro is longer-form and supports WAV.`),
34760
34780
  audio_format: external_exports.enum(AUDIO_FORMATS).optional().describe("Output format (default mp3). wav is lyria-3-pro-preview-only."),
34761
34781
  images: external_exports.array(external_exports.string().min(1)).optional().describe("Optional reference image path(s) to condition the music"),
34762
- images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs"),
34782
+ images_url: imagesUrlSchema("Reference images"),
34783
+ images_file_uris: imagesFileUrisSchema("Reference images"),
34784
+ 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"),
34763
34785
  from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS clipboard as a reference"),
34764
34786
  filename: external_exports.string().optional().describe("Base filename for the output audio (extension stripped; default: slugified prompt)"),
34765
34787
  output_dir: external_exports.string().optional().describe("Directory to write audio to (default: $GEMINI_OUTPUT_DIR or cwd)"),
@@ -34798,10 +34820,12 @@ function registerMusicTools(server, client2) {
34798
34820
  images: args.images,
34799
34821
  images_base64: args.images_base64,
34800
34822
  from_clipboard: args.from_clipboard,
34823
+ images_url: args.images_url,
34824
+ images_file_uris: args.images_file_uris,
34801
34825
  previous_interaction_id: previousInteractionId
34802
34826
  });
34803
34827
  return client2.session.jobs.dispatch({ toolName: "gemini_music_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
34804
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
34828
+ const { inputs, report } = await resolveImageInputs(args, client2);
34805
34829
  const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client2.generateMusic({
34806
34830
  input: args.prompt,
34807
34831
  images: inputs.length ? inputs : void 0,
@@ -34814,6 +34838,7 @@ function registerMusicTools(server, client2) {
34814
34838
  const meta3 = { model, interaction_id: r.id };
34815
34839
  if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
34816
34840
  if (r.text) meta3.text = r.text;
34841
+ if (report) meta3.image_inputs = report;
34817
34842
  meta3.hint = `To continue this track, call gemini_music_generate again with previous_interaction_id: "${r.id}" (or continue_last: true).`;
34818
34843
  const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
34819
34844
  const named = r.audios.map((media, i) => ({
@@ -34847,7 +34872,6 @@ init_zod();
34847
34872
  init_dist();
34848
34873
  var UPLOAD_URL_MAX_BYTES_DISK = 100 * 1024 * 1024;
34849
34874
  var UPLOAD_URL_MAX_BYTES_HOSTED = 25 * 1024 * 1024;
34850
- var mb = (bytes) => Math.round(bytes / (1024 * 1024));
34851
34875
  var UPLOAD_ACCEPT = ["image/", "video/", "audio/"];
34852
34876
  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
34877
  function uploadResult(file2) {
@@ -34870,11 +34894,11 @@ function registerFileTools(server, client2) {
34870
34894
  server.registerTool(
34871
34895
  "gemini_upload_file",
34872
34896
  {
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."),
34897
+ description: "Upload a file \u2014 an image, reference photo, picture, screenshot, video or audio clip \u2014 to the Gemini Files API ONCE, and get back a reusable `file_uri` (`files/<id>`) to attach to later image, video or music generations. Keywords: upload, upload file, upload image, upload photo, attach, reference image, reference photo, file_uri, files api, image reference, reuse across calls. Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever enter the conversation, and it can be reused across many generations until it expires (~48h). Provide exactly one of `url` (the server downloads it), `data_base64`" + (onDisk ? ", or `path` (a local file)." : ".") + (onDisk ? "" : " This connector also accepts raw bytes over HTTP: POST them to /upload with the same Authorization header and a Content-Type of image/*, which avoids base64 entirely."),
34874
34898
  annotations: { readOnlyHint: false, openWorldHint: true },
34875
34899
  inputSchema: {
34876
34900
  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.")
34901
+ `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.")
34878
34902
  ),
34879
34903
  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
34904
  path: external_exports.string().min(1).optional().describe(
@@ -34913,19 +34937,21 @@ function registerFileTools(server, client2) {
34913
34937
  return client2.uploadBytes(
34914
34938
  fetched.bytes,
34915
34939
  args.mime_type ?? fetched.mimeType,
34916
- args.display_name ?? nameFromUrl(args.url)
34940
+ // 'upload', matching the data_base64 and path branches below —
34941
+ // this tool takes video and audio too, so 'image' would be a lie.
34942
+ args.display_name ?? fileNameFromUrl(args.url, "upload")
34917
34943
  );
34918
34944
  }
34919
34945
  if (args.data_base64) {
34920
34946
  const { decodeImageInput: decodeImageInput2 } = await Promise.resolve().then(() => (init_images(), images_exports));
34921
34947
  const decoded = decodeImageInput2(args.data_base64);
34922
- const bytes2 = Uint8Array.from(atob(decoded.base64), (c) => c.charCodeAt(0));
34948
+ const bytes2 = base64ToBytes(decoded.base64);
34923
34949
  return client2.uploadBytes(bytes2, args.mime_type ?? decoded.mimeType, args.display_name ?? "upload");
34924
34950
  }
34925
34951
  const { readImageAsInline: readImageAsInline2, resolveImagePath: resolveImagePath2 } = await Promise.resolve().then(() => (init_images(), images_exports));
34926
34952
  const resolved = resolveImagePath2(args.path);
34927
34953
  const inline = await readImageAsInline2(resolved);
34928
- const bytes = Uint8Array.from(atob(inline.base64), (c) => c.charCodeAt(0));
34954
+ const bytes = base64ToBytes(inline.base64);
34929
34955
  return client2.uploadBytes(
34930
34956
  bytes,
34931
34957
  args.mime_type ?? inline.mimeType,
@@ -34938,7 +34964,7 @@ function registerFileTools(server, client2) {
34938
34964
  server.registerTool(
34939
34965
  "gemini_list_files",
34940
34966
  {
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.",
34967
+ description: "List files, images and photos currently uploaded to the Gemini Files API under this API key, with their reusable `file_uri` (`files/<id>`) references, MIME types and expiry times. Retention is ~48h, so an entry that has vanished has expired rather than failed.",
34942
34968
  annotations: { readOnlyHint: true, openWorldHint: true },
34943
34969
  inputSchema: {
34944
34970
  page_size: external_exports.number().int().positive().max(100).optional().describe("Maximum files to return (1-100, default 100)")
@@ -34965,7 +34991,7 @@ function registerFileTools(server, client2) {
34965
34991
  server.registerTool(
34966
34992
  "gemini_delete_file",
34967
34993
  {
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.",
34994
+ description: "Delete an uploaded file, image or photo (by file_uri) from the Gemini Files API before its ~48h expiry. Any tool call still referencing it will then fail with a generic 404, so delete only references you are finished with.",
34969
34995
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
34970
34996
  inputSchema: {
34971
34997
  file_uri: external_exports.string().min(1).describe("The `files/<id>` reference (or full uri) to delete"),
@@ -34980,14 +35006,6 @@ function registerFileTools(server, client2) {
34980
35006
  }
34981
35007
  );
34982
35008
  }
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
35009
 
34992
35010
  // src/index.ts
34993
35011
  await loadStdioDotenv();
package/dist/bytes.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * base64 ↔ bytes, and byte-size formatting.
3
+ *
4
+ * These conversions had grown four near-identical copies — an inline
5
+ * `Uint8Array.from(atob(x), c => c.charCodeAt(0))` in `inputs.ts` and twice in
6
+ * `tools/files.ts`, plus a private `decodeBase64` in `storage/media.ts` — and
7
+ * a fifth (`encodeBase64`) that was subtly different because it is the only one
8
+ * that has to chunk. One place, so the chunking rule can't be forgotten in copy
9
+ * number six.
10
+ *
11
+ * `atob`/`btoa` rather than `Buffer`: this module is on the Worker's import
12
+ * graph, and they are the primitive both runtimes share. Module scope stays
13
+ * side-effect-free for the same reason (see the note in client.ts).
14
+ */
15
+ /** Decode base64 into raw bytes. */
16
+ export function base64ToBytes(base64) {
17
+ const binary = atob(base64);
18
+ const bytes = new Uint8Array(binary.length);
19
+ for (let i = 0; i < binary.length; i++)
20
+ bytes[i] = binary.charCodeAt(i);
21
+ return bytes;
22
+ }
23
+ /**
24
+ * Encode raw bytes as base64.
25
+ *
26
+ * Chunked because `String.fromCharCode(...bytes)` spreads one argument per
27
+ * byte and blows the engine's argument limit somewhere around 100–125k — i.e.
28
+ * it works fine on every small test fixture and throws on the first real
29
+ * photo, which is the worst possible place to learn about it.
30
+ */
31
+ export function bytesToBase64(bytes) {
32
+ const CHUNK = 0x8000;
33
+ let binary = '';
34
+ for (let i = 0; i < bytes.length; i += CHUNK) {
35
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
36
+ }
37
+ return btoa(binary);
38
+ }
39
+ /** Whole megabytes, for limits stated in tool descriptions (`25MB`). */
40
+ export function wholeMb(bytes) {
41
+ return Math.round(bytes / (1024 * 1024));
42
+ }
43
+ /** One-decimal megabytes, for error messages about an actual size (`15.4 MB`). */
44
+ export function formatMb(bytes) {
45
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
46
+ }