@chrischall/gemini-mcp 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.2.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.2.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.2.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
@@ -89,8 +89,9 @@ progress), two guards make re-issuing safe and unnecessary:
89
89
 
90
90
  ## Sending reference images without burning context
91
91
 
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:
92
+ Every tool that takes a reference image — `gemini_image_generate`, `gemini_image_edit`,
93
+ `gemini_image_set`, `gemini_interact`, plus `gemini_video_generate` (reference stills) and
94
+ `gemini_music_generate` — accepts them four ways. Only one of them costs model context:
94
95
 
95
96
  | Parameter | Where the bytes travel | Context cost |
96
97
  |---|---|---|
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.2.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(
@@ -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") {
@@ -34675,7 +34678,9 @@ function registerVideoTools(server, client2) {
34675
34678
  aspect_ratio: external_exports.enum(VIDEO_ASPECT_RATIOS).optional().describe("Output aspect ratio (omni: 16:9 or 9:16)"),
34676
34679
  task: external_exports.enum(VIDEO_TASKS).optional().describe("text_to_video (default), image_to_video / reference_to_video (need image input), or edit (needs previous_interaction_id)"),
34677
34680
  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"),
34681
+ images_url: imagesUrlSchema("Reference stills"),
34682
+ images_file_uris: imagesFileUrisSchema("Reference stills"),
34683
+ images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation"),
34679
34684
  from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS clipboard as a reference"),
34680
34685
  filename: external_exports.string().optional().describe("Base filename for the output video (extension stripped; default: slugified prompt)"),
34681
34686
  output_dir: external_exports.string().optional().describe("Directory to write the video to (default: $GEMINI_OUTPUT_DIR or cwd)"),
@@ -34710,10 +34715,12 @@ function registerVideoTools(server, client2) {
34710
34715
  images: args.images,
34711
34716
  images_base64: args.images_base64,
34712
34717
  from_clipboard: args.from_clipboard,
34718
+ images_url: args.images_url,
34719
+ images_file_uris: args.images_file_uris,
34713
34720
  previous_interaction_id: previousInteractionId
34714
34721
  });
34715
34722
  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 });
34723
+ const { inputs, report } = await resolveImageInputs(args, client2);
34717
34724
  const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client2.generateVideo({
34718
34725
  input: args.prompt,
34719
34726
  images: inputs.length ? inputs : void 0,
@@ -34727,6 +34734,7 @@ function registerVideoTools(server, client2) {
34727
34734
  const meta3 = { model, interaction_id: r.id };
34728
34735
  if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
34729
34736
  if (r.text) meta3.text = r.text;
34737
+ if (report) meta3.image_inputs = report;
34730
34738
  meta3.hint = `To edit this video, call gemini_video_generate again with task: "edit" and previous_interaction_id: "${r.id}" (or continue_last: true).`;
34731
34739
  const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
34732
34740
  const named = r.videos.map((media, i) => ({
@@ -34759,7 +34767,9 @@ function registerMusicTools(server, client2) {
34759
34767
  model: external_exports.enum(MUSIC_MODELS).optional().describe(`Lyria model (default: ${DEFAULT_MUSIC_MODEL}). Pro is longer-form and supports WAV.`),
34760
34768
  audio_format: external_exports.enum(AUDIO_FORMATS).optional().describe("Output format (default mp3). wav is lyria-3-pro-preview-only."),
34761
34769
  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"),
34770
+ images_url: imagesUrlSchema("Reference images"),
34771
+ images_file_uris: imagesFileUrisSchema("Reference images"),
34772
+ images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation"),
34763
34773
  from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS clipboard as a reference"),
34764
34774
  filename: external_exports.string().optional().describe("Base filename for the output audio (extension stripped; default: slugified prompt)"),
34765
34775
  output_dir: external_exports.string().optional().describe("Directory to write audio to (default: $GEMINI_OUTPUT_DIR or cwd)"),
@@ -34798,10 +34808,12 @@ function registerMusicTools(server, client2) {
34798
34808
  images: args.images,
34799
34809
  images_base64: args.images_base64,
34800
34810
  from_clipboard: args.from_clipboard,
34811
+ images_url: args.images_url,
34812
+ images_file_uris: args.images_file_uris,
34801
34813
  previous_interaction_id: previousInteractionId
34802
34814
  });
34803
34815
  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 });
34816
+ const { inputs, report } = await resolveImageInputs(args, client2);
34805
34817
  const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client2.generateMusic({
34806
34818
  input: args.prompt,
34807
34819
  images: inputs.length ? inputs : void 0,
@@ -34814,6 +34826,7 @@ function registerMusicTools(server, client2) {
34814
34826
  const meta3 = { model, interaction_id: r.id };
34815
34827
  if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
34816
34828
  if (r.text) meta3.text = r.text;
34829
+ if (report) meta3.image_inputs = report;
34817
34830
  meta3.hint = `To continue this track, call gemini_music_generate again with previous_interaction_id: "${r.id}" (or continue_last: true).`;
34818
34831
  const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
34819
34832
  const named = r.audios.map((media, i) => ({
@@ -34847,7 +34860,6 @@ init_zod();
34847
34860
  init_dist();
34848
34861
  var UPLOAD_URL_MAX_BYTES_DISK = 100 * 1024 * 1024;
34849
34862
  var UPLOAD_URL_MAX_BYTES_HOSTED = 25 * 1024 * 1024;
34850
- var mb = (bytes) => Math.round(bytes / (1024 * 1024));
34851
34863
  var UPLOAD_ACCEPT = ["image/", "video/", "audio/"];
34852
34864
  var RETENTION_NOTE = "Gemini retains uploaded files for ~48h. Until then this reference can be reused across any number of calls (images_file_uris on gemini_image_generate / _edit / _set / gemini_interact, or video_url) without re-sending the bytes.";
34853
34865
  function uploadResult(file2) {
@@ -34874,7 +34886,7 @@ function registerFileTools(server, client2) {
34874
34886
  annotations: { readOnlyHint: false, openWorldHint: true },
34875
34887
  inputSchema: {
34876
34888
  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.")
34889
+ `Public https URL the SERVER downloads and uploads (image/video/audio, up to ${wholeMb(urlMaxBytes)}MB). No bytes pass through the conversation.` + (onDisk ? "" : " Larger files: POST the raw bytes to /upload, which streams instead of buffering.")
34878
34890
  ),
34879
34891
  data_base64: external_exports.string().min(1).optional().describe("Raw base64 or a data: URI. Last resort \u2014 this is the one form that costs model context (~14k tokens for a modest JPEG)."),
34880
34892
  path: external_exports.string().min(1).optional().describe(
@@ -34913,19 +34925,21 @@ function registerFileTools(server, client2) {
34913
34925
  return client2.uploadBytes(
34914
34926
  fetched.bytes,
34915
34927
  args.mime_type ?? fetched.mimeType,
34916
- args.display_name ?? nameFromUrl(args.url)
34928
+ // 'upload', matching the data_base64 and path branches below —
34929
+ // this tool takes video and audio too, so 'image' would be a lie.
34930
+ args.display_name ?? fileNameFromUrl(args.url, "upload")
34917
34931
  );
34918
34932
  }
34919
34933
  if (args.data_base64) {
34920
34934
  const { decodeImageInput: decodeImageInput2 } = await Promise.resolve().then(() => (init_images(), images_exports));
34921
34935
  const decoded = decodeImageInput2(args.data_base64);
34922
- const bytes2 = Uint8Array.from(atob(decoded.base64), (c) => c.charCodeAt(0));
34936
+ const bytes2 = base64ToBytes(decoded.base64);
34923
34937
  return client2.uploadBytes(bytes2, args.mime_type ?? decoded.mimeType, args.display_name ?? "upload");
34924
34938
  }
34925
34939
  const { readImageAsInline: readImageAsInline2, resolveImagePath: resolveImagePath2 } = await Promise.resolve().then(() => (init_images(), images_exports));
34926
34940
  const resolved = resolveImagePath2(args.path);
34927
34941
  const inline = await readImageAsInline2(resolved);
34928
- const bytes = Uint8Array.from(atob(inline.base64), (c) => c.charCodeAt(0));
34942
+ const bytes = base64ToBytes(inline.base64);
34929
34943
  return client2.uploadBytes(
34930
34944
  bytes,
34931
34945
  args.mime_type ?? inline.mimeType,
@@ -34980,14 +34994,6 @@ function registerFileTools(server, client2) {
34980
34994
  }
34981
34995
  );
34982
34996
  }
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
34997
 
34992
34998
  // src/index.ts
34993
34999
  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
+ }
@@ -1,4 +1,5 @@
1
1
  import { McpToolError } from '@chrischall/mcp-utils';
2
+ import { formatMb } from './bytes.js';
2
3
  /**
3
4
  * Server-side fetching of `images_url` references.
4
5
  *
@@ -207,6 +208,28 @@ function assertPublicHttpsUrl(raw, requested) {
207
208
  }
208
209
  return url;
209
210
  }
211
+ /**
212
+ * Last path segment of a URL, for the Files API display name. Cosmetic — the
213
+ * API keys on the resource name it returns, not on this — so an unparseable
214
+ * URL degrades to `fallback` rather than failing the upload.
215
+ *
216
+ * `fallback` is REQUIRED, deliberately. This function was created by merging
217
+ * two byte-identical copies that differed only in their default (`'image'` in
218
+ * `inputs.ts`, `'upload'` in `tools/files.ts`), and taking one of those
219
+ * defaults silently changed the other caller's behaviour — `gemini_upload_file`
220
+ * started naming a fallback upload "image" even for an mp4, and disagreed with
221
+ * the other two branches of its own handler. With no default, merging callers
222
+ * that disagree stops being something you can do by accident.
223
+ */
224
+ export function fileNameFromUrl(url, fallback) {
225
+ try {
226
+ const name = new URL(url).pathname.split('/').filter(Boolean).pop();
227
+ return name ? decodeURIComponent(name).slice(0, 120) : fallback;
228
+ }
229
+ catch {
230
+ return fallback;
231
+ }
232
+ }
210
233
  /** "<url>" — or "<url> (redirected from <requested>)" when they differ. */
211
234
  function describeUrl(current, requested) {
212
235
  return current === requested ? current : `${current} (redirected from ${requested})`;
@@ -265,8 +288,7 @@ function fetchFailure(err, current, requested, timeoutMs) {
265
288
  return new McpToolError(`Could not fetch ${describeUrl(current, requested)}: ${err instanceof Error ? err.message : String(err)}`, { hint: 'Check the URL is reachable from the server and serves the image without authentication.' });
266
289
  }
267
290
  function tooBig(url, requested, size, max, partial = false) {
268
- const mb = (n) => `${(n / (1024 * 1024)).toFixed(1)} MB`;
269
- return new McpToolError(`Image at ${describeUrl(url, requested)} is ${partial ? 'over' : ''} ${mb(size)}, above the ${mb(max)} limit for images_url.`, { hint: 'Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri.' });
291
+ return new McpToolError(`Image at ${describeUrl(url, requested)} is ${partial ? 'over' : ''} ${formatMb(size)}, above the ${formatMb(max)} limit for images_url.`, { hint: 'Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri.' });
270
292
  }
271
293
  /**
272
294
  * Fetch one image URL server-side, validating protocol, destination, response
package/dist/images.js CHANGED
@@ -215,25 +215,6 @@ export function decodeImageInput(input) {
215
215
  const buf = Buffer.from(trimmed, 'base64');
216
216
  return { base64: trimmed, mimeType: sniffMimeBytes(buf) };
217
217
  }
218
- /** Load images from file paths (via readImageAsInline) and raw base64 strings (via decodeImageInput), paths first. */
219
- export async function loadImageInputs(paths = [], base64s = []) {
220
- const fromPaths = await Promise.all(paths.map((p) => readImageAsInline(p)));
221
- const fromBase64s = base64s.map((b) => decodeImageInput(b));
222
- return [...fromPaths, ...fromBase64s];
223
- }
224
- /**
225
- * Aggregate image inputs from all sources: clipboard (macOS only), file paths, and base64 strings.
226
- * Clipboard image is prepended if `from_clipboard` is true.
227
- */
228
- export async function gatherImageInputs(opts) {
229
- // Only load the clipboard module when actually requested (keeps the child_process
230
- // path off the default code path).
231
- const clipboardImages = opts.from_clipboard
232
- ? [await (await import('./clipboard.js')).readClipboardImage()]
233
- : [];
234
- const fileImages = await loadImageInputs(opts.images, opts.images_base64);
235
- return [...clipboardImages, ...fileImages];
236
- }
237
218
  /** Caller-supplied filename → safe base name (no extension). */
238
219
  export function baseName(name) {
239
220
  // Strip a known media extension (image / video / audio) so writeMedia doesn't
package/dist/inputs.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { McpToolError } from '@chrischall/mcp-utils';
2
- import { IMAGE_INLINE_MAX_BYTES } from './fetch-image.js';
2
+ import { IMAGE_INLINE_MAX_BYTES, fileNameFromUrl } from './fetch-image.js';
3
+ import { base64ToBytes, bytesToBase64 } from './bytes.js';
3
4
  /** True when this call carries at least one image input of any kind. */
4
5
  export function hasImageInput(args) {
5
6
  return Boolean(args.images?.length ||
@@ -117,7 +118,7 @@ async function resolveLocalPath(path, client, onDisk, report) {
117
118
  // Best-effort — an upload failure must not fail a call that inline handles
118
119
  // perfectly well.
119
120
  try {
120
- const bytes = Uint8Array.from(atob(inline.base64), (c) => c.charCodeAt(0));
121
+ const bytes = base64ToBytes(inline.base64);
121
122
  const displayName = resolved.split(/[\\/]/).pop() || 'image';
122
123
  const uploaded = await client.uploadBytes(bytes, inline.mimeType, displayName);
123
124
  client.session.uploadCache.set(key, {
@@ -143,36 +144,14 @@ async function resolveUrl(url, client, report) {
143
144
  };
144
145
  (report.fetched_urls ??= []).push(entry);
145
146
  if (fetched.size <= IMAGE_INLINE_MAX_BYTES) {
146
- return { base64: encodeBase64(fetched.bytes), mimeType: fetched.mimeType };
147
+ return { base64: bytesToBase64(fetched.bytes), mimeType: fetched.mimeType };
147
148
  }
148
- const displayName = fileNameFromUrl(url);
149
+ // 'image' here: this path is `images_url`, which only ever carries images.
150
+ const displayName = fileNameFromUrl(url, 'image');
149
151
  const uploaded = await client.uploadBytes(fetched.bytes, fetched.mimeType, displayName);
150
152
  entry.file_uri = uploaded.name;
151
153
  return { uri: uploaded.uri, mimeType: uploaded.mimeType };
152
154
  }
153
- /** Last path segment of a URL, for the Files API display name. */
154
- function fileNameFromUrl(url) {
155
- try {
156
- const name = new URL(url).pathname.split('/').filter(Boolean).pop();
157
- return name ? decodeURIComponent(name).slice(0, 120) : 'image';
158
- }
159
- catch {
160
- return 'image';
161
- }
162
- }
163
- /**
164
- * bytes → base64 without `Buffer`. `btoa` is the primitive both runtimes share
165
- * (see `storage/media.ts`, which decodes the same way); chunked because
166
- * `String.fromCharCode(...huge)` blows the argument limit.
167
- */
168
- function encodeBase64(bytes) {
169
- let binary = '';
170
- const CHUNK = 0x8000;
171
- for (let i = 0; i < bytes.length; i += CHUNK) {
172
- binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
173
- }
174
- return btoa(binary);
175
- }
176
155
  /**
177
156
  * Guard for tools that REQUIRE at least one reference image
178
157
  * (`gemini_image_edit`). Names every accepted param, in the order a caller
@@ -11,6 +11,7 @@
11
11
  * is a thin wrapper over the pre-existing `resolveOutputDir` + `writeMedia`
12
12
  * behaviour and must stay byte-for-byte equivalent to it.
13
13
  */
14
+ import { base64ToBytes } from '../bytes.js';
14
15
  /**
15
16
  * The stdio sink: `resolveOutputDir(output_dir)` → `writeMedia` per item, in
16
17
  * order (so `uniquePath`'s `name`/`name-2`/`name-3` sequencing is preserved).
@@ -69,7 +70,7 @@ export function createR2Sink(bucket, opts) {
69
70
  const refs = [];
70
71
  for (const it of items) {
71
72
  const key = `${prefix}/${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;
72
- const bytes = decodeBase64(it.base64);
73
+ const bytes = base64ToBytes(it.base64);
73
74
  await bucket.put(key, bytes, { httpMetadata: { contentType: it.mimeType } });
74
75
  refs.push(base ? `${base}/${key}` : `r2://${opts.bucketName ?? 'media'}/${key}`);
75
76
  }
@@ -83,15 +84,3 @@ export function createR2Sink(bucket, opts) {
83
84
  'This hosted connector has no filesystem, so `output_dir` is ignored and no <image>.json sidecar is written — capture interaction_id from this result to chain further turns.',
84
85
  };
85
86
  }
86
- /**
87
- * base64 → bytes without `Buffer`. `Buffer` exists in a Worker under
88
- * `nodejs_compat`, but `atob` is the platform primitive on both runtimes and
89
- * keeps this module free of any Node dependency.
90
- */
91
- function decodeBase64(b64) {
92
- const binary = atob(b64);
93
- const bytes = new Uint8Array(binary.length);
94
- for (let i = 0; i < binary.length; i++)
95
- bytes[i] = binary.charCodeAt(i);
96
- return bytes;
97
- }
@@ -1,6 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { McpToolError, textResult } from '@chrischall/mcp-utils';
3
3
  import { withProgressHeartbeat } from './shared.js';
4
+ import { base64ToBytes, wholeMb } from '../bytes.js';
5
+ import { fileNameFromUrl } from '../fetch-image.js';
4
6
  import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
5
7
  /**
6
8
  * First-class Gemini Files API access.
@@ -38,7 +40,6 @@ import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfir
38
40
  */
39
41
  const UPLOAD_URL_MAX_BYTES_DISK = 100 * 1024 * 1024;
40
42
  const UPLOAD_URL_MAX_BYTES_HOSTED = 25 * 1024 * 1024;
41
- const mb = (bytes) => Math.round(bytes / (1024 * 1024));
42
43
  /** What a `url` upload will accept: the media the Files API is useful for. */
43
44
  const UPLOAD_ACCEPT = ['image/', 'video/', 'audio/'];
44
45
  const RETENTION_NOTE = 'Gemini retains uploaded files for ~48h. Until then this reference can be reused across any number of calls ' +
@@ -77,7 +78,7 @@ export function registerFileTools(server, client) {
77
78
  .string()
78
79
  .url()
79
80
  .optional()
80
- .describe(`Public https URL the SERVER downloads and uploads (image/video/audio, up to ${mb(urlMaxBytes)}MB). ` +
81
+ .describe(`Public https URL the SERVER downloads and uploads (image/video/audio, up to ${wholeMb(urlMaxBytes)}MB). ` +
81
82
  'No bytes pass through the conversation.' +
82
83
  (onDisk ? '' : ' Larger files: POST the raw bytes to /upload, which streams instead of buffering.')),
83
84
  data_base64: z
@@ -122,18 +123,21 @@ export function registerFileTools(server, client) {
122
123
  const uploaded = await withProgressHeartbeat(extra, 'Uploading to the Gemini Files API', async () => {
123
124
  if (args.url) {
124
125
  const fetched = await client.fetchRemoteImage(args.url, { maxBytes: urlMaxBytes, accept: UPLOAD_ACCEPT });
125
- return client.uploadBytes(fetched.bytes, args.mime_type ?? fetched.mimeType, args.display_name ?? nameFromUrl(args.url));
126
+ return client.uploadBytes(fetched.bytes, args.mime_type ?? fetched.mimeType,
127
+ // 'upload', matching the data_base64 and path branches below —
128
+ // this tool takes video and audio too, so 'image' would be a lie.
129
+ args.display_name ?? fileNameFromUrl(args.url, 'upload'));
126
130
  }
127
131
  if (args.data_base64) {
128
132
  const { decodeImageInput } = await import('../images.js');
129
133
  const decoded = decodeImageInput(args.data_base64);
130
- const bytes = Uint8Array.from(atob(decoded.base64), (c) => c.charCodeAt(0));
134
+ const bytes = base64ToBytes(decoded.base64);
131
135
  return client.uploadBytes(bytes, args.mime_type ?? decoded.mimeType, args.display_name ?? 'upload');
132
136
  }
133
137
  const { readImageAsInline, resolveImagePath } = await import('../images.js');
134
138
  const resolved = resolveImagePath(args.path);
135
139
  const inline = await readImageAsInline(resolved);
136
- const bytes = Uint8Array.from(atob(inline.base64), (c) => c.charCodeAt(0));
140
+ const bytes = base64ToBytes(inline.base64);
137
141
  return client.uploadBytes(bytes, args.mime_type ?? inline.mimeType, args.display_name ?? (resolved.split(/[\\/]/).pop() || 'upload'));
138
142
  });
139
143
  return uploadResult(uploaded);
@@ -182,13 +186,3 @@ export function registerFileTools(server, client) {
182
186
  return textResult({ deleted: args.file_uri });
183
187
  });
184
188
  }
185
- /** Last path segment of a URL, for the Files API display name. */
186
- function nameFromUrl(url) {
187
- try {
188
- const name = new URL(url).pathname.split('/').filter(Boolean).pop();
189
- return name ? decodeURIComponent(name).slice(0, 120) : 'upload';
190
- }
191
- catch {
192
- return 'upload';
193
- }
194
- }
@@ -20,7 +20,7 @@ const NEW_REFERENCES_ONLY = 'NEW reference images only (e.g. a style or target p
20
20
  /**
21
21
  * On a chained call, split `images` into genuinely new references vs paths
22
22
  * this server itself generated (which the interaction already contains).
23
- * Unresolvable paths are kept so `gatherImageInputs` surfaces its usual error.
23
+ * Unresolvable paths are kept so `resolveImageInputs` surfaces its usual error.
24
24
  */
25
25
  function splitReattachedOutputs(images, writtenOutputs) {
26
26
  const kept = [];
@@ -1,8 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { McpToolError } from '@chrischall/mcp-utils';
3
- import { slugify, baseName, gatherImageInputs } from '../images.js';
3
+ import { slugify, baseName } from '../images.js';
4
+ import { resolveImageInputs } from '../inputs.js';
4
5
  import { DEFAULT_MUSIC_MODEL } from '../models.js';
5
- import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
6
+ import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
6
7
  import { fingerprintRequest } from '../jobs.js';
7
8
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
8
9
  const MUSIC_MODELS = ['lyria-3-clip-preview', 'lyria-3-pro-preview'];
@@ -26,7 +27,9 @@ export function registerMusicTools(server, client) {
26
27
  model: z.enum(MUSIC_MODELS).optional().describe(`Lyria model (default: ${DEFAULT_MUSIC_MODEL}). Pro is longer-form and supports WAV.`),
27
28
  audio_format: z.enum(AUDIO_FORMATS).optional().describe('Output format (default mp3). wav is lyria-3-pro-preview-only.'),
28
29
  images: z.array(z.string().min(1)).optional().describe('Optional reference image path(s) to condition the music'),
29
- images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs'),
30
+ images_url: imagesUrlSchema('Reference images'),
31
+ images_file_uris: imagesFileUrisSchema('Reference images'),
32
+ images_base64: z.array(z.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'),
30
33
  from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS clipboard as a reference'),
31
34
  filename: z.string().optional().describe('Base filename for the output audio (extension stripped; default: slugified prompt)'),
32
35
  output_dir: z.string().optional().describe('Directory to write audio to (default: $GEMINI_OUTPUT_DIR or cwd)'),
@@ -61,10 +64,11 @@ export function registerMusicTools(server, client) {
61
64
  const fingerprint = fingerprintRequest('gemini_music_generate', {
62
65
  model, prompt: args.prompt, audio_format: args.audio_format,
63
66
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
67
+ images_url: args.images_url, images_file_uris: args.images_file_uris,
64
68
  previous_interaction_id: previousInteractionId,
65
69
  });
66
70
  return client.session.jobs.dispatch({ toolName: 'gemini_music_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
67
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
71
+ const { inputs, report } = await resolveImageInputs(args, client);
68
72
  const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client.generateMusic({
69
73
  input: args.prompt,
70
74
  images: inputs.length ? inputs : undefined,
@@ -79,6 +83,8 @@ export function registerMusicTools(server, client) {
79
83
  meta.previous_interaction_id = previousInteractionId;
80
84
  if (r.text)
81
85
  meta.text = r.text;
86
+ if (report)
87
+ meta.image_inputs = report;
82
88
  meta.hint = `To continue this track, call gemini_music_generate again with previous_interaction_id: "${r.id}" (or continue_last: true).`;
83
89
  const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
84
90
  const named = r.audios.map((media, i) => ({
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { textResult, McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveVideoPath, videoMimeType } from '../images.js';
4
4
  import { IMAGE_URL_MAX_BYTES } from '../fetch-image.js';
5
+ import { wholeMb } from '../bytes.js';
5
6
  import { createDiskSink } from '../storage/media.js';
6
7
  /**
7
8
  * Fallback sink for call sites that pass no explicit one (every pre-existing
@@ -64,7 +65,7 @@ export function imagesUrlSchema(label = 'Reference images') {
64
65
  .optional()
65
66
  .describe(`${label} as public https URLs — the SERVER downloads them, so no image bytes travel through the conversation. ` +
66
67
  'Preferred over images_base64, which costs ~14k tokens per photo and breaks if a file read was truncated. ' +
67
- `Max ${Math.round(IMAGE_URL_MAX_BYTES / (1024 * 1024))}MB each; must be a directly-linked image (Content-Type image/*).`);
68
+ `Max ${wholeMb(IMAGE_URL_MAX_BYTES)}MB each; must be a directly-linked image (Content-Type image/*).`);
68
69
  }
69
70
  /**
70
71
  * Reference images already in the Gemini Files API. The cheapest form there is:
@@ -1,8 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { McpToolError } from '@chrischall/mcp-utils';
3
- import { slugify, baseName, gatherImageInputs } from '../images.js';
3
+ import { slugify, baseName } from '../images.js';
4
+ import { resolveImageInputs } from '../inputs.js';
4
5
  import { DEFAULT_VIDEO_MODEL } from '../models.js';
5
- import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
6
+ import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
6
7
  import { fingerprintRequest } from '../jobs.js';
7
8
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
8
9
  /** omni's own aspect-ratio enum — NOT the image tools' ASPECT_RATIOS. */
@@ -27,7 +28,9 @@ export function registerVideoTools(server, client) {
27
28
  aspect_ratio: z.enum(VIDEO_ASPECT_RATIOS).optional().describe('Output aspect ratio (omni: 16:9 or 9:16)'),
28
29
  task: z.enum(VIDEO_TASKS).optional().describe('text_to_video (default), image_to_video / reference_to_video (need image input), or edit (needs previous_interaction_id)'),
29
30
  images: z.array(z.string().min(1)).optional().describe('Reference image path(s) for image_to_video / reference_to_video'),
30
- images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs'),
31
+ images_url: imagesUrlSchema('Reference stills'),
32
+ images_file_uris: imagesFileUrisSchema('Reference stills'),
33
+ images_base64: z.array(z.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'),
31
34
  from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS clipboard as a reference'),
32
35
  filename: z.string().optional().describe('Base filename for the output video (extension stripped; default: slugified prompt)'),
33
36
  output_dir: z.string().optional().describe('Directory to write the video to (default: $GEMINI_OUTPUT_DIR or cwd)'),
@@ -63,10 +66,11 @@ export function registerVideoTools(server, client) {
63
66
  const fingerprint = fingerprintRequest('gemini_video_generate', {
64
67
  model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, task: args.task,
65
68
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
69
+ images_url: args.images_url, images_file_uris: args.images_file_uris,
66
70
  previous_interaction_id: previousInteractionId,
67
71
  });
68
72
  return client.session.jobs.dispatch({ toolName: 'gemini_video_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
69
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
73
+ const { inputs, report } = await resolveImageInputs(args, client);
70
74
  const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client.generateVideo({
71
75
  input: args.prompt,
72
76
  images: inputs.length ? inputs : undefined,
@@ -82,6 +86,8 @@ export function registerVideoTools(server, client) {
82
86
  meta.previous_interaction_id = previousInteractionId;
83
87
  if (r.text)
84
88
  meta.text = r.text;
89
+ if (report)
90
+ meta.image_inputs = report;
85
91
  meta.hint = `To edit this video, call gemini_video_generate again with task: "edit" and previous_interaction_id: "${r.id}" (or continue_last: true).`;
86
92
  const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
87
93
  const named = r.videos.map((media, i) => ({
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Single source of the server version. release-please bumps the literal below. */
2
- export const VERSION = '1.1.0'; // x-release-please-version
2
+ export const VERSION = '1.2.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/gemini-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "mcpName": "io.github.chrischall/gemini-mcp",
5
5
  "description": "Gemini image-generation MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/gemini-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.1.0",
9
+ "version": "1.2.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "1.1.0",
14
+ "version": "1.2.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -97,6 +97,9 @@ Note: Image generation requires a billing-enabled Google Cloud project.
97
97
 
98
98
  ### Reference images — four ways in, one that costs context
99
99
 
100
+ These apply to **every** tool that takes a reference image: the four image tools, plus
101
+ `gemini_video_generate` (reference stills) and `gemini_music_generate`.
102
+
100
103
  | Parameter | Where the bytes travel | Context cost |
101
104
  |---|---|---|
102
105
  | `images_url` (`master_images_url`) | the **server** downloads the https URL | none |
@@ -142,8 +145,8 @@ curl -X POST https://<connector>/upload \
142
145
  ### Video & Music (preview — funded account)
143
146
  | Tool | Description |
144
147
  |------|-------------|
145
- | `gemini_video_generate(prompt, aspect_ratio?, task?, images?, images_base64?, from_clipboard?, previous_interaction_id?, continue_last?, model?, filename?, output_dir?, timeout_ms?, idempotency_key?, async?)` | Generate a short video via the Gemini omni model: `text_to_video` (default), `image_to_video` / `reference_to_video` (supply reference image[s]), or `edit` (with `previous_interaction_id` / `continue_last`). `aspect_ratio` is `16:9` or `9:16`. Written to disk as **MP4**. Runs long — use `async: true` + `gemini_get_result`, or raise `timeout_ms`. |
146
- | `gemini_music_generate(prompt, model?, audio_format?, images?, images_base64?, from_clipboard?, previous_interaction_id?, continue_last?, filename?, output_dir?, inline?, timeout_ms?, idempotency_key?, async?)` | Generate music from a prompt (mood/genre/instruments/structure/lyrics) via a Lyria model: `lyria-3-clip-preview` (~30s, default) or `lyria-3-pro-preview` (longer, WAV-capable). `audio_format` `mp3` (default) or `wav` (**Pro-only**). Written to disk as MP3/WAV, or returned inline. |
148
+ | `gemini_video_generate(prompt, aspect_ratio?, task?, images_url?, images_file_uris?, images?, images_base64?, from_clipboard?, previous_interaction_id?, continue_last?, model?, filename?, output_dir?, timeout_ms?, idempotency_key?, async?)` | Generate a short video via the Gemini omni model: `text_to_video` (default), `image_to_video` / `reference_to_video` (supply reference image[s]), or `edit` (with `previous_interaction_id` / `continue_last`). `aspect_ratio` is `16:9` or `9:16`. Written to disk as **MP4**. Runs long — use `async: true` + `gemini_get_result`, or raise `timeout_ms`. |
149
+ | `gemini_music_generate(prompt, model?, audio_format?, images_url?, images_file_uris?, images?, images_base64?, from_clipboard?, previous_interaction_id?, continue_last?, filename?, output_dir?, inline?, timeout_ms?, idempotency_key?, async?)` | Generate music from a prompt (mood/genre/instruments/structure/lyrics) via a Lyria model: `lyria-3-clip-preview` (~30s, default) or `lyria-3-pro-preview` (longer, WAV-capable). `audio_format` `mp3` (default) or `wav` (**Pro-only**). Written to disk as MP3/WAV, or returned inline. |
147
150
 
148
151
  ### Async / idempotency (any generation tool)
149
152
  | Tool | Description |