@chrischall/gemini-mcp 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bundle.js CHANGED
@@ -27186,6 +27186,7 @@ var require_fast_uri = __commonJS({
27186
27186
  return uriTokens.join("");
27187
27187
  }
27188
27188
  var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
27189
+ var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/;
27189
27190
  function getParseError(parsed, matches) {
27190
27191
  if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
27191
27192
  return 'URI path must start with "/" when authority is present.';
@@ -27215,6 +27216,11 @@ var require_fast_uri = __commonJS({
27215
27216
  uri = "//" + uri;
27216
27217
  }
27217
27218
  }
27219
+ const authorityMatch = uri.match(AUTHORITY_PREFIX);
27220
+ if (authorityMatch !== null && authorityMatch[1].indexOf("\\") !== -1) {
27221
+ parsed.error = "URI authority must not contain a literal backslash.";
27222
+ malformedAuthorityOrPort = true;
27223
+ }
27218
27224
  const matches = uri.match(URI_PARSE);
27219
27225
  if (matches) {
27220
27226
  parsed.scheme = matches[1];
@@ -27258,7 +27264,7 @@ var require_fast_uri = __commonJS({
27258
27264
  if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport)) {
27259
27265
  if (parsed.host && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP === false && nonSimpleDomain(parsed.host)) {
27260
27266
  try {
27261
- parsed.host = URL.domainToASCII(parsed.host.toLowerCase());
27267
+ parsed.host = new URL("http://" + parsed.host).hostname;
27262
27268
  } catch (e) {
27263
27269
  parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e;
27264
27270
  }
@@ -32602,19 +32608,227 @@ var init_clipboard = __esm({
32602
32608
  }
32603
32609
  });
32604
32610
 
32611
+ // src/images.ts
32612
+ var images_exports = {};
32613
+ __export(images_exports, {
32614
+ baseName: () => baseName,
32615
+ decodeImageInput: () => decodeImageInput,
32616
+ gatherImageInputs: () => gatherImageInputs,
32617
+ loadImageInputs: () => loadImageInputs,
32618
+ mediaExt: () => mediaExt,
32619
+ previewImageInput: () => previewImageInput,
32620
+ previewVideoInput: () => previewVideoInput,
32621
+ readImageAsInline: () => readImageAsInline,
32622
+ resolveImagePath: () => resolveImagePath,
32623
+ resolveOutputDir: () => resolveOutputDir,
32624
+ resolveVideoPath: () => resolveVideoPath,
32625
+ slugify: () => slugify2,
32626
+ uniquePath: () => uniquePath,
32627
+ videoMimeType: () => videoMimeType,
32628
+ writeImage: () => writeImage,
32629
+ writeMedia: () => writeMedia,
32630
+ writeSidecar: () => writeSidecar
32631
+ });
32632
+ import { mkdir, readFile as readFile2, writeFile, access, stat, open } from "node:fs/promises";
32633
+ import { existsSync } from "node:fs";
32634
+ import { join as join3, resolve, isAbsolute } from "node:path";
32635
+ function slugify2(text, max = 40) {
32636
+ const s = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/g, "");
32637
+ return s || "image";
32638
+ }
32639
+ async function exists(p) {
32640
+ try {
32641
+ await access(p);
32642
+ return true;
32643
+ } catch {
32644
+ return false;
32645
+ }
32646
+ }
32647
+ async function uniquePath(dir, base, ext) {
32648
+ let candidate = join3(dir, `${base}.${ext}`);
32649
+ let n = 2;
32650
+ while (await exists(candidate)) {
32651
+ candidate = join3(dir, `${base}-${n}.${ext}`);
32652
+ n++;
32653
+ }
32654
+ return candidate;
32655
+ }
32656
+ function sniffMimeBytes(buf) {
32657
+ if (buf.length >= 4 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) {
32658
+ return "image/png";
32659
+ }
32660
+ if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) {
32661
+ return "image/jpeg";
32662
+ }
32663
+ if (buf.length >= 12 && buf[0] === 82 && buf[1] === 73 && buf[2] === 70 && buf[3] === 70 && buf[8] === 87 && buf[9] === 69 && buf[10] === 66 && buf[11] === 80) {
32664
+ return "image/webp";
32665
+ }
32666
+ return "image/png";
32667
+ }
32668
+ function mediaExt(mimeType) {
32669
+ const m = mimeType.toLowerCase();
32670
+ if (m.includes("jpeg")) return "jpg";
32671
+ if (m.includes("webp")) return "webp";
32672
+ if (m.includes("png")) return "png";
32673
+ if (m.includes("mp4")) return "mp4";
32674
+ if (m.includes("quicktime")) return "mov";
32675
+ if (m.includes("webm")) return "webm";
32676
+ if (m.includes("wav")) return "wav";
32677
+ if (m.includes("mpeg") || m.includes("mp3")) return "mp3";
32678
+ if (m.includes("ogg")) return "ogg";
32679
+ return m.split("/")[1]?.split(";")[0]?.replace(/^x-/, "") || "bin";
32680
+ }
32681
+ async function writeMedia(dir, base, base643, mimeType) {
32682
+ await mkdir(dir, { recursive: true });
32683
+ const path = await uniquePath(dir, base, mediaExt(mimeType));
32684
+ await writeFile(path, Buffer.from(base643, "base64"));
32685
+ return resolve(path);
32686
+ }
32687
+ async function writeImage(dir, base, base643, mimeType) {
32688
+ return writeMedia(dir, base, base643, mimeType);
32689
+ }
32690
+ async function writeSidecar(imagePath, data) {
32691
+ await writeFile(`${imagePath}.json`, JSON.stringify(data, null, 2));
32692
+ }
32693
+ function resolveInputPath(p, kind) {
32694
+ if (isAbsolute(p) && existsSync(p)) return p;
32695
+ const inputDir = readEnvVar("GEMINI_INPUT_DIR");
32696
+ if (inputDir) {
32697
+ const candidate = join3(inputDir, p);
32698
+ if (existsSync(candidate)) return resolve(candidate);
32699
+ }
32700
+ const cwd = resolve(p);
32701
+ if (existsSync(cwd)) return cwd;
32702
+ const hint = `Set GEMINI_INPUT_DIR to a directory containing your ${kind.toLowerCase()}s, or pass an absolute path.`;
32703
+ throw new McpToolError(
32704
+ `${kind} not found: ${p}` + (inputDir ? ` (also searched GEMINI_INPUT_DIR=${inputDir})` : ""),
32705
+ { hint }
32706
+ );
32707
+ }
32708
+ function resolveImagePath(p) {
32709
+ return resolveInputPath(p, "Image");
32710
+ }
32711
+ function resolveVideoPath(p) {
32712
+ return resolveInputPath(p, "Video");
32713
+ }
32714
+ function videoMimeType(p) {
32715
+ const ext = p.includes(".") ? p.slice(p.lastIndexOf(".") + 1).toLowerCase() : "";
32716
+ const mime = VIDEO_MIME_BY_EXT[ext];
32717
+ if (!mime) {
32718
+ throw new McpToolError(`Unsupported video format: ${ext ? `.${ext}` : p}`, {
32719
+ hint: `Supported extensions: ${Object.keys(VIDEO_MIME_BY_EXT).map((e) => `.${e}`).join(", ")}`
32720
+ });
32721
+ }
32722
+ return mime;
32723
+ }
32724
+ async function previewImageInput(path) {
32725
+ const resolved = resolveImagePath(path);
32726
+ const { size } = await stat(resolved);
32727
+ const fh = await open(resolved, "r");
32728
+ try {
32729
+ const head = Buffer.alloc(16);
32730
+ const { bytesRead } = await fh.read(head, 0, 16, 0);
32731
+ return { path: resolved, mimeType: sniffMimeBytes(head.subarray(0, bytesRead)), size };
32732
+ } finally {
32733
+ await fh.close();
32734
+ }
32735
+ }
32736
+ async function previewVideoInput(path) {
32737
+ const resolved = resolveVideoPath(path);
32738
+ const { size } = await stat(resolved);
32739
+ return { path: resolved, mimeType: videoMimeType(resolved), size };
32740
+ }
32741
+ async function readImageAsInline(path) {
32742
+ const resolved = resolveImagePath(path);
32743
+ const buf = await readFile2(resolved);
32744
+ const mimeType = sniffMimeBytes(buf);
32745
+ return { base64: buf.toString("base64"), mimeType };
32746
+ }
32747
+ function decodeImageInput(input) {
32748
+ const trimmed = input.trim();
32749
+ if (trimmed.startsWith("data:")) {
32750
+ const marker = trimmed.indexOf(";base64,");
32751
+ if (marker === -1) {
32752
+ throw new Error(`Unsupported data URI (expected ;base64,<data>): ${trimmed.slice(0, 48)}\u2026`);
32753
+ }
32754
+ const mimeType = trimmed.slice(5, trimmed.indexOf(";", 5)) || "image/png";
32755
+ return { mimeType, base64: trimmed.slice(marker + ";base64,".length) };
32756
+ }
32757
+ const buf = Buffer.from(trimmed, "base64");
32758
+ return { base64: trimmed, mimeType: sniffMimeBytes(buf) };
32759
+ }
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
+ function baseName(name) {
32771
+ const stripped = name.replace(/\.(png|jpe?g|webp|mp4|mov|webm|mp3|wav|ogg)$/i, "");
32772
+ const safe = stripped.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
32773
+ return safe || "image";
32774
+ }
32775
+ function resolveOutputDir(perCall) {
32776
+ return perCall?.trim() || readEnvVar("GEMINI_OUTPUT_DIR") || process.cwd();
32777
+ }
32778
+ var VIDEO_MIME_BY_EXT;
32779
+ var init_images = __esm({
32780
+ "src/images.ts"() {
32781
+ "use strict";
32782
+ init_dist();
32783
+ VIDEO_MIME_BY_EXT = {
32784
+ mp4: "video/mp4",
32785
+ mpeg: "video/mpeg",
32786
+ mpg: "video/mpg",
32787
+ mov: "video/mov",
32788
+ avi: "video/avi",
32789
+ flv: "video/x-flv",
32790
+ webm: "video/webm",
32791
+ wmv: "video/wmv",
32792
+ "3gp": "video/3gpp",
32793
+ "3gpp": "video/3gpp"
32794
+ };
32795
+ }
32796
+ });
32797
+
32605
32798
  // src/index.ts
32606
32799
  init_dist();
32607
32800
 
32801
+ // src/dotenv.ts
32802
+ init_dist();
32803
+ import { dirname, join } from "node:path";
32804
+ import { fileURLToPath } from "node:url";
32805
+ async function loadStdioDotenv() {
32806
+ const here = dirname(fileURLToPath(import.meta.url));
32807
+ await loadDotenvSafely({ path: join(here, "..", ".env"), override: false });
32808
+ }
32809
+
32608
32810
  // src/version.ts
32609
- var VERSION = "1.0.2";
32811
+ var VERSION = "1.0.4";
32610
32812
 
32611
32813
  // src/tools/models.ts
32612
32814
  init_dist();
32815
+ function registerModelTools(server, client2) {
32816
+ server.registerTool(
32817
+ "gemini_list_models",
32818
+ {
32819
+ description: "List the Gemini image-generation models available to your API key (Nano Banana / Nano Banana Pro family), and the current default model.",
32820
+ annotations: { readOnlyHint: true }
32821
+ },
32822
+ async () => {
32823
+ const models = await client2.listModels();
32824
+ return textResult({ default: client2.defaultModel(), models });
32825
+ }
32826
+ );
32827
+ }
32613
32828
 
32614
- // src/client.ts
32829
+ // src/tools/generate.ts
32830
+ init_zod();
32615
32831
  init_dist();
32616
- import { basename, dirname, join } from "node:path";
32617
- import { fileURLToPath } from "node:url";
32618
32832
 
32619
32833
  // src/models.ts
32620
32834
  var DEFAULT_IMAGE_MODEL = "gemini-3.1-flash-image";
@@ -32631,1052 +32845,1095 @@ function filterImageModels(raw) {
32631
32845
  }));
32632
32846
  }
32633
32847
 
32634
- // src/client.ts
32635
- var __dirname = dirname(fileURLToPath(import.meta.url));
32636
- await loadDotenvSafely({ path: join(__dirname, "..", ".env"), override: false });
32637
- var BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
32638
- var SERVICE = "Gemini";
32639
- var INTERACTIONS_SERVICE = "Gemini Interactions";
32640
- var REQUEST_TIMEOUT_MS = 6e4;
32641
- var REQUEST_TIMEOUT_4K_MS = 12e4;
32642
- function resolveTimeoutMs(perCallMs, imageSize) {
32643
- if (perCallMs !== void 0 && perCallMs > 0) return perCallMs;
32644
- const env = Number(readEnvVar("GEMINI_TIMEOUT_MS"));
32645
- if (Number.isFinite(env) && env > 0) return env;
32646
- return imageSize === "4K" ? REQUEST_TIMEOUT_4K_MS : REQUEST_TIMEOUT_MS;
32848
+ // src/tools/generate.ts
32849
+ init_images();
32850
+
32851
+ // src/tools/shared.ts
32852
+ init_zod();
32853
+ init_dist();
32854
+ init_images();
32855
+
32856
+ // src/storage/media.ts
32857
+ function createDiskSink() {
32858
+ return {
32859
+ kind: "disk",
32860
+ persistsFiles: true,
32861
+ async persist(items, opts) {
32862
+ const { writeMedia: writeMedia2, resolveOutputDir: resolveOutputDir2 } = await Promise.resolve().then(() => (init_images(), images_exports));
32863
+ const dir = resolveOutputDir2(opts.output_dir);
32864
+ const refs = [];
32865
+ for (const it of items) refs.push(await writeMedia2(dir, it.base, it.base64, it.mimeType));
32866
+ return refs;
32867
+ },
32868
+ note: () => void 0
32869
+ };
32647
32870
  }
32648
- var UPLOAD_BASE_URL = "https://generativelanguage.googleapis.com/upload/v1beta";
32649
- var FILE_MAX_BYTES = 2 * 1024 ** 3;
32650
- var FILE_POLL_INTERVAL_MS = 2e3;
32651
- var FILE_POLL_MAX_ATTEMPTS = 150;
32652
- var CHAIN_404_RETRIES = 2;
32653
- var CHAIN_404_RETRY_MS = 2e3;
32654
- var GeminiClient = class {
32655
- apiKey;
32656
- configError;
32657
- apis = /* @__PURE__ */ new Map();
32658
- fetchImpl;
32659
- sleep;
32660
- constructor(opts = {}) {
32661
- const key = readEnvVar("GEMINI_API_KEY");
32662
- if (!key) {
32663
- this.apiKey = null;
32664
- this.configError = new McpToolError("GEMINI_API_KEY environment variable is required", {
32665
- hint: "Create a key at https://aistudio.google.com/apikey and set GEMINI_API_KEY in your MCP host env or .env"
32666
- });
32667
- } else {
32668
- this.apiKey = key;
32669
- this.configError = null;
32670
- }
32671
- this.fetchImpl = opts.fetchImpl ?? fetch;
32672
- this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
32871
+
32872
+ // src/tools/shared.ts
32873
+ var DEFAULT_SINK = createDiskSink();
32874
+ var ASPECT_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9", "1:4", "4:1", "1:8", "8:1"];
32875
+ var IMAGE_SIZES = ["512", "1K", "2K", "4K"];
32876
+ var MODEL_CHOICE_GUIDE = "gemini-3.1-flash-image (Nano Banana 2) is the versatile generalist workhorse \u2014 balances speed with state-of-the-art 4K generation, world knowledge, and reliable text rendering; excels at multi-reference-image processing and consistency. gemini-3-pro-image (Nano Banana Pro) is the premium choice for the most complex visual tasks \u2014 highest world knowledge, advanced localization, accurate brand consistency, precision creative control. gemini-3.1-flash-lite-image (Nano Banana 2 Lite) is the fastest/cheapest for simple tasks (1K only, no search grounding).";
32877
+ var timeoutMsSchema = external_exports.number().int().positive().optional().describe(
32878
+ "Upstream request timeout in ms for this call (default: $GEMINI_TIMEOUT_MS, else 60000 \u2014 or 120000 when image_size is 4K, which routinely runs past 60s)"
32879
+ );
32880
+ var idempotencyKeySchema = external_exports.string().min(1).optional().describe(
32881
+ "Opaque idempotency key: a repeat call with the same key returns the recorded result (reused: true) instead of billing a new generation. Set it when retrying after a host timeout (-32001) to avoid a duplicate charge."
32882
+ );
32883
+ var asyncSchema = external_exports.boolean().optional().describe(
32884
+ "Run in the background and return a job_id immediately instead of the image, so a long (Pro/4K) generation cannot hit the host tools/call timeout (-32001). Poll gemini_get_result with the job_id to fetch the result (jobs are per-process and expire ~10 min after completion)."
32885
+ );
32886
+ var sharedImageSchema = {
32887
+ // Bare id only: the model is interpolated into the URL path
32888
+ // (`/models/${model}:generateContent`), so slashes/colons/queries must be
32889
+ // rejected to keep a crafted value from escaping the path segment.
32890
+ model: external_exports.string().regex(/^[\w.-]+$/, 'must be a bare model id (letters, digits, ".", "_", "-")').optional().describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
32891
+ aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Output aspect ratio"),
32892
+ image_size: external_exports.enum(IMAGE_SIZES).optional().describe("Output resolution (512 = 0.5K, Flash-only)"),
32893
+ output_dir: external_exports.string().optional().describe("Directory to write images to (default: $GEMINI_OUTPUT_DIR or cwd)"),
32894
+ inline: external_exports.boolean().optional().describe("Return base64 images inline instead of writing to disk"),
32895
+ seed: external_exports.number().int().optional().describe("Seed for reproducible generation; random if omitted"),
32896
+ thinking_level: external_exports.enum(["minimal", "high"]).optional().describe("Reasoning depth (Gemini 3 models); higher can help complex/structural edits"),
32897
+ google_search: external_exports.boolean().optional().describe("Ground the image in live Google Search results (current events, weather, data)"),
32898
+ from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)"),
32899
+ timeout_ms: timeoutMsSchema,
32900
+ idempotency_key: idempotencyKeySchema,
32901
+ async: asyncSchema
32902
+ };
32903
+ async function resolveVideoInput(args, client2) {
32904
+ if (args.video_url && args.video_path) {
32905
+ throw new McpToolError("Provide `video_url` OR `video_path`, not both.");
32673
32906
  }
32674
- /**
32675
- * The `createApiClient` pair for one timeout budget, built lazily and
32676
- * memoized per distinct timeout (a handful of values in practice). Both
32677
- * share: the key in the `x-goog-api-key` header (Gemini does not use
32678
- * `Authorization: Bearer`) and the default 429 retry (once after 2s — the
32679
- * free tier rate-limits aggressively). `getToken` calls requireKey() so key
32680
- * resolution stays at REQUEST time: the server boots without a key and the
32681
- * actionable config error (with its aistudio hint) surfaces on the first
32682
- * tool call, not at startup.
32683
- */
32684
- apisFor(timeoutMs) {
32685
- let entry = this.apis.get(timeoutMs);
32686
- if (!entry) {
32687
- const shared = {
32688
- tokenHeader: "x-goog-api-key",
32689
- getToken: () => this.requireKey(),
32690
- timeout: timeoutMs,
32691
- fetchImpl: this.fetchImpl
32692
- };
32693
- entry = {
32694
- api: createApiClient({ baseUrl: BASE_URL, serviceName: SERVICE, ...shared }),
32695
- // Separate client for the Interactions API so errors name the right API.
32696
- // The beta-era Api-Revision header is gone: the API went GA and requests
32697
- // succeed without it (verified live 2026-07-06; see docs/GEMINI-API.md).
32698
- // Don't re-add a pinned revision — it would freeze us on old semantics.
32699
- interactionsApi: createApiClient({ baseUrl: BASE_URL, serviceName: INTERACTIONS_SERVICE, ...shared })
32700
- };
32701
- this.apis.set(timeoutMs, entry);
32907
+ if (!args.video_path) return { videoUrl: args.video_url };
32908
+ const path = resolveVideoPath(args.video_path);
32909
+ const uploaded = await client2.uploadVideo(path, videoMimeType(args.video_path));
32910
+ const videoFileMeta = { uri: uploaded.uri, name: uploaded.name };
32911
+ if (uploaded.expirationTime) videoFileMeta.expires = uploaded.expirationTime;
32912
+ return { videoUrl: uploaded.uri, videoMimeType: uploaded.mimeType, videoFileMeta };
32913
+ }
32914
+ var videoPathSchema = external_exports.string().optional().describe(
32915
+ "Path to a local video file \u2014 uploaded to the Gemini Files API (~48h retention, 2 GB max) and used as the video reference. Alternative to video_url."
32916
+ );
32917
+ function pickSeed(seed) {
32918
+ return seed ?? Math.floor(Math.random() * 2147483e3);
32919
+ }
32920
+ var HEARTBEAT_DEFAULT_MS = 1e4;
32921
+ async function withProgressHeartbeat(extra, message, fn) {
32922
+ const progressToken = extra?._meta?.progressToken;
32923
+ const send = extra?.sendNotification;
32924
+ const env = Number(readEnvVar("GEMINI_HEARTBEAT_MS"));
32925
+ const intervalMs = Number.isFinite(env) ? env : HEARTBEAT_DEFAULT_MS;
32926
+ const debug = !!readEnvVar("GEMINI_DEBUG");
32927
+ const active = progressToken !== void 0 && !!send && intervalMs > 0;
32928
+ if (debug) {
32929
+ if (active) {
32930
+ console.error(`[gemini-mcp] heartbeat active: progressToken present, interval=${intervalMs}ms \u2014 ${message}`);
32931
+ } else {
32932
+ const reason = progressToken === void 0 ? "no progressToken from host (host cannot extend its tools/call timeout; long calls will hit it)" : !send ? "host provided no sendNotification channel" : "heartbeat disabled (GEMINI_HEARTBEAT_MS=0)";
32933
+ console.error(`[gemini-mcp] heartbeat inactive: ${reason} \u2014 ${message}`);
32702
32934
  }
32703
- return entry;
32704
32935
  }
32705
- requireKey() {
32706
- if (this.configError) throw this.configError;
32707
- return this.apiKey;
32936
+ if (progressToken === void 0 || !send || intervalMs <= 0) return fn();
32937
+ let ticks = 0;
32938
+ const timer = setInterval(() => {
32939
+ ticks++;
32940
+ const elapsed = Math.round(ticks * intervalMs / 1e3);
32941
+ if (debug) console.error(`[gemini-mcp] heartbeat: ${message} \u2014 still working (${elapsed}s)`);
32942
+ void send({
32943
+ method: "notifications/progress",
32944
+ params: {
32945
+ progressToken,
32946
+ progress: ticks,
32947
+ message: `${message} \u2014 still working (${elapsed}s)`
32948
+ }
32949
+ }).catch(() => {
32950
+ });
32951
+ }, intervalMs);
32952
+ timer.unref?.();
32953
+ try {
32954
+ return await fn();
32955
+ } finally {
32956
+ clearInterval(timer);
32708
32957
  }
32709
- /** The default model after env override (no per-call arg). */
32710
- defaultModel() {
32711
- return resolveModel(void 0, readEnvVar("GEMINI_IMAGE_MODEL"));
32958
+ }
32959
+ function timeoutRiskHint(opts) {
32960
+ const proModel = /(?:^|[-_./])pro(?:[-_./]|$)/i.test(opts.model);
32961
+ const fourK = opts.imageSize === "4K";
32962
+ const multi = (opts.count ?? 1) > 1;
32963
+ if (!proModel && !fourK && !multi) return void 0;
32964
+ const why = [proModel && "Pro model", fourK && "4K output", multi && `count=${opts.count}`].filter(Boolean).join(" + ");
32965
+ const lead = `Slow config (${why}) can exceed a host's tools/call timeout (e.g. Claude Desktop ~30s, error -32001). `;
32966
+ if (opts.persistsFiles === false) {
32967
+ return lead + `This connector has no filesystem, so a lost response cannot be recovered from disk \u2014 there is no output dir and no <image>.json sidecar. Use \`async: true\` to get a job_id immediately and poll gemini_get_result, or use a Flash model / drop image_size to 1K/2K for a faster in-band result.`;
32712
32968
  }
32713
- async call(method, path, body, timeoutMs) {
32714
- const { api } = this.apisFor(timeoutMs ?? resolveTimeoutMs());
32715
- return api.fetchJson(method, path, body !== void 0 ? { body } : {});
32969
+ return lead + `If the host gives up mid-generation the image is still written to disk (and gemini_interact leaves an <image>.json sidecar with the interaction id) \u2014 no rerun needed, just look in the output dir. For a faster in-band result use a Flash model, drop image_size to 1K/2K, or \u2014 in Claude Code \u2014 set MCP_TOOL_TIMEOUT.`;
32970
+ }
32971
+ function buildMeta(model, seed, opts) {
32972
+ const meta3 = { model, seed };
32973
+ if (opts.aspect_ratio) meta3.aspect_ratio = opts.aspect_ratio;
32974
+ if (opts.image_size) meta3.image_size = opts.image_size;
32975
+ return meta3;
32976
+ }
32977
+ async function emitMedia(named, kind, opts, meta3, onWritten) {
32978
+ const inline = opts.inline && kind !== "video";
32979
+ if (inline) {
32980
+ const blocks = named.map((n) => ({ type: kind, data: n.media.base64, mimeType: n.media.mimeType }));
32981
+ if (meta3 && Object.keys(meta3).length > 0) {
32982
+ return { content: [{ type: "text", text: JSON.stringify(meta3, null, 2) }, ...blocks] };
32983
+ }
32984
+ return { content: blocks };
32985
+ }
32986
+ const sink = opts.sink ?? DEFAULT_SINK;
32987
+ const refs = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
32988
+ output_dir: opts.output_dir
32989
+ });
32990
+ if (sink.persistsFiles) await onWritten?.(refs);
32991
+ const note = opts.inline && kind === "video" ? { inline_unsupported: "video has no MCP inline content type \u2014 written to disk instead" } : {};
32992
+ const storageNote = sink.note();
32993
+ const storage = sink.persistsFiles ? {} : { storage: sink.kind, storage_note: storageNote };
32994
+ return textResult({ [`${kind}s`]: refs, ...note, ...storage, ...meta3 });
32995
+ }
32996
+ async function emit(named, opts, meta3, onWritten) {
32997
+ return emitMedia(named.map((n) => ({ media: n.image, base: n.base })), "image", opts, meta3, onWritten);
32998
+ }
32999
+ function assertLocalInputsAvailable(sink, args) {
33000
+ if ((sink ?? DEFAULT_SINK).persistsFiles) return;
33001
+ const unavailable = [];
33002
+ if (args.images?.length) unavailable.push("`images` (local file paths)");
33003
+ if (args.master_images?.length) unavailable.push("`master_images` (local file paths)");
33004
+ if (args.from_clipboard) unavailable.push("`from_clipboard` (reads the macOS clipboard via child_process)");
33005
+ if (args.video_path) unavailable.push("`video_path` (uploads a file streamed from disk)");
33006
+ if (unavailable.length === 0) return;
33007
+ const fix = "Send image bytes as `images_base64` (raw base64 or a data: URI) instead of `images`/`master_images`/`from_clipboard`, and reference video by `video_url` (a public YouTube URL, or a files/\u2026 uri uploaded elsewhere) instead of `video_path`.";
33008
+ throw new McpToolError(
33009
+ `${unavailable.join(", ")} ${unavailable.length > 1 ? "are" : "is"} unavailable on the hosted connector: it runs on a Cloudflare Worker, which has no filesystem and no local clipboard. ${fix}`,
33010
+ { hint: fix }
33011
+ );
33012
+ }
33013
+
33014
+ // src/jobs.ts
33015
+ init_dist();
33016
+ import { randomUUID, createHash } from "node:crypto";
33017
+ var JOB_TTL_MS = 6e5;
33018
+ var JOB_MAX = 64;
33019
+ function isExpired(entry, now) {
33020
+ return entry.settledAt !== void 0 && now - entry.settledAt > JOB_TTL_MS;
33021
+ }
33022
+ function fingerprintRequest(toolName, parts) {
33023
+ return createHash("sha256").update(`${toolName}\0${JSON.stringify(parts)}`).digest("hex");
33024
+ }
33025
+ function annotateReused(result, jobId) {
33026
+ const content = result.content ?? [];
33027
+ const first = content[0];
33028
+ if (first && first.type === "text") {
33029
+ let obj;
33030
+ try {
33031
+ obj = JSON.parse(first.text);
33032
+ } catch {
33033
+ obj = {};
33034
+ }
33035
+ obj.reused = true;
33036
+ obj.reused_job_id = jobId;
33037
+ return { ...result, content: [{ ...first, text: JSON.stringify(obj, null, 2) }, ...content.slice(1)] };
33038
+ }
33039
+ return {
33040
+ ...result,
33041
+ content: [{ type: "text", text: JSON.stringify({ reused: true, reused_job_id: jobId }, null, 2) }, ...content]
33042
+ };
33043
+ }
33044
+ function jobHandle(jobId, status) {
33045
+ return textResult({
33046
+ job_id: jobId,
33047
+ status,
33048
+ hint: `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`
33049
+ });
33050
+ }
33051
+ var JobRegistry = class {
33052
+ jobs = /* @__PURE__ */ new Map();
33053
+ byKey = /* @__PURE__ */ new Map();
33054
+ // idempotencyKey -> jobId
33055
+ runningByFingerprint = /* @__PURE__ */ new Map();
33056
+ // fingerprint -> jobId (only while running)
33057
+ /** Drop every job. Used by `SessionState.reset()` (tests) — not by handlers. */
33058
+ reset() {
33059
+ this.jobs.clear();
33060
+ this.byKey.clear();
33061
+ this.runningByFingerprint.clear();
33062
+ }
33063
+ /** Drop expired entries, then enforce JOB_MAX by evicting oldest-settled first. */
33064
+ evict(now) {
33065
+ for (const [id, entry] of this.jobs) {
33066
+ if (isExpired(entry, now)) this.remove(id, entry);
33067
+ }
33068
+ if (this.jobs.size < JOB_MAX) return;
33069
+ const settled = [...this.jobs.values()].filter((e) => e.settledAt !== void 0).sort((a, b) => (a.settledAt ?? 0) - (b.settledAt ?? 0));
33070
+ for (const e of settled) {
33071
+ if (this.jobs.size < JOB_MAX) break;
33072
+ this.remove(e.jobId, e);
33073
+ }
33074
+ }
33075
+ remove(id, entry) {
33076
+ this.jobs.delete(id);
33077
+ if (entry.idempotencyKey !== void 0 && this.byKey.get(entry.idempotencyKey) === id) this.byKey.delete(entry.idempotencyKey);
33078
+ if (this.runningByFingerprint.get(entry.fingerprint) === id) this.runningByFingerprint.delete(entry.fingerprint);
32716
33079
  }
32717
33080
  /**
32718
- * Upload a local video to the Gemini Files API (resumable protocol) and wait
32719
- * for it to become usable. Three live-verified steps (docs/GEMINI-API.md
32720
- * "Files API — local video upload"):
32721
- *
32722
- * 1. `POST {UPLOAD_BASE_URL}/files` (command `start`) → the upload session
32723
- * URL arrives in the `x-goog-upload-url` RESPONSE HEADER (body is empty).
32724
- * 2. `POST <session url>` (command `upload, finalize`) with the bytes —
32725
- * a file-backed Blob (`fileBlob`/`fs.openAsBlob`), so fetch STREAMS the
32726
- * video from disk; it is never buffered in memory. Response: `{file:…}`.
32727
- * 3. Poll `GET /v1beta/files/<id>` while `state` is `PROCESSING`, until
32728
- * `ACTIVE` (the poll response is the File object UNWRAPPED).
32729
- *
32730
- * These two upload calls are raw `fetchImpl` (not `createApiClient`): step 1
32731
- * needs the response *header* and step 2 posts a binary body — neither fits
32732
- * `fetchJson`. The poll DOES go through the shared client (timeout + retry).
32733
- * Uploads deliberately have no abort timeout: a multi-hundred-MB video can
32734
- * legitimately take longer than any fixed budget.
33081
+ * Run `work` under the registry, deduplicating where safe:
33082
+ * - with an `idempotencyKey`: attach to a matching `running` OR recently-`done`
33083
+ * job (not `failed`, not expired);
33084
+ * - without a key: attach only to a matching **in-flight** job by fingerprint
33085
+ * (two truly-simultaneous identical requests are a timeout-retry race, never
33086
+ * an intentional variation).
33087
+ * On a dedup hit the shared result is returned with `reused: true`. When
33088
+ * `async` is set, a `{ job_id, status }` handle is returned immediately (no
33089
+ * await) and the caller retrieves the result via `getResult`. Otherwise a
33090
+ * new job runs synchronously and failures propagate to the caller as before.
32735
33091
  *
32736
- * Returned files live ~48h (`expirationTime`); per-file cap is 2 GB
32737
- * (enforced locally before any bytes are sent).
33092
+ * Both dedup paths are scoped to THIS registry, i.e. to one session — a
33093
+ * colliding `idempotency_key` or an identical prompt from another user is a
33094
+ * different registry and therefore a genuinely separate generation.
32738
33095
  */
32739
- async uploadVideo(path, mimeType) {
32740
- const key = this.requireKey();
32741
- const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: "Video" });
32742
- const startPath = "/files";
32743
- const startRes = await this.fetchImpl(`${UPLOAD_BASE_URL}${startPath}`, {
32744
- method: "POST",
32745
- headers: {
32746
- "x-goog-api-key": key,
32747
- "X-Goog-Upload-Protocol": "resumable",
32748
- "X-Goog-Upload-Command": "start",
32749
- "X-Goog-Upload-Header-Content-Length": String(blob.size),
32750
- "X-Goog-Upload-Header-Content-Type": mimeType,
32751
- "Content-Type": "application/json"
33096
+ async dispatch(opts, work) {
33097
+ const { toolName, fingerprint, idempotencyKey, async } = opts;
33098
+ const now = Date.now();
33099
+ let hit;
33100
+ if (idempotencyKey !== void 0) {
33101
+ const id = this.byKey.get(idempotencyKey);
33102
+ const entry2 = id ? this.jobs.get(id) : void 0;
33103
+ if (entry2 && entry2.status !== "failed" && !isExpired(entry2, now)) hit = entry2;
33104
+ } else {
33105
+ const id = this.runningByFingerprint.get(fingerprint);
33106
+ const entry2 = id ? this.jobs.get(id) : void 0;
33107
+ if (entry2 && entry2.status === "running") hit = entry2;
33108
+ }
33109
+ if (hit) {
33110
+ if (async) return jobHandle(hit.jobId, hit.status);
33111
+ return annotateReused(await hit.promise, hit.jobId);
33112
+ }
33113
+ const jobId = randomUUID();
33114
+ this.evict(now);
33115
+ const promise2 = work();
33116
+ const entry = { jobId, toolName, fingerprint, idempotencyKey, status: "running", promise: promise2, createdAt: now };
33117
+ this.jobs.set(jobId, entry);
33118
+ if (idempotencyKey !== void 0) this.byKey.set(idempotencyKey, jobId);
33119
+ this.runningByFingerprint.set(fingerprint, jobId);
33120
+ promise2.then(
33121
+ (res) => {
33122
+ entry.status = "done";
33123
+ entry.result = res;
33124
+ entry.settledAt = Date.now();
32752
33125
  },
32753
- body: JSON.stringify({ file: { display_name: basename(path) } })
33126
+ (err) => {
33127
+ entry.status = "failed";
33128
+ entry.error = { message: err instanceof Error ? err.message : String(err), hint: err?.hint };
33129
+ entry.settledAt = Date.now();
33130
+ }
33131
+ ).finally(() => {
33132
+ if (this.runningByFingerprint.get(fingerprint) === jobId) this.runningByFingerprint.delete(fingerprint);
32754
33133
  });
32755
- if (!startRes.ok) {
32756
- throw new McpToolError(
32757
- formatApiError(startRes.status, "POST", `/upload/v1beta${startPath}`, await startRes.text(), { service: SERVICE })
32758
- );
32759
- }
32760
- const uploadUrl = startRes.headers.get("x-goog-upload-url");
32761
- if (!uploadUrl) {
32762
- throw new McpToolError(`${SERVICE} upload start did not return an x-goog-upload-url header`, {
32763
- hint: "The Files API resumable-upload contract may have changed \u2014 see docs/GEMINI-API.md."
33134
+ if (async) return jobHandle(jobId, "running");
33135
+ return promise2;
33136
+ }
33137
+ /**
33138
+ * Retrieve an async job by id (backs the `gemini_get_result` tool):
33139
+ * `running` → a status handle; `done` → the recorded result payload;
33140
+ * `failed` → the recorded error; unknown/expired → an actionable error.
33141
+ *
33142
+ * A job id from ANOTHER session is simply not in this registry, so it takes
33143
+ * the unknown-id path — the poll tool cannot be used to read another user's
33144
+ * result even if they know the uuid.
33145
+ */
33146
+ getResult(jobId) {
33147
+ const entry = this.jobs.get(jobId);
33148
+ if (!entry) {
33149
+ throw new McpToolError(`No job "${jobId}" \u2014 unknown or expired.`, {
33150
+ hint: "Jobs live with your session and are evicted ~10 min after completion (or on server restart). If the generation likely finished, check the output dir / <image>.json sidecar instead of polling."
32764
33151
  });
32765
33152
  }
32766
- const upRes = await this.fetchImpl(uploadUrl, {
32767
- method: "POST",
32768
- headers: {
32769
- "X-Goog-Upload-Offset": "0",
32770
- "X-Goog-Upload-Command": "upload, finalize"
32771
- },
32772
- body: blob
32773
- });
32774
- if (!upRes.ok) {
32775
- throw new McpToolError(
32776
- formatApiError(upRes.status, "POST", "/upload/v1beta/files (finalize)", await upRes.text(), { service: SERVICE })
32777
- );
32778
- }
32779
- let file2 = (await upRes.json()).file ?? {};
32780
- const name = file2.name ?? "";
32781
- if (!/^files\/[\w-]+$/.test(name)) {
32782
- throw new McpToolError(`${SERVICE} upload returned an unexpected file name: ${name || "(none)"}`);
32783
- }
32784
- for (let attempt = 0; file2.state === "PROCESSING"; attempt++) {
32785
- if (attempt >= FILE_POLL_MAX_ATTEMPTS) {
32786
- throw new McpToolError(`${SERVICE} video file ${name} is still processing after ${FILE_POLL_INTERVAL_MS * FILE_POLL_MAX_ATTEMPTS / 6e4} minutes`, {
32787
- hint: "Very long videos can take a while to process \u2014 retry later by passing the file uri as video_url."
32788
- });
32789
- }
32790
- await this.sleep(FILE_POLL_INTERVAL_MS);
32791
- file2 = await this.call("GET", `/${name}`);
32792
- }
32793
- if (file2.state !== "ACTIVE") {
32794
- throw new McpToolError(
32795
- `${SERVICE} video file processing failed (state ${file2.state ?? "unknown"})${file2.error?.message ? `: ${file2.error.message}` : ""}`,
32796
- { hint: "Check the video is a supported format/codec and under 2 GB." }
32797
- );
32798
- }
32799
- if (!file2.uri) {
32800
- throw new McpToolError(`${SERVICE} did not return a uri for uploaded file ${name}`);
33153
+ if (entry.status === "running") return jobHandle(jobId, "running");
33154
+ if (entry.status === "failed") {
33155
+ throw new McpToolError(entry.error?.message ?? "Job failed.", entry.error?.hint ? { hint: entry.error.hint } : void 0);
32801
33156
  }
32802
- return { name, uri: file2.uri, mimeType: file2.mimeType ?? mimeType, expirationTime: file2.expirationTime };
32803
- }
32804
- async listModels() {
32805
- const data = await this.call("GET", "/models?pageSize=200");
32806
- return filterImageModels(data.models ?? []);
33157
+ if (!entry.result) throw new McpToolError(`Job "${jobId}" completed without a recorded result.`);
33158
+ return entry.result;
32807
33159
  }
32808
- async generate(opts) {
32809
- const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
32810
- const parts = [{ text: opts.prompt }];
32811
- for (const img of opts.images ?? []) {
32812
- parts.push({ inline_data: { mime_type: img.mimeType, data: img.base64 } });
32813
- }
32814
- if (opts.videoUrl) {
32815
- parts.push({ file_data: { file_uri: opts.videoUrl, mime_type: opts.videoMimeType ?? "video/mp4" } });
32816
- }
32817
- const generationConfig = { responseModalities: ["TEXT", "IMAGE"] };
32818
- if (opts.aspectRatio || opts.imageSize) {
32819
- const imageConfig = {};
32820
- if (opts.aspectRatio) imageConfig.aspectRatio = opts.aspectRatio;
32821
- if (opts.imageSize) imageConfig.imageSize = opts.imageSize;
32822
- generationConfig.imageConfig = imageConfig;
32823
- }
32824
- if (opts.seed !== void 0) generationConfig.seed = opts.seed;
32825
- if (opts.thinkingLevel !== void 0) generationConfig.thinkingConfig = { thinkingLevel: opts.thinkingLevel };
32826
- const requestBody = { contents: [{ parts }], generationConfig };
32827
- if (opts.googleSearch) requestBody.tools = [{ google_search: {} }];
32828
- const data = await this.call(
32829
- "POST",
32830
- `/models/${model}:generateContent`,
32831
- requestBody,
32832
- resolveTimeoutMs(opts.timeoutMs, opts.imageSize)
32833
- );
32834
- const images = [];
32835
- const textParts = [];
32836
- let groundingMeta;
32837
- for (const cand of data.candidates ?? []) {
32838
- for (const part of cand.content?.parts ?? []) {
32839
- const inline = part.inline_data ?? part.inlineData;
32840
- if (inline?.data) {
32841
- images.push({ base64: inline.data, mimeType: inline.mime_type ?? inline.mimeType ?? "image/jpeg" });
32842
- } else if (typeof part.text === "string" && part.text.trim()) {
32843
- textParts.push(part.text);
32844
- }
32845
- }
32846
- if (cand.groundingMetadata) groundingMeta = cand.groundingMetadata;
32847
- }
32848
- if (images.length === 0) {
32849
- throw new McpToolError(`${SERVICE} returned no image`, {
32850
- hint: "The request may have been blocked by safety filters \u2014 try rephrasing the prompt."
32851
- });
32852
- }
32853
- const text = textParts.join("\n") || void 0;
32854
- let grounding;
32855
- if (groundingMeta) {
32856
- const queries = (groundingMeta.webSearchQueries ?? []).filter(Boolean);
32857
- const sources = (groundingMeta.groundingChunks ?? []).map((c) => c.web).filter((w) => !!w);
32858
- if (queries.length > 0 || sources.length > 0) {
32859
- grounding = {};
32860
- if (queries.length > 0) grounding.queries = queries;
32861
- if (sources.length > 0) grounding.sources = sources;
33160
+ };
33161
+
33162
+ // src/tools/_confirm.ts
33163
+ init_dist();
33164
+ init_images();
33165
+ function previewUnlessConfirmed(confirm, action, method, path, body) {
33166
+ if (confirm === true) return null;
33167
+ return textResult({
33168
+ dryRun: true,
33169
+ action,
33170
+ method,
33171
+ path,
33172
+ ...body !== void 0 ? { willSend: body } : {},
33173
+ note: "Re-run with confirm: true to execute."
33174
+ });
33175
+ }
33176
+ async function previewLocalInputsUnlessConfirmed(confirm, action, endpoint, imagePaths, videoPath) {
33177
+ const hasLocal = (imagePaths?.length ?? 0) > 0 || Boolean(videoPath);
33178
+ if (confirm === true || !hasLocal) return null;
33179
+ const inputs = [];
33180
+ for (const p of imagePaths ?? []) inputs.push(await previewImageInput(p));
33181
+ if (videoPath) inputs.push(await previewVideoInput(videoPath));
33182
+ return previewUnlessConfirmed(confirm, action, "POST", endpoint, { inputs });
33183
+ }
33184
+
33185
+ // src/tools/generate.ts
33186
+ var GENERATE_ENDPOINT = "/v1beta/models/{model}:generateContent";
33187
+ var SEND_LOCAL_ACTION = "Send local file input(s) to the Gemini API";
33188
+ var REFINE_HINT = "To iteratively refine this image, use gemini_interact (pass the output file via `images` on the first call, then chain `previous_interaction_id` on each subsequent call).";
33189
+ function registerGenerateTools(server, client2) {
33190
+ server.registerTool(
33191
+ "gemini_image_generate",
33192
+ {
33193
+ description: "Generate image(s) from a text prompt with a Gemini image model (Nano Banana / Nano Banana Pro). If the result will likely be refined iteratively, prefer gemini_interact (multi-turn) as the entry point.",
33194
+ annotations: { readOnlyHint: false, openWorldHint: true },
33195
+ inputSchema: {
33196
+ prompt: external_exports.string().min(1).describe("Text prompt describing the image"),
33197
+ count: external_exports.number().int().positive().max(8).optional().describe("Number of independent images (default 1)"),
33198
+ filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
33199
+ images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to reference input images (image-conditioned generation)"),
33200
+ images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs"),
33201
+ video_url: external_exports.string().url().optional().describe("Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video\u2192image; use a Flash model e.g. gemini-3.1-flash-image)"),
33202
+ video_path: videoPathSchema,
33203
+ confirm: schemaConfirm,
33204
+ ...sharedImageSchema
32862
33205
  }
32863
- }
32864
- return { images, text, grounding };
32865
- }
32866
- async interact(opts) {
32867
- const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
32868
- const inputParts = [{ type: "text", text: opts.input }];
32869
- for (const img of opts.images ?? []) {
32870
- inputParts.push({ type: "image", mime_type: img.mimeType, data: img.base64 });
32871
- }
32872
- if (opts.videoUrl) {
32873
- inputParts.push({ type: "video", uri: opts.videoUrl, mime_type: opts.videoMimeType ?? "video/mp4" });
32874
- }
32875
- const responseFormat = { type: "image", mime_type: "image/jpeg" };
32876
- if (opts.aspectRatio) responseFormat.aspect_ratio = opts.aspectRatio;
32877
- if (opts.imageSize) responseFormat.image_size = opts.imageSize;
32878
- const body = { model, input: inputParts, response_format: responseFormat };
32879
- if (opts.thinkingLevel !== void 0) body.generation_config = { thinking_level: opts.thinkingLevel };
32880
- if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
32881
- if (opts.googleSearch || opts.searchTypes?.length) {
32882
- const searchTool = { type: "google_search" };
32883
- if (opts.searchTypes?.length) searchTool.search_types = opts.searchTypes;
32884
- body.tools = [searchTool];
32885
- }
32886
- const data = await this.postInteraction(body, opts);
32887
- const { images, text, grounding } = this.extractInteraction(data, opts.searchTypes);
32888
- if (images.length === 0) {
32889
- throw new McpToolError("Gemini returned no image", {
32890
- hint: "The request may have been blocked by safety filters \u2014 try rephrasing the prompt."
32891
- });
32892
- }
32893
- return { id: data.id, images, text, grounding };
32894
- }
32895
- /**
32896
- * Generate video via the omni model (Interactions API, inline delivery). Same
32897
- * plumbing as {@link interact} — text/image input parts, a `video`
32898
- * response_format, optional `video_config.task`, and `previous_interaction_id`
32899
- * for edits. Preview model: shapes are docs-derived; parsing stays tolerant.
32900
- */
32901
- async generateVideo(opts) {
32902
- const model = opts.model ?? DEFAULT_VIDEO_MODEL;
32903
- const inputParts = [{ type: "text", text: opts.input }];
32904
- for (const img of opts.images ?? []) inputParts.push({ type: "image", mime_type: img.mimeType, data: img.base64 });
32905
- const responseFormat = { type: "video", delivery: "inline" };
32906
- if (opts.aspectRatio) responseFormat.aspect_ratio = opts.aspectRatio;
32907
- const body = { model, input: inputParts, response_format: responseFormat };
32908
- if (opts.task) body.generation_config = { video_config: { task: opts.task } };
32909
- if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
32910
- const data = await this.postInteraction(body, opts);
32911
- const { videos, text } = this.extractInteraction(data);
32912
- if (videos.length === 0) {
32913
- throw new McpToolError("Gemini returned no video", {
32914
- hint: "The request may have been blocked by a safety filter, or the clip exceeded the ~4MB inline limit \u2014 try a shorter/simpler prompt or a different aspect ratio."
32915
- });
32916
- }
32917
- return { id: data.id, videos, text };
32918
- }
32919
- /**
32920
- * Generate music via a Lyria model (Interactions API, inline audio). Reuses
32921
- * the interact plumbing with an `audio` response_format. Preview model.
32922
- */
32923
- async generateMusic(opts) {
32924
- const model = opts.model ?? DEFAULT_MUSIC_MODEL;
32925
- const inputParts = [{ type: "text", text: opts.input }];
32926
- for (const img of opts.images ?? []) inputParts.push({ type: "image", mime_type: img.mimeType, data: img.base64 });
32927
- const responseFormat = { type: "audio" };
32928
- if (opts.audioFormat) responseFormat.audio_format = opts.audioFormat;
32929
- const body = { model, input: inputParts, response_format: responseFormat };
32930
- if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
32931
- const data = await this.postInteraction(body, opts);
32932
- const { audios, text } = this.extractInteraction(data);
32933
- if (audios.length === 0) {
32934
- throw new McpToolError("Gemini returned no audio", {
32935
- hint: "The request may have been blocked by a safety filter \u2014 try rephrasing the prompt."
33206
+ },
33207
+ async (args, extra) => {
33208
+ assertLocalInputsAvailable(client2.mediaSink, args);
33209
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images, args.video_path);
33210
+ if (gate) return gate;
33211
+ const count = args.count ?? 1;
33212
+ const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33213
+ const fingerprint = fingerprintRequest("gemini_image_generate", {
33214
+ model,
33215
+ prompt: args.prompt,
33216
+ aspect_ratio: args.aspect_ratio,
33217
+ image_size: args.image_size,
33218
+ thinking_level: args.thinking_level,
33219
+ google_search: args.google_search,
33220
+ count,
33221
+ images: args.images,
33222
+ images_base64: args.images_base64,
33223
+ from_clipboard: args.from_clipboard,
33224
+ video_url: args.video_url,
33225
+ video_path: args.video_path
32936
33226
  });
32937
- }
32938
- return { id: data.id, audios, text };
32939
- }
32940
- /** POST `/interactions` with the chained-404 retry. Shared by all media. */
32941
- async postInteraction(body, opts) {
32942
- const { interactionsApi } = this.apisFor(resolveTimeoutMs(opts.timeoutMs, opts.imageSize));
32943
- for (let attempt = 0; ; attempt++) {
32944
- try {
32945
- return await interactionsApi.fetchJson("POST", "/interactions", { body });
32946
- } catch (err) {
32947
- if (!(opts.previousInteractionId && err instanceof ApiError && err.status === 404)) throw err;
32948
- if (attempt < CHAIN_404_RETRIES) {
32949
- await this.sleep(CHAIN_404_RETRY_MS * (attempt + 1));
32950
- continue;
32951
- }
32952
- throw new McpToolError(
32953
- `Previous interaction "${opts.previousInteractionId}" was not found (retried ${CHAIN_404_RETRIES}\xD7 \u2014 the interactions store can lag briefly after a turn completes). If the id is old: interactions are retained 55 days on the paid tier (1 day on the free tier) and are scoped to the API key that created them.`,
32954
- {
32955
- hint: "Retry once more in a few seconds; if it keeps failing, start a new chain by re-attaching the last output as an input plus the same instruction, then chain the NEW interaction id.",
32956
- cause: err
32957
- }
32958
- );
32959
- }
32960
- }
32961
- }
32962
- /**
32963
- * Collect `model_output` media (image/video/audio) + text + grounding from an
32964
- * interactions response. Only the `model_output` step is surfaced — `thought`
32965
- * steps (internal reasoning / draft images) are dropped on purpose so they
32966
- * can't leak into text or pollute the outputs. Tolerant of snake/camel MIME.
32967
- */
32968
- extractInteraction(data, searchTypes) {
32969
- const images = [];
32970
- const videos = [];
32971
- const audios = [];
32972
- const textParts = [];
32973
- const searchQueries = [];
32974
- const searchSuggestions = /* @__PURE__ */ new Set();
32975
- for (const step of data.steps ?? []) {
32976
- if (step.type === "google_search_call") {
32977
- for (const q of step.arguments?.queries ?? []) if (q?.trim()) searchQueries.push(q);
32978
- continue;
32979
- }
32980
- if (step.type === "google_search_result") {
32981
- if (searchTypes?.includes("image_search")) {
32982
- for (const r of step.result ?? []) if (r.search_suggestions?.trim()) searchSuggestions.add(r.search_suggestions);
32983
- }
32984
- continue;
32985
- }
32986
- if (step.type !== "model_output") continue;
32987
- for (const parts of [step.content ?? [], step.summary ?? []]) {
32988
- for (const part of parts) {
32989
- const mime = part.mime_type ?? part.mimeType;
32990
- if (part.data) {
32991
- if (part.type === "image") images.push({ base64: part.data, mimeType: mime ?? "image/jpeg" });
32992
- else if (part.type === "video") videos.push({ base64: part.data, mimeType: mime ?? "video/mp4" });
32993
- else if (part.type === "audio") audios.push({ base64: part.data, mimeType: mime ?? "audio/mpeg" });
32994
- } else if (part.type === "text" && part.text?.trim()) {
32995
- textParts.push(part.text);
33227
+ return client2.session.jobs.dispatch({ toolName: "gemini_image_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33228
+ const seed = pickSeed(args.seed);
33229
+ const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33230
+ const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33231
+ const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args, client2));
33232
+ const named = [];
33233
+ let capturedText;
33234
+ let capturedGrounding;
33235
+ await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : "image"} (${model})`, async () => {
33236
+ for (let i = 0; i < count; i++) {
33237
+ const result = await client2.generate({
33238
+ prompt: args.prompt,
33239
+ images: refInputs.length > 0 ? refInputs : void 0,
33240
+ model: args.model,
33241
+ aspectRatio: args.aspect_ratio,
33242
+ imageSize: args.image_size,
33243
+ seed: seed + i,
33244
+ thinkingLevel: args.thinking_level,
33245
+ googleSearch: args.google_search,
33246
+ videoUrl: video.videoUrl,
33247
+ videoMimeType: video.videoMimeType,
33248
+ timeoutMs: args.timeout_ms
33249
+ });
33250
+ const { images: [img], text } = result;
33251
+ if (text && !capturedText) capturedText = text;
33252
+ if (result.grounding && !capturedGrounding) capturedGrounding = result.grounding;
33253
+ named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, "0")}` });
32996
33254
  }
32997
- }
32998
- }
32999
- }
33000
- let grounding;
33001
- if (searchQueries.length > 0 || searchSuggestions.size > 0) {
33002
- grounding = {};
33003
- if (searchQueries.length > 0) grounding.queries = searchQueries;
33004
- if (searchSuggestions.size > 0) grounding.search_suggestions = [...searchSuggestions];
33255
+ });
33256
+ const meta3 = buildMeta(model, seed, args);
33257
+ if (capturedText) meta3.text = capturedText;
33258
+ if (capturedGrounding) meta3.grounding = capturedGrounding;
33259
+ if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
33260
+ meta3.hint = REFINE_HINT;
33261
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size, count, persistsFiles: client2.mediaSink?.persistsFiles });
33262
+ if (risk) meta3.timeout_risk = risk;
33263
+ return emit(named, { ...args, sink: client2.mediaSink }, meta3);
33264
+ });
33005
33265
  }
33006
- return { images, videos, audios, text: textParts.join("\n") || void 0, grounding };
33007
- }
33008
- };
33009
- var client = new GeminiClient();
33010
-
33011
- // src/tools/models.ts
33012
- function registerModelTools(server) {
33266
+ );
33013
33267
  server.registerTool(
33014
- "gemini_list_models",
33268
+ "gemini_image_edit",
33015
33269
  {
33016
- description: "List the Gemini image-generation models available to your API key (Nano Banana / Nano Banana Pro family), and the current default model.",
33017
- annotations: { readOnlyHint: true }
33270
+ description: "Edit or compose images: provide one or more input images (paths or base64), plus a text instruction. For a SERIES of successive edits to the same image, prefer gemini_interact (multi-turn) \u2014 it keeps edit context and avoids re-processing the full image each round; use gemini_image_edit for one-off edits or composing multiple distinct inputs. Gemini over-preserves the input; there is no edit-strength control \u2014 for large structural changes, reroll with a different `seed` or more forceful wording.",
33271
+ annotations: { readOnlyHint: false, openWorldHint: true },
33272
+ inputSchema: {
33273
+ prompt: external_exports.string().min(1).describe("Instruction describing the edit or composition"),
33274
+ images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to input image file(s) (1 = edit, 2+ = compose)"),
33275
+ images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Input images as base64 strings or data URIs"),
33276
+ filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
33277
+ confirm: schemaConfirm,
33278
+ ...sharedImageSchema
33279
+ }
33018
33280
  },
33019
- async () => {
33020
- const models = await client.listModels();
33021
- return textResult({ default: client.defaultModel(), models });
33281
+ async (args, extra) => {
33282
+ assertLocalInputsAvailable(client2.mediaSink, args);
33283
+ const hasPaths = (args.images?.length ?? 0) > 0;
33284
+ const hasBase64 = (args.images_base64?.length ?? 0) > 0;
33285
+ if (!hasPaths && !hasBase64 && !args.from_clipboard) {
33286
+ throw new McpToolError("Provide at least one input image via `images`, `images_base64`, or `from_clipboard`.");
33287
+ }
33288
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
33289
+ if (gate) return gate;
33290
+ const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33291
+ const fingerprint = fingerprintRequest("gemini_image_edit", {
33292
+ model,
33293
+ prompt: args.prompt,
33294
+ aspect_ratio: args.aspect_ratio,
33295
+ image_size: args.image_size,
33296
+ thinking_level: args.thinking_level,
33297
+ google_search: args.google_search,
33298
+ images: args.images,
33299
+ images_base64: args.images_base64,
33300
+ from_clipboard: args.from_clipboard
33301
+ });
33302
+ return client2.session.jobs.dispatch({ toolName: "gemini_image_edit", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33303
+ const seed = pickSeed(args.seed);
33304
+ const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33305
+ const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33306
+ const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client2.generate({
33307
+ prompt: args.prompt,
33308
+ images: inputs,
33309
+ model: args.model,
33310
+ aspectRatio: args.aspect_ratio,
33311
+ imageSize: args.image_size,
33312
+ seed,
33313
+ thinkingLevel: args.thinking_level,
33314
+ googleSearch: args.google_search,
33315
+ timeoutMs: args.timeout_ms
33316
+ }));
33317
+ const { images: [img], text } = result;
33318
+ const meta3 = buildMeta(model, seed, args);
33319
+ if (text) meta3.text = text;
33320
+ if (result.grounding) meta3.grounding = result.grounding;
33321
+ meta3.hint = REFINE_HINT;
33322
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: client2.mediaSink?.persistsFiles });
33323
+ if (risk) meta3.timeout_risk = risk;
33324
+ return emit([{ image: img, base: slug }], { ...args, sink: client2.mediaSink }, meta3);
33325
+ });
33022
33326
  }
33023
33327
  );
33024
33328
  }
33025
33329
 
33026
- // src/tools/generate.ts
33330
+ // src/tools/set.ts
33027
33331
  init_zod();
33028
33332
  init_dist();
33029
-
33030
- // src/images.ts
33031
- init_dist();
33032
- import { mkdir, readFile as readFile2, writeFile, access, stat, open } from "node:fs/promises";
33033
- import { existsSync } from "node:fs";
33034
- import { join as join3, resolve, isAbsolute } from "node:path";
33035
- function slugify2(text, max = 40) {
33036
- const s = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/g, "");
33037
- return s || "image";
33038
- }
33039
- async function exists(p) {
33040
- try {
33041
- await access(p);
33042
- return true;
33043
- } catch {
33044
- return false;
33045
- }
33046
- }
33047
- async function uniquePath(dir, base, ext) {
33048
- let candidate = join3(dir, `${base}.${ext}`);
33049
- let n = 2;
33050
- while (await exists(candidate)) {
33051
- candidate = join3(dir, `${base}-${n}.${ext}`);
33052
- n++;
33053
- }
33054
- return candidate;
33055
- }
33056
- function sniffMimeBytes(buf) {
33057
- if (buf.length >= 4 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) {
33058
- return "image/png";
33059
- }
33060
- if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) {
33061
- return "image/jpeg";
33062
- }
33063
- if (buf.length >= 12 && buf[0] === 82 && buf[1] === 73 && buf[2] === 70 && buf[3] === 70 && buf[8] === 87 && buf[9] === 69 && buf[10] === 66 && buf[11] === 80) {
33064
- return "image/webp";
33065
- }
33066
- return "image/png";
33067
- }
33068
- function mediaExt(mimeType) {
33069
- const m = mimeType.toLowerCase();
33070
- if (m.includes("jpeg")) return "jpg";
33071
- if (m.includes("webp")) return "webp";
33072
- if (m.includes("png")) return "png";
33073
- if (m.includes("mp4")) return "mp4";
33074
- if (m.includes("quicktime")) return "mov";
33075
- if (m.includes("webm")) return "webm";
33076
- if (m.includes("wav")) return "wav";
33077
- if (m.includes("mpeg") || m.includes("mp3")) return "mp3";
33078
- if (m.includes("ogg")) return "ogg";
33079
- return m.split("/")[1]?.split(";")[0]?.replace(/^x-/, "") || "bin";
33080
- }
33081
- async function writeMedia(dir, base, base643, mimeType) {
33082
- await mkdir(dir, { recursive: true });
33083
- const path = await uniquePath(dir, base, mediaExt(mimeType));
33084
- await writeFile(path, Buffer.from(base643, "base64"));
33085
- return resolve(path);
33086
- }
33087
- async function writeSidecar(imagePath, data) {
33088
- await writeFile(`${imagePath}.json`, JSON.stringify(data, null, 2));
33089
- }
33090
- function resolveInputPath(p, kind) {
33091
- if (isAbsolute(p) && existsSync(p)) return p;
33092
- const inputDir = readEnvVar("GEMINI_INPUT_DIR");
33093
- if (inputDir) {
33094
- const candidate = join3(inputDir, p);
33095
- if (existsSync(candidate)) return resolve(candidate);
33096
- }
33097
- const cwd = resolve(p);
33098
- if (existsSync(cwd)) return cwd;
33099
- const hint = `Set GEMINI_INPUT_DIR to a directory containing your ${kind.toLowerCase()}s, or pass an absolute path.`;
33100
- throw new McpToolError(
33101
- `${kind} not found: ${p}` + (inputDir ? ` (also searched GEMINI_INPUT_DIR=${inputDir})` : ""),
33102
- { hint }
33103
- );
33104
- }
33105
- function resolveImagePath(p) {
33106
- return resolveInputPath(p, "Image");
33107
- }
33108
- function resolveVideoPath(p) {
33109
- return resolveInputPath(p, "Video");
33110
- }
33111
- var VIDEO_MIME_BY_EXT = {
33112
- mp4: "video/mp4",
33113
- mpeg: "video/mpeg",
33114
- mpg: "video/mpg",
33115
- mov: "video/mov",
33116
- avi: "video/avi",
33117
- flv: "video/x-flv",
33118
- webm: "video/webm",
33119
- wmv: "video/wmv",
33120
- "3gp": "video/3gpp",
33121
- "3gpp": "video/3gpp"
33122
- };
33123
- function videoMimeType(p) {
33124
- const ext = p.includes(".") ? p.slice(p.lastIndexOf(".") + 1).toLowerCase() : "";
33125
- const mime = VIDEO_MIME_BY_EXT[ext];
33126
- if (!mime) {
33127
- throw new McpToolError(`Unsupported video format: ${ext ? `.${ext}` : p}`, {
33128
- hint: `Supported extensions: ${Object.keys(VIDEO_MIME_BY_EXT).map((e) => `.${e}`).join(", ")}`
33129
- });
33130
- }
33131
- return mime;
33132
- }
33133
- async function previewImageInput(path) {
33134
- const resolved = resolveImagePath(path);
33135
- const { size } = await stat(resolved);
33136
- const fh = await open(resolved, "r");
33137
- try {
33138
- const head = Buffer.alloc(16);
33139
- const { bytesRead } = await fh.read(head, 0, 16, 0);
33140
- return { path: resolved, mimeType: sniffMimeBytes(head.subarray(0, bytesRead)), size };
33141
- } finally {
33142
- await fh.close();
33143
- }
33144
- }
33145
- async function previewVideoInput(path) {
33146
- const resolved = resolveVideoPath(path);
33147
- const { size } = await stat(resolved);
33148
- return { path: resolved, mimeType: videoMimeType(resolved), size };
33149
- }
33150
- async function readImageAsInline(path) {
33151
- const resolved = resolveImagePath(path);
33152
- const buf = await readFile2(resolved);
33153
- const mimeType = sniffMimeBytes(buf);
33154
- return { base64: buf.toString("base64"), mimeType };
33155
- }
33156
- function decodeImageInput(input) {
33157
- const trimmed = input.trim();
33158
- if (trimmed.startsWith("data:")) {
33159
- const marker = trimmed.indexOf(";base64,");
33160
- if (marker === -1) {
33161
- throw new Error(`Unsupported data URI (expected ;base64,<data>): ${trimmed.slice(0, 48)}\u2026`);
33162
- }
33163
- const mimeType = trimmed.slice(5, trimmed.indexOf(";", 5)) || "image/png";
33164
- return { mimeType, base64: trimmed.slice(marker + ";base64,".length) };
33165
- }
33166
- const buf = Buffer.from(trimmed, "base64");
33167
- return { base64: trimmed, mimeType: sniffMimeBytes(buf) };
33168
- }
33169
- async function loadImageInputs(paths = [], base64s = []) {
33170
- const fromPaths = await Promise.all(paths.map((p) => readImageAsInline(p)));
33171
- const fromBase64s = base64s.map((b) => decodeImageInput(b));
33172
- return [...fromPaths, ...fromBase64s];
33173
- }
33174
- async function gatherImageInputs(opts) {
33175
- const clipboardImages = opts.from_clipboard ? [await (await Promise.resolve().then(() => (init_clipboard(), clipboard_exports))).readClipboardImage()] : [];
33176
- const fileImages = await loadImageInputs(opts.images, opts.images_base64);
33177
- return [...clipboardImages, ...fileImages];
33178
- }
33179
- function baseName(name) {
33180
- const stripped = name.replace(/\.(png|jpe?g|webp|mp4|mov|webm|mp3|wav|ogg)$/i, "");
33181
- const safe = stripped.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
33182
- return safe || "image";
33183
- }
33184
- function resolveOutputDir(perCall) {
33185
- return perCall?.trim() || readEnvVar("GEMINI_OUTPUT_DIR") || process.cwd();
33333
+ init_images();
33334
+ function registerSetTools(server, client2) {
33335
+ server.registerTool(
33336
+ "gemini_image_set",
33337
+ {
33338
+ description: "Generate a consistent SET of images: a master image from master_prompt, then one image per scene that references the master so the subject/style stays consistent. Provide `scenes` (explicit per-image prompts) OR `count` (variations of the master).",
33339
+ annotations: { readOnlyHint: false, openWorldHint: true },
33340
+ inputSchema: {
33341
+ master_prompt: external_exports.string().min(1).describe("Prompt for the master/reference image"),
33342
+ scenes: external_exports.array(external_exports.string().min(1)).min(1).max(8).optional().describe("Per-image prompts (1-8); each references the master"),
33343
+ count: external_exports.number().int().positive().max(8).optional().describe("Number of variations of master_prompt (when scenes omitted)"),
33344
+ reference_mode: external_exports.enum(["master", "chain"]).optional().describe("master: every image references the master (default). chain: each references the previous."),
33345
+ basename: external_exports.string().optional().describe("Base filename prefix for output images (default: slugified master_prompt)"),
33346
+ master_images: external_exports.array(external_exports.string().min(1)).optional().describe("Reference image paths passed to the master generation call"),
33347
+ master_images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs for master generation"),
33348
+ confirm: schemaConfirm,
33349
+ ...sharedImageSchema
33350
+ }
33351
+ },
33352
+ async (args, extra) => {
33353
+ assertLocalInputsAvailable(client2.mediaSink, args);
33354
+ if (args.scenes && args.count || !args.scenes && !args.count) {
33355
+ throw new McpToolError("Provide exactly one of `scenes` or `count`.");
33356
+ }
33357
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local master image input(s) to the Gemini API", "/v1beta/models/{model}:generateContent", args.master_images);
33358
+ if (gate) return gate;
33359
+ const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33360
+ const fingerprint = fingerprintRequest("gemini_image_set", {
33361
+ model,
33362
+ master_prompt: args.master_prompt,
33363
+ scenes: args.scenes,
33364
+ count: args.count,
33365
+ reference_mode: args.reference_mode,
33366
+ aspect_ratio: args.aspect_ratio,
33367
+ image_size: args.image_size,
33368
+ thinking_level: args.thinking_level,
33369
+ google_search: args.google_search,
33370
+ master_images: args.master_images,
33371
+ master_images_base64: args.master_images_base64,
33372
+ from_clipboard: args.from_clipboard
33373
+ });
33374
+ return client2.session.jobs.dispatch({ toolName: "gemini_image_set", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33375
+ const seed = pickSeed(args.seed);
33376
+ const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
33377
+ const slug = args.basename ? baseName(args.basename) : slugify2(args.master_prompt);
33378
+ const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
33379
+ const named = [];
33380
+ const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
33381
+ const result = await client2.generate({
33382
+ prompt: args.master_prompt,
33383
+ images: masterRefInputs.length > 0 ? masterRefInputs : void 0,
33384
+ seed,
33385
+ ...cfg
33386
+ });
33387
+ const master = result.images[0];
33388
+ named.push({ image: master, base: `${slug}-master` });
33389
+ const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
33390
+ const mode = args.reference_mode ?? "master";
33391
+ if (mode === "chain") {
33392
+ let ref = master;
33393
+ for (let i = 0; i < scenePrompts.length; i++) {
33394
+ const { images: [img] } = await client2.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
33395
+ named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` });
33396
+ ref = img;
33397
+ }
33398
+ } else {
33399
+ const scenes = await Promise.all(
33400
+ scenePrompts.map((p, i) => client2.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
33401
+ );
33402
+ scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` }));
33403
+ }
33404
+ return result;
33405
+ });
33406
+ const masterText = masterResult.text;
33407
+ const meta3 = buildMeta(model, seed, args);
33408
+ if (masterText) meta3.text = masterText;
33409
+ if (masterResult.grounding) meta3.grounding = masterResult.grounding;
33410
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client2.mediaSink?.persistsFiles });
33411
+ if (risk) meta3.timeout_risk = risk;
33412
+ return emit(named, { ...args, sink: client2.mediaSink }, meta3);
33413
+ });
33414
+ }
33415
+ );
33186
33416
  }
33187
33417
 
33188
- // src/tools/shared.ts
33418
+ // src/tools/interact.ts
33189
33419
  init_zod();
33190
33420
  init_dist();
33191
- var ASPECT_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9", "1:4", "4:1", "1:8", "8:1"];
33192
- var IMAGE_SIZES = ["512", "1K", "2K", "4K"];
33193
- var MODEL_CHOICE_GUIDE = "gemini-3.1-flash-image (Nano Banana 2) is the versatile generalist workhorse \u2014 balances speed with state-of-the-art 4K generation, world knowledge, and reliable text rendering; excels at multi-reference-image processing and consistency. gemini-3-pro-image (Nano Banana Pro) is the premium choice for the most complex visual tasks \u2014 highest world knowledge, advanced localization, accurate brand consistency, precision creative control. gemini-3.1-flash-lite-image (Nano Banana 2 Lite) is the fastest/cheapest for simple tasks (1K only, no search grounding).";
33194
- var timeoutMsSchema = external_exports.number().int().positive().optional().describe(
33195
- "Upstream request timeout in ms for this call (default: $GEMINI_TIMEOUT_MS, else 60000 \u2014 or 120000 when image_size is 4K, which routinely runs past 60s)"
33196
- );
33197
- var idempotencyKeySchema = external_exports.string().min(1).optional().describe(
33198
- "Opaque idempotency key: a repeat call with the same key returns the recorded result (reused: true) instead of billing a new generation. Set it when retrying after a host timeout (-32001) to avoid a duplicate charge."
33199
- );
33200
- var asyncSchema = external_exports.boolean().optional().describe(
33201
- "Run in the background and return a job_id immediately instead of the image, so a long (Pro/4K) generation cannot hit the host tools/call timeout (-32001). Poll gemini_get_result with the job_id to fetch the result (jobs are per-process and expire ~10 min after completion)."
33202
- );
33203
- var sharedImageSchema = {
33204
- // Bare id only: the model is interpolated into the URL path
33205
- // (`/models/${model}:generateContent`), so slashes/colons/queries must be
33206
- // rejected to keep a crafted value from escaping the path segment.
33207
- model: external_exports.string().regex(/^[\w.-]+$/, 'must be a bare model id (letters, digits, ".", "_", "-")').optional().describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
33208
- aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Output aspect ratio"),
33209
- image_size: external_exports.enum(IMAGE_SIZES).optional().describe("Output resolution (512 = 0.5K, Flash-only)"),
33210
- output_dir: external_exports.string().optional().describe("Directory to write images to (default: $GEMINI_OUTPUT_DIR or cwd)"),
33211
- inline: external_exports.boolean().optional().describe("Return base64 images inline instead of writing to disk"),
33212
- seed: external_exports.number().int().optional().describe("Seed for reproducible generation; random if omitted"),
33213
- thinking_level: external_exports.enum(["minimal", "high"]).optional().describe("Reasoning depth (Gemini 3 models); higher can help complex/structural edits"),
33214
- google_search: external_exports.boolean().optional().describe("Ground the image in live Google Search results (current events, weather, data)"),
33215
- from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)"),
33216
- timeout_ms: timeoutMsSchema,
33217
- idempotency_key: idempotencyKeySchema,
33218
- async: asyncSchema
33219
- };
33220
- async function resolveVideoInput(args) {
33221
- if (args.video_url && args.video_path) {
33222
- throw new McpToolError("Provide `video_url` OR `video_path`, not both.");
33421
+
33422
+ // src/client.ts
33423
+ init_dist();
33424
+ import { basename } from "node:path";
33425
+
33426
+ // src/session.ts
33427
+ var SessionState = class {
33428
+ /** Idempotency + async-handle registry for this session (issues #52/#53). */
33429
+ jobs = new JobRegistry();
33430
+ /**
33431
+ * The most recent interaction id THIS session created — what
33432
+ * `continue_last: true` resumes. In-memory only, so "last" means "last in
33433
+ * this session"; a restart falls back to the newest sidecar on disk.
33434
+ */
33435
+ lastInteractionId;
33436
+ /**
33437
+ * The same, for the media-specific tools, which keep their own chains.
33438
+ * `gemini_music_generate` IS served by the hosted connector, so this one
33439
+ * leaked across tenants exactly as `lastInteractionId` did; video is
33440
+ * stdio-only today but carries the identical hazard, so both are scoped here.
33441
+ */
33442
+ lastMusicInteractionId;
33443
+ lastVideoInteractionId;
33444
+ /**
33445
+ * Absolute paths of every image this session has written. Used to drop a
33446
+ * chained call's re-attached prior output: the interaction state already
33447
+ * contains that image, and re-sending it as a fresh reference anchors the
33448
+ * model against the requested edit.
33449
+ */
33450
+ writtenOutputs = /* @__PURE__ */ new Set();
33451
+ /** Forget everything. Tests use this; nothing in `src/` calls it. */
33452
+ reset() {
33453
+ this.jobs.reset();
33454
+ this.lastInteractionId = void 0;
33455
+ this.lastMusicInteractionId = void 0;
33456
+ this.lastVideoInteractionId = void 0;
33457
+ this.writtenOutputs.clear();
33223
33458
  }
33224
- if (!args.video_path) return { videoUrl: args.video_url };
33225
- const path = resolveVideoPath(args.video_path);
33226
- const uploaded = await client.uploadVideo(path, videoMimeType(args.video_path));
33227
- const videoFileMeta = { uri: uploaded.uri, name: uploaded.name };
33228
- if (uploaded.expirationTime) videoFileMeta.expires = uploaded.expirationTime;
33229
- return { videoUrl: uploaded.uri, videoMimeType: uploaded.mimeType, videoFileMeta };
33230
- }
33231
- var videoPathSchema = external_exports.string().optional().describe(
33232
- "Path to a local video file \u2014 uploaded to the Gemini Files API (~48h retention, 2 GB max) and used as the video reference. Alternative to video_url."
33233
- );
33234
- function pickSeed(seed) {
33235
- return seed ?? Math.floor(Math.random() * 2147483e3);
33459
+ };
33460
+
33461
+ // src/client.ts
33462
+ var BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
33463
+ var SERVICE = "Gemini";
33464
+ var INTERACTIONS_SERVICE = "Gemini Interactions";
33465
+ var REQUEST_TIMEOUT_MS = 6e4;
33466
+ var REQUEST_TIMEOUT_4K_MS = 12e4;
33467
+ function resolveTimeoutMs(perCallMs, imageSize) {
33468
+ if (perCallMs !== void 0 && perCallMs > 0) return perCallMs;
33469
+ const env = Number(readEnvVar("GEMINI_TIMEOUT_MS"));
33470
+ if (Number.isFinite(env) && env > 0) return env;
33471
+ return imageSize === "4K" ? REQUEST_TIMEOUT_4K_MS : REQUEST_TIMEOUT_MS;
33236
33472
  }
33237
- var HEARTBEAT_DEFAULT_MS = 1e4;
33238
- async function withProgressHeartbeat(extra, message, fn) {
33239
- const progressToken = extra?._meta?.progressToken;
33240
- const send = extra?.sendNotification;
33241
- const env = Number(readEnvVar("GEMINI_HEARTBEAT_MS"));
33242
- const intervalMs = Number.isFinite(env) ? env : HEARTBEAT_DEFAULT_MS;
33243
- const debug = !!readEnvVar("GEMINI_DEBUG");
33244
- const active = progressToken !== void 0 && !!send && intervalMs > 0;
33245
- if (debug) {
33246
- if (active) {
33247
- console.error(`[gemini-mcp] heartbeat active: progressToken present, interval=${intervalMs}ms \u2014 ${message}`);
33248
- } else {
33249
- const reason = progressToken === void 0 ? "no progressToken from host (host cannot extend its tools/call timeout; long calls will hit it)" : !send ? "host provided no sendNotification channel" : "heartbeat disabled (GEMINI_HEARTBEAT_MS=0)";
33250
- console.error(`[gemini-mcp] heartbeat inactive: ${reason} \u2014 ${message}`);
33251
- }
33473
+ var UPLOAD_BASE_URL = "https://generativelanguage.googleapis.com/upload/v1beta";
33474
+ var FILE_MAX_BYTES = 2 * 1024 ** 3;
33475
+ var FILE_POLL_INTERVAL_MS = 2e3;
33476
+ var FILE_POLL_MAX_ATTEMPTS = 150;
33477
+ var CHAIN_404_RETRY_BUDGET_MS = 12e4;
33478
+ var CHAIN_404_RETRY_BASE_MS = 2e3;
33479
+ var CHAIN_404_RETRY_MAX_SLEEP_MS = 3e4;
33480
+ function resolveChainRetryBudgetMs() {
33481
+ const env = Number(readEnvVar("GEMINI_CHAIN_RETRY_MS"));
33482
+ return Number.isFinite(env) && env >= 0 ? env : CHAIN_404_RETRY_BUDGET_MS;
33483
+ }
33484
+ var ChainedRequest404Error = class extends McpToolError {
33485
+ constructor(previousInteractionId, upstreamMessage, retries, opts) {
33486
+ super(
33487
+ `Gemini returned HTTP 404 for a chained request (previous_interaction_id: "${previousInteractionId}"), retried ${retries.attempts}\xD7 over ~${Math.round(retries.waitedMs / 1e3)}s for store lag. Upstream said: ${upstreamMessage}. This does not necessarily mean the interaction expired \u2014 an unknown model id or an expired files/\u2026 uri returns the same generic 404. (For reference: interactions are retained 55 days on the paid tier, 1 day on the free tier, and are scoped to the API key that created them.)`,
33488
+ opts
33489
+ );
33490
+ this.previousInteractionId = previousInteractionId;
33491
+ this.upstreamMessage = upstreamMessage;
33492
+ this.retries = retries;
33252
33493
  }
33253
- if (progressToken === void 0 || !send || intervalMs <= 0) return fn();
33254
- let ticks = 0;
33255
- const timer = setInterval(() => {
33256
- ticks++;
33257
- const elapsed = Math.round(ticks * intervalMs / 1e3);
33258
- if (debug) console.error(`[gemini-mcp] heartbeat: ${message} \u2014 still working (${elapsed}s)`);
33259
- void send({
33260
- method: "notifications/progress",
33261
- params: {
33262
- progressToken,
33263
- progress: ticks,
33264
- message: `${message} \u2014 still working (${elapsed}s)`
33265
- }
33266
- }).catch(() => {
33267
- });
33268
- }, intervalMs);
33269
- timer.unref?.();
33270
- try {
33271
- return await fn();
33272
- } finally {
33273
- clearInterval(timer);
33494
+ previousInteractionId;
33495
+ upstreamMessage;
33496
+ retries;
33497
+ };
33498
+ var GeminiClient = class {
33499
+ /**
33500
+ * A key handed in explicitly (the connector's per-session key). When absent
33501
+ * the key is read from the environment at REQUEST time, not here — see
33502
+ * `requireKey`.
33503
+ */
33504
+ explicitApiKey;
33505
+ apis = /* @__PURE__ */ new Map();
33506
+ fetchImpl;
33507
+ sleep;
33508
+ /**
33509
+ * The media destination for every tool this client is handed to. Public and
33510
+ * readonly: `tools/*` read `client.mediaSink` to decide disk-vs-URL output
33511
+ * AND whether disk-only *inputs* (local paths, clipboard, video upload) are
33512
+ * even available on this runtime.
33513
+ */
33514
+ mediaSink;
33515
+ /**
33516
+ * This client's session memory — job registry, last-interaction id, written
33517
+ * outputs. One client is one session: the hosted connector builds a client
33518
+ * per authenticated user, so hanging the state here is what keeps it from
33519
+ * leaking between tenants sharing an isolate (see src/session.ts). The stdio
33520
+ * server is single-user, so its one singleton client is one session.
33521
+ */
33522
+ session = new SessionState();
33523
+ constructor(opts = {}) {
33524
+ this.mediaSink = opts.mediaSink ?? createDiskSink();
33525
+ this.explicitApiKey = opts.apiKey?.trim() || void 0;
33526
+ this.fetchImpl = opts.fetchImpl ?? fetch;
33527
+ this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
33274
33528
  }
33275
- }
33276
- function timeoutRiskHint(opts) {
33277
- const proModel = /(?:^|[-_./])pro(?:[-_./]|$)/i.test(opts.model);
33278
- const fourK = opts.imageSize === "4K";
33279
- const multi = (opts.count ?? 1) > 1;
33280
- if (!proModel && !fourK && !multi) return void 0;
33281
- const why = [proModel && "Pro model", fourK && "4K output", multi && `count=${opts.count}`].filter(Boolean).join(" + ");
33282
- return `Slow config (${why}) can exceed a host's tools/call timeout (e.g. Claude Desktop ~30s, error -32001). If the host gives up mid-generation the image is still written to disk (and gemini_interact leaves an <image>.json sidecar with the interaction id) \u2014 no rerun needed, just look in the output dir. For a faster in-band result use a Flash model, drop image_size to 1K/2K, or \u2014 in Claude Code \u2014 set MCP_TOOL_TIMEOUT.`;
33283
- }
33284
- function buildMeta(model, seed, opts) {
33285
- const meta3 = { model, seed };
33286
- if (opts.aspect_ratio) meta3.aspect_ratio = opts.aspect_ratio;
33287
- if (opts.image_size) meta3.image_size = opts.image_size;
33288
- return meta3;
33289
- }
33290
- async function emitMedia(named, kind, opts, meta3, onWritten) {
33291
- const inline = opts.inline && kind !== "video";
33292
- if (inline) {
33293
- const blocks = named.map((n) => ({ type: kind, data: n.media.base64, mimeType: n.media.mimeType }));
33294
- if (meta3 && Object.keys(meta3).length > 0) {
33295
- return { content: [{ type: "text", text: JSON.stringify(meta3, null, 2) }, ...blocks] };
33529
+ /**
33530
+ * The `createApiClient` pair for one timeout budget, built lazily and
33531
+ * memoized per distinct timeout (a handful of values in practice). Both
33532
+ * share: the key in the `x-goog-api-key` header (Gemini does not use
33533
+ * `Authorization: Bearer`) and the default 429 retry (once after 2s — the
33534
+ * free tier rate-limits aggressively). `getToken` calls requireKey() so key
33535
+ * resolution stays at REQUEST time: the server boots without a key and the
33536
+ * actionable config error (with its aistudio hint) surfaces on the first
33537
+ * tool call, not at startup.
33538
+ */
33539
+ apisFor(timeoutMs) {
33540
+ let entry = this.apis.get(timeoutMs);
33541
+ if (!entry) {
33542
+ const shared = {
33543
+ tokenHeader: "x-goog-api-key",
33544
+ getToken: () => this.requireKey(),
33545
+ timeout: timeoutMs,
33546
+ fetchImpl: this.fetchImpl
33547
+ };
33548
+ entry = {
33549
+ api: createApiClient({ baseUrl: BASE_URL, serviceName: SERVICE, ...shared }),
33550
+ // Separate client for the Interactions API so errors name the right API.
33551
+ // The beta-era Api-Revision header is gone: the API went GA and requests
33552
+ // succeed without it (verified live 2026-07-06; see docs/GEMINI-API.md).
33553
+ // Don't re-add a pinned revision — it would freeze us on old semantics.
33554
+ interactionsApi: createApiClient({ baseUrl: BASE_URL, serviceName: INTERACTIONS_SERVICE, ...shared })
33555
+ };
33556
+ this.apis.set(timeoutMs, entry);
33296
33557
  }
33297
- return { content: blocks };
33558
+ return entry;
33298
33559
  }
33299
- const dir = resolveOutputDir(opts.output_dir);
33300
- const paths = [];
33301
- for (const n of named) paths.push(await writeMedia(dir, n.base, n.media.base64, n.media.mimeType));
33302
- await onWritten?.(paths);
33303
- const note = opts.inline && kind === "video" ? { inline_unsupported: "video has no MCP inline content type \u2014 written to disk instead" } : {};
33304
- return textResult({ [`${kind}s`]: paths, ...note, ...meta3 });
33305
- }
33306
- async function emit(named, opts, meta3, onWritten) {
33307
- return emitMedia(named.map((n) => ({ media: n.image, base: n.base })), "image", opts, meta3, onWritten);
33308
- }
33309
-
33310
- // src/jobs.ts
33311
- init_dist();
33312
- import { randomUUID, createHash } from "node:crypto";
33313
- var JOB_TTL_MS = 6e5;
33314
- var JOB_MAX = 64;
33315
- var jobs = /* @__PURE__ */ new Map();
33316
- var byKey = /* @__PURE__ */ new Map();
33317
- var runningByFingerprint = /* @__PURE__ */ new Map();
33318
- function isExpired(entry, now) {
33319
- return entry.settledAt !== void 0 && now - entry.settledAt > JOB_TTL_MS;
33320
- }
33321
- function evict(now) {
33322
- for (const [id, entry] of jobs) {
33323
- if (isExpired(entry, now)) remove(id, entry);
33560
+ /**
33561
+ * The API key for this request: an explicitly-injected key (the connector's
33562
+ * per-session key) else `$GEMINI_API_KEY`.
33563
+ *
33564
+ * Resolved HERE, at request time, not in the constructor — same as every
33565
+ * other env var this client reads. Two reasons:
33566
+ *
33567
+ * 1. It keeps the actionable config error deferred to the first tool call, so
33568
+ * the server still boots and answers a host's install-time `tools/list`
33569
+ * probe without a key. (That was already true; this preserves it.)
33570
+ * 2. It makes module-evaluation order irrelevant. `src/index.ts` loads `.env`
33571
+ * before serving, and reading the key in the constructor meant the
33572
+ * `client` singleton — constructed when `client.js` is first evaluated —
33573
+ * could latch "unset" before dotenv ran, depending on import ordering.
33574
+ * That is an invisible, silent failure (the key just stops working); not
33575
+ * depending on the order removes the hazard instead of documenting it.
33576
+ */
33577
+ requireKey() {
33578
+ const key = this.explicitApiKey || readEnvVar("GEMINI_API_KEY");
33579
+ if (!key) {
33580
+ throw new McpToolError("GEMINI_API_KEY environment variable is required", {
33581
+ hint: "Create a key at https://aistudio.google.com/apikey and set GEMINI_API_KEY in your MCP host env or .env"
33582
+ });
33583
+ }
33584
+ return key;
33324
33585
  }
33325
- if (jobs.size < JOB_MAX) return;
33326
- const settled = [...jobs.values()].filter((e) => e.settledAt !== void 0).sort((a, b) => (a.settledAt ?? 0) - (b.settledAt ?? 0));
33327
- for (const e of settled) {
33328
- if (jobs.size < JOB_MAX) break;
33329
- remove(e.jobId, e);
33586
+ /** The default model after env override (no per-call arg). */
33587
+ defaultModel() {
33588
+ return resolveModel(void 0, readEnvVar("GEMINI_IMAGE_MODEL"));
33330
33589
  }
33331
- }
33332
- function remove(id, entry) {
33333
- jobs.delete(id);
33334
- if (entry.idempotencyKey !== void 0 && byKey.get(entry.idempotencyKey) === id) byKey.delete(entry.idempotencyKey);
33335
- if (runningByFingerprint.get(entry.fingerprint) === id) runningByFingerprint.delete(entry.fingerprint);
33336
- }
33337
- function fingerprintRequest(toolName, parts) {
33338
- return createHash("sha256").update(`${toolName}\0${JSON.stringify(parts)}`).digest("hex");
33339
- }
33340
- function annotateReused(result, jobId) {
33341
- const content = result.content ?? [];
33342
- const first = content[0];
33343
- if (first && first.type === "text") {
33344
- let obj;
33345
- try {
33346
- obj = JSON.parse(first.text);
33347
- } catch {
33348
- obj = {};
33590
+ async call(method, path, body, timeoutMs) {
33591
+ const { api } = this.apisFor(timeoutMs ?? resolveTimeoutMs());
33592
+ return api.fetchJson(method, path, body !== void 0 ? { body } : {});
33593
+ }
33594
+ /**
33595
+ * Upload a local video to the Gemini Files API (resumable protocol) and wait
33596
+ * for it to become usable. Three live-verified steps (docs/GEMINI-API.md
33597
+ * "Files API — local video upload"):
33598
+ *
33599
+ * 1. `POST {UPLOAD_BASE_URL}/files` (command `start`) → the upload session
33600
+ * URL arrives in the `x-goog-upload-url` RESPONSE HEADER (body is empty).
33601
+ * 2. `POST <session url>` (command `upload, finalize`) with the bytes —
33602
+ * a file-backed Blob (`fileBlob`/`fs.openAsBlob`), so fetch STREAMS the
33603
+ * video from disk; it is never buffered in memory. Response: `{file:…}`.
33604
+ * 3. Poll `GET /v1beta/files/<id>` while `state` is `PROCESSING`, until
33605
+ * `ACTIVE` (the poll response is the File object UNWRAPPED).
33606
+ *
33607
+ * These two upload calls are raw `fetchImpl` (not `createApiClient`): step 1
33608
+ * needs the response *header* and step 2 posts a binary body — neither fits
33609
+ * `fetchJson`. The poll DOES go through the shared client (timeout + retry).
33610
+ * Uploads deliberately have no abort timeout: a multi-hundred-MB video can
33611
+ * legitimately take longer than any fixed budget.
33612
+ *
33613
+ * Returned files live ~48h (`expirationTime`); per-file cap is 2 GB
33614
+ * (enforced locally before any bytes are sent).
33615
+ */
33616
+ async uploadVideo(path, mimeType) {
33617
+ const key = this.requireKey();
33618
+ const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: "Video" });
33619
+ const startPath = "/files";
33620
+ const startRes = await this.fetchImpl(`${UPLOAD_BASE_URL}${startPath}`, {
33621
+ method: "POST",
33622
+ headers: {
33623
+ "x-goog-api-key": key,
33624
+ "X-Goog-Upload-Protocol": "resumable",
33625
+ "X-Goog-Upload-Command": "start",
33626
+ "X-Goog-Upload-Header-Content-Length": String(blob.size),
33627
+ "X-Goog-Upload-Header-Content-Type": mimeType,
33628
+ "Content-Type": "application/json"
33629
+ },
33630
+ body: JSON.stringify({ file: { display_name: basename(path) } })
33631
+ });
33632
+ if (!startRes.ok) {
33633
+ throw new McpToolError(
33634
+ formatApiError(startRes.status, "POST", `/upload/v1beta${startPath}`, await startRes.text(), { service: SERVICE })
33635
+ );
33349
33636
  }
33350
- obj.reused = true;
33351
- obj.reused_job_id = jobId;
33352
- return { ...result, content: [{ ...first, text: JSON.stringify(obj, null, 2) }, ...content.slice(1)] };
33353
- }
33354
- return {
33355
- ...result,
33356
- content: [{ type: "text", text: JSON.stringify({ reused: true, reused_job_id: jobId }, null, 2) }, ...content]
33357
- };
33358
- }
33359
- function jobHandle(jobId, status) {
33360
- return textResult({
33361
- job_id: jobId,
33362
- status,
33363
- hint: `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`
33364
- });
33365
- }
33366
- async function dispatch(opts, work) {
33367
- const { toolName, fingerprint, idempotencyKey, async } = opts;
33368
- const now = Date.now();
33369
- let hit;
33370
- if (idempotencyKey !== void 0) {
33371
- const id = byKey.get(idempotencyKey);
33372
- const entry2 = id ? jobs.get(id) : void 0;
33373
- if (entry2 && entry2.status !== "failed" && !isExpired(entry2, now)) hit = entry2;
33374
- } else {
33375
- const id = runningByFingerprint.get(fingerprint);
33376
- const entry2 = id ? jobs.get(id) : void 0;
33377
- if (entry2 && entry2.status === "running") hit = entry2;
33378
- }
33379
- if (hit) {
33380
- if (async) return jobHandle(hit.jobId, hit.status);
33381
- return annotateReused(await hit.promise, hit.jobId);
33382
- }
33383
- const jobId = randomUUID();
33384
- evict(now);
33385
- const promise2 = work();
33386
- const entry = { jobId, toolName, fingerprint, idempotencyKey, status: "running", promise: promise2, createdAt: now };
33387
- jobs.set(jobId, entry);
33388
- if (idempotencyKey !== void 0) byKey.set(idempotencyKey, jobId);
33389
- runningByFingerprint.set(fingerprint, jobId);
33390
- promise2.then(
33391
- (res) => {
33392
- entry.status = "done";
33393
- entry.result = res;
33394
- entry.settledAt = Date.now();
33395
- },
33396
- (err) => {
33397
- entry.status = "failed";
33398
- entry.error = { message: err instanceof Error ? err.message : String(err), hint: err?.hint };
33399
- entry.settledAt = Date.now();
33637
+ const uploadUrl = startRes.headers.get("x-goog-upload-url");
33638
+ if (!uploadUrl) {
33639
+ throw new McpToolError(`${SERVICE} upload start did not return an x-goog-upload-url header`, {
33640
+ hint: "The Files API resumable-upload contract may have changed \u2014 see docs/GEMINI-API.md."
33641
+ });
33400
33642
  }
33401
- ).finally(() => {
33402
- if (runningByFingerprint.get(fingerprint) === jobId) runningByFingerprint.delete(fingerprint);
33403
- });
33404
- if (async) return jobHandle(jobId, "running");
33405
- return promise2;
33406
- }
33407
- function getJobResult(jobId) {
33408
- const entry = jobs.get(jobId);
33409
- if (!entry) {
33410
- throw new McpToolError(`No job "${jobId}" \u2014 unknown or expired.`, {
33411
- hint: "Jobs are per-process and evicted ~10 min after completion (or on server restart). If the generation likely finished, check the output dir / <image>.json sidecar instead of polling."
33643
+ const upRes = await this.fetchImpl(uploadUrl, {
33644
+ method: "POST",
33645
+ headers: {
33646
+ "X-Goog-Upload-Offset": "0",
33647
+ "X-Goog-Upload-Command": "upload, finalize"
33648
+ },
33649
+ body: blob
33412
33650
  });
33651
+ if (!upRes.ok) {
33652
+ throw new McpToolError(
33653
+ formatApiError(upRes.status, "POST", "/upload/v1beta/files (finalize)", await upRes.text(), { service: SERVICE })
33654
+ );
33655
+ }
33656
+ let file2 = (await upRes.json()).file ?? {};
33657
+ const name = file2.name ?? "";
33658
+ if (!/^files\/[\w-]+$/.test(name)) {
33659
+ throw new McpToolError(`${SERVICE} upload returned an unexpected file name: ${name || "(none)"}`);
33660
+ }
33661
+ for (let attempt = 0; file2.state === "PROCESSING"; attempt++) {
33662
+ if (attempt >= FILE_POLL_MAX_ATTEMPTS) {
33663
+ throw new McpToolError(`${SERVICE} video file ${name} is still processing after ${FILE_POLL_INTERVAL_MS * FILE_POLL_MAX_ATTEMPTS / 6e4} minutes`, {
33664
+ hint: "Very long videos can take a while to process \u2014 retry later by passing the file uri as video_url."
33665
+ });
33666
+ }
33667
+ await this.sleep(FILE_POLL_INTERVAL_MS);
33668
+ file2 = await this.call("GET", `/${name}`);
33669
+ }
33670
+ if (file2.state !== "ACTIVE") {
33671
+ throw new McpToolError(
33672
+ `${SERVICE} video file processing failed (state ${file2.state ?? "unknown"})${file2.error?.message ? `: ${file2.error.message}` : ""}`,
33673
+ { hint: "Check the video is a supported format/codec and under 2 GB." }
33674
+ );
33675
+ }
33676
+ if (!file2.uri) {
33677
+ throw new McpToolError(`${SERVICE} did not return a uri for uploaded file ${name}`);
33678
+ }
33679
+ return { name, uri: file2.uri, mimeType: file2.mimeType ?? mimeType, expirationTime: file2.expirationTime };
33413
33680
  }
33414
- if (entry.status === "running") return jobHandle(jobId, "running");
33415
- if (entry.status === "failed") {
33416
- throw new McpToolError(entry.error?.message ?? "Job failed.", entry.error?.hint ? { hint: entry.error.hint } : void 0);
33681
+ async listModels() {
33682
+ const data = await this.call("GET", "/models?pageSize=200");
33683
+ return filterImageModels(data.models ?? []);
33417
33684
  }
33418
- if (!entry.result) throw new McpToolError(`Job "${jobId}" completed without a recorded result.`);
33419
- return entry.result;
33420
- }
33421
-
33422
- // src/tools/_confirm.ts
33423
- init_dist();
33424
- function previewUnlessConfirmed(confirm, action, method, path, body) {
33425
- if (confirm === true) return null;
33426
- return textResult({
33427
- dryRun: true,
33428
- action,
33429
- method,
33430
- path,
33431
- ...body !== void 0 ? { willSend: body } : {},
33432
- note: "Re-run with confirm: true to execute."
33433
- });
33434
- }
33435
- async function previewLocalInputsUnlessConfirmed(confirm, action, endpoint, imagePaths, videoPath) {
33436
- const hasLocal = (imagePaths?.length ?? 0) > 0 || Boolean(videoPath);
33437
- if (confirm === true || !hasLocal) return null;
33438
- const inputs = [];
33439
- for (const p of imagePaths ?? []) inputs.push(await previewImageInput(p));
33440
- if (videoPath) inputs.push(await previewVideoInput(videoPath));
33441
- return previewUnlessConfirmed(confirm, action, "POST", endpoint, { inputs });
33442
- }
33443
-
33444
- // src/tools/generate.ts
33445
- var GENERATE_ENDPOINT = "/v1beta/models/{model}:generateContent";
33446
- var SEND_LOCAL_ACTION = "Send local file input(s) to the Gemini API";
33447
- var REFINE_HINT = "To iteratively refine this image, use gemini_interact (pass the output file via `images` on the first call, then chain `previous_interaction_id` on each subsequent call).";
33448
- function registerGenerateTools(server) {
33449
- server.registerTool(
33450
- "gemini_image_generate",
33451
- {
33452
- description: "Generate image(s) from a text prompt with a Gemini image model (Nano Banana / Nano Banana Pro). If the result will likely be refined iteratively, prefer gemini_interact (multi-turn) as the entry point.",
33453
- annotations: { readOnlyHint: false, openWorldHint: true },
33454
- inputSchema: {
33455
- prompt: external_exports.string().min(1).describe("Text prompt describing the image"),
33456
- count: external_exports.number().int().positive().max(8).optional().describe("Number of independent images (default 1)"),
33457
- filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
33458
- images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to reference input images (image-conditioned generation)"),
33459
- images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs"),
33460
- video_url: external_exports.string().url().optional().describe("Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video\u2192image; use a Flash model e.g. gemini-3.1-flash-image)"),
33461
- video_path: videoPathSchema,
33462
- confirm: schemaConfirm,
33463
- ...sharedImageSchema
33685
+ async generate(opts) {
33686
+ const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33687
+ const parts = [{ text: opts.prompt }];
33688
+ for (const img of opts.images ?? []) {
33689
+ parts.push({ inline_data: { mime_type: img.mimeType, data: img.base64 } });
33690
+ }
33691
+ if (opts.videoUrl) {
33692
+ parts.push({ file_data: { file_uri: opts.videoUrl, mime_type: opts.videoMimeType ?? "video/mp4" } });
33693
+ }
33694
+ const generationConfig = { responseModalities: ["TEXT", "IMAGE"] };
33695
+ if (opts.aspectRatio || opts.imageSize) {
33696
+ const imageConfig = {};
33697
+ if (opts.aspectRatio) imageConfig.aspectRatio = opts.aspectRatio;
33698
+ if (opts.imageSize) imageConfig.imageSize = opts.imageSize;
33699
+ generationConfig.imageConfig = imageConfig;
33700
+ }
33701
+ if (opts.seed !== void 0) generationConfig.seed = opts.seed;
33702
+ if (opts.thinkingLevel !== void 0) generationConfig.thinkingConfig = { thinkingLevel: opts.thinkingLevel };
33703
+ const requestBody = { contents: [{ parts }], generationConfig };
33704
+ if (opts.googleSearch) requestBody.tools = [{ google_search: {} }];
33705
+ const data = await this.call(
33706
+ "POST",
33707
+ `/models/${model}:generateContent`,
33708
+ requestBody,
33709
+ resolveTimeoutMs(opts.timeoutMs, opts.imageSize)
33710
+ );
33711
+ const images = [];
33712
+ const textParts = [];
33713
+ let groundingMeta;
33714
+ for (const cand of data.candidates ?? []) {
33715
+ for (const part of cand.content?.parts ?? []) {
33716
+ const inline = part.inline_data ?? part.inlineData;
33717
+ if (inline?.data) {
33718
+ images.push({ base64: inline.data, mimeType: inline.mime_type ?? inline.mimeType ?? "image/jpeg" });
33719
+ } else if (typeof part.text === "string" && part.text.trim()) {
33720
+ textParts.push(part.text);
33721
+ }
33464
33722
  }
33465
- },
33466
- async (args, extra) => {
33467
- const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images, args.video_path);
33468
- if (gate) return gate;
33469
- const count = args.count ?? 1;
33470
- const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33471
- const fingerprint = fingerprintRequest("gemini_image_generate", {
33472
- model,
33473
- prompt: args.prompt,
33474
- aspect_ratio: args.aspect_ratio,
33475
- image_size: args.image_size,
33476
- thinking_level: args.thinking_level,
33477
- google_search: args.google_search,
33478
- count,
33479
- images: args.images,
33480
- images_base64: args.images_base64,
33481
- from_clipboard: args.from_clipboard,
33482
- video_url: args.video_url,
33483
- video_path: args.video_path
33723
+ if (cand.groundingMetadata) groundingMeta = cand.groundingMetadata;
33724
+ }
33725
+ if (images.length === 0) {
33726
+ throw new McpToolError(`${SERVICE} returned no image`, {
33727
+ hint: "The request may have been blocked by safety filters \u2014 try rephrasing the prompt."
33484
33728
  });
33485
- return dispatch({ toolName: "gemini_image_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33486
- const seed = pickSeed(args.seed);
33487
- const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33488
- const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33489
- const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args));
33490
- const named = [];
33491
- let capturedText;
33492
- let capturedGrounding;
33493
- await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : "image"} (${model})`, async () => {
33494
- for (let i = 0; i < count; i++) {
33495
- const result = await client.generate({
33496
- prompt: args.prompt,
33497
- images: refInputs.length > 0 ? refInputs : void 0,
33498
- model: args.model,
33499
- aspectRatio: args.aspect_ratio,
33500
- imageSize: args.image_size,
33501
- seed: seed + i,
33502
- thinkingLevel: args.thinking_level,
33503
- googleSearch: args.google_search,
33504
- videoUrl: video.videoUrl,
33505
- videoMimeType: video.videoMimeType,
33506
- timeoutMs: args.timeout_ms
33507
- });
33508
- const { images: [img], text } = result;
33509
- if (text && !capturedText) capturedText = text;
33510
- if (result.grounding && !capturedGrounding) capturedGrounding = result.grounding;
33511
- named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, "0")}` });
33512
- }
33513
- });
33514
- const meta3 = buildMeta(model, seed, args);
33515
- if (capturedText) meta3.text = capturedText;
33516
- if (capturedGrounding) meta3.grounding = capturedGrounding;
33517
- if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
33518
- meta3.hint = REFINE_HINT;
33519
- const risk = timeoutRiskHint({ model, imageSize: args.image_size, count });
33520
- if (risk) meta3.timeout_risk = risk;
33521
- return emit(named, args, meta3);
33729
+ }
33730
+ const text = textParts.join("\n") || void 0;
33731
+ let grounding;
33732
+ if (groundingMeta) {
33733
+ const queries = (groundingMeta.webSearchQueries ?? []).filter(Boolean);
33734
+ const sources = (groundingMeta.groundingChunks ?? []).map((c) => c.web).filter((w) => !!w);
33735
+ if (queries.length > 0 || sources.length > 0) {
33736
+ grounding = {};
33737
+ if (queries.length > 0) grounding.queries = queries;
33738
+ if (sources.length > 0) grounding.sources = sources;
33739
+ }
33740
+ }
33741
+ return { images, text, grounding };
33742
+ }
33743
+ async interact(opts) {
33744
+ const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33745
+ const inputParts = [{ type: "text", text: opts.input }];
33746
+ for (const img of opts.images ?? []) {
33747
+ inputParts.push({ type: "image", mime_type: img.mimeType, data: img.base64 });
33748
+ }
33749
+ if (opts.videoUrl) {
33750
+ inputParts.push({ type: "video", uri: opts.videoUrl, mime_type: opts.videoMimeType ?? "video/mp4" });
33751
+ }
33752
+ const responseFormat = { type: "image", mime_type: "image/jpeg" };
33753
+ if (opts.aspectRatio) responseFormat.aspect_ratio = opts.aspectRatio;
33754
+ if (opts.imageSize) responseFormat.image_size = opts.imageSize;
33755
+ const body = { model, input: inputParts, response_format: responseFormat };
33756
+ if (opts.thinkingLevel !== void 0) body.generation_config = { thinking_level: opts.thinkingLevel };
33757
+ if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
33758
+ if (opts.googleSearch || opts.searchTypes?.length) {
33759
+ const searchTool = { type: "google_search" };
33760
+ if (opts.searchTypes?.length) searchTool.search_types = opts.searchTypes;
33761
+ body.tools = [searchTool];
33762
+ }
33763
+ const data = await this.postInteraction(body, opts);
33764
+ const { images, text, grounding } = this.extractInteraction(data, opts.searchTypes);
33765
+ if (images.length === 0) {
33766
+ throw new McpToolError("Gemini returned no image", {
33767
+ hint: "The request may have been blocked by safety filters \u2014 try rephrasing the prompt."
33768
+ });
33769
+ }
33770
+ return { id: data.id, images, text, grounding };
33771
+ }
33772
+ /**
33773
+ * Generate video via the omni model (Interactions API, inline delivery). Same
33774
+ * plumbing as {@link interact} — text/image input parts, a `video`
33775
+ * response_format, optional `video_config.task`, and `previous_interaction_id`
33776
+ * for edits. Preview model: shapes are docs-derived; parsing stays tolerant.
33777
+ */
33778
+ async generateVideo(opts) {
33779
+ const model = opts.model ?? DEFAULT_VIDEO_MODEL;
33780
+ const inputParts = [{ type: "text", text: opts.input }];
33781
+ for (const img of opts.images ?? []) inputParts.push({ type: "image", mime_type: img.mimeType, data: img.base64 });
33782
+ const responseFormat = { type: "video", delivery: "inline" };
33783
+ if (opts.aspectRatio) responseFormat.aspect_ratio = opts.aspectRatio;
33784
+ const body = { model, input: inputParts, response_format: responseFormat };
33785
+ if (opts.task) body.generation_config = { video_config: { task: opts.task } };
33786
+ if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
33787
+ const data = await this.postInteraction(body, opts);
33788
+ const { videos, text } = this.extractInteraction(data);
33789
+ if (videos.length === 0) {
33790
+ throw new McpToolError("Gemini returned no video", {
33791
+ hint: "The request may have been blocked by a safety filter, or the clip exceeded the ~4MB inline limit \u2014 try a shorter/simpler prompt or a different aspect ratio."
33522
33792
  });
33523
33793
  }
33524
- );
33525
- server.registerTool(
33526
- "gemini_image_edit",
33527
- {
33528
- description: "Edit or compose images: provide one or more input images (paths or base64), plus a text instruction. For a SERIES of successive edits to the same image, prefer gemini_interact (multi-turn) \u2014 it keeps edit context and avoids re-processing the full image each round; use gemini_image_edit for one-off edits or composing multiple distinct inputs. Gemini over-preserves the input; there is no edit-strength control \u2014 for large structural changes, reroll with a different `seed` or more forceful wording.",
33529
- annotations: { readOnlyHint: false, openWorldHint: true },
33530
- inputSchema: {
33531
- prompt: external_exports.string().min(1).describe("Instruction describing the edit or composition"),
33532
- images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to input image file(s) (1 = edit, 2+ = compose)"),
33533
- images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Input images as base64 strings or data URIs"),
33534
- filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
33535
- confirm: schemaConfirm,
33536
- ...sharedImageSchema
33537
- }
33538
- },
33539
- async (args, extra) => {
33540
- const hasPaths = (args.images?.length ?? 0) > 0;
33541
- const hasBase64 = (args.images_base64?.length ?? 0) > 0;
33542
- if (!hasPaths && !hasBase64 && !args.from_clipboard) {
33543
- throw new McpToolError("Provide at least one input image via `images`, `images_base64`, or `from_clipboard`.");
33544
- }
33545
- const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
33546
- if (gate) return gate;
33547
- const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33548
- const fingerprint = fingerprintRequest("gemini_image_edit", {
33549
- model,
33550
- prompt: args.prompt,
33551
- aspect_ratio: args.aspect_ratio,
33552
- image_size: args.image_size,
33553
- thinking_level: args.thinking_level,
33554
- google_search: args.google_search,
33555
- images: args.images,
33556
- images_base64: args.images_base64,
33557
- from_clipboard: args.from_clipboard
33558
- });
33559
- return dispatch({ toolName: "gemini_image_edit", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33560
- const seed = pickSeed(args.seed);
33561
- const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33562
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33563
- const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
33564
- prompt: args.prompt,
33565
- images: inputs,
33566
- model: args.model,
33567
- aspectRatio: args.aspect_ratio,
33568
- imageSize: args.image_size,
33569
- seed,
33570
- thinkingLevel: args.thinking_level,
33571
- googleSearch: args.google_search,
33572
- timeoutMs: args.timeout_ms
33573
- }));
33574
- const { images: [img], text } = result;
33575
- const meta3 = buildMeta(model, seed, args);
33576
- if (text) meta3.text = text;
33577
- if (result.grounding) meta3.grounding = result.grounding;
33578
- meta3.hint = REFINE_HINT;
33579
- const risk = timeoutRiskHint({ model, imageSize: args.image_size });
33580
- if (risk) meta3.timeout_risk = risk;
33581
- return emit([{ image: img, base: slug }], args, meta3);
33794
+ return { id: data.id, videos, text };
33795
+ }
33796
+ /**
33797
+ * Generate music via a Lyria model (Interactions API, inline audio). Reuses
33798
+ * the interact plumbing with an `audio` response_format. Preview model.
33799
+ */
33800
+ async generateMusic(opts) {
33801
+ const model = opts.model ?? DEFAULT_MUSIC_MODEL;
33802
+ const inputParts = [{ type: "text", text: opts.input }];
33803
+ for (const img of opts.images ?? []) inputParts.push({ type: "image", mime_type: img.mimeType, data: img.base64 });
33804
+ const responseFormat = { type: "audio" };
33805
+ if (opts.audioFormat) responseFormat.audio_format = opts.audioFormat;
33806
+ const body = { model, input: inputParts, response_format: responseFormat };
33807
+ if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
33808
+ const data = await this.postInteraction(body, opts);
33809
+ const { audios, text } = this.extractInteraction(data);
33810
+ if (audios.length === 0) {
33811
+ throw new McpToolError("Gemini returned no audio", {
33812
+ hint: "The request may have been blocked by a safety filter \u2014 try rephrasing the prompt."
33582
33813
  });
33583
33814
  }
33584
- );
33585
- }
33586
-
33587
- // src/tools/set.ts
33588
- init_zod();
33589
- init_dist();
33590
- function registerSetTools(server) {
33591
- server.registerTool(
33592
- "gemini_image_set",
33593
- {
33594
- description: "Generate a consistent SET of images: a master image from master_prompt, then one image per scene that references the master so the subject/style stays consistent. Provide `scenes` (explicit per-image prompts) OR `count` (variations of the master).",
33595
- annotations: { readOnlyHint: false, openWorldHint: true },
33596
- inputSchema: {
33597
- master_prompt: external_exports.string().min(1).describe("Prompt for the master/reference image"),
33598
- scenes: external_exports.array(external_exports.string().min(1)).min(1).max(8).optional().describe("Per-image prompts (1-8); each references the master"),
33599
- count: external_exports.number().int().positive().max(8).optional().describe("Number of variations of master_prompt (when scenes omitted)"),
33600
- reference_mode: external_exports.enum(["master", "chain"]).optional().describe("master: every image references the master (default). chain: each references the previous."),
33601
- basename: external_exports.string().optional().describe("Base filename prefix for output images (default: slugified master_prompt)"),
33602
- master_images: external_exports.array(external_exports.string().min(1)).optional().describe("Reference image paths passed to the master generation call"),
33603
- master_images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs for master generation"),
33604
- confirm: schemaConfirm,
33605
- ...sharedImageSchema
33815
+ return { id: data.id, audios, text };
33816
+ }
33817
+ /** POST `/interactions` with the chained-404 retry. Shared by all media. */
33818
+ async postInteraction(body, opts) {
33819
+ const { interactionsApi } = this.apisFor(resolveTimeoutMs(opts.timeoutMs, opts.imageSize));
33820
+ const budgetMs = resolveChainRetryBudgetMs();
33821
+ let waitedMs = 0;
33822
+ for (let attempt = 0; ; attempt++) {
33823
+ try {
33824
+ return await interactionsApi.fetchJson("POST", "/interactions", { body });
33825
+ } catch (err) {
33826
+ if (!(opts.previousInteractionId && err instanceof ApiError && err.status === 404)) throw err;
33827
+ const delay = Math.min(CHAIN_404_RETRY_BASE_MS * 2 ** attempt, CHAIN_404_RETRY_MAX_SLEEP_MS);
33828
+ if (waitedMs + delay <= budgetMs) {
33829
+ waitedMs += delay;
33830
+ await this.sleep(delay);
33831
+ continue;
33832
+ }
33833
+ throw new ChainedRequest404Error(opts.previousInteractionId, err.message, { attempts: attempt, waitedMs }, {
33834
+ hint: "Re-issue the request WITHOUT previous_interaction_id (re-attaching the prior output image if you were editing). If that also 404s, the interaction id is not the cause \u2014 check the model id and any files/\u2026 uri (they expire ~48h). gemini_interact does this probe automatically.",
33835
+ cause: err
33836
+ });
33606
33837
  }
33607
- },
33608
- async (args, extra) => {
33609
- if (args.scenes && args.count || !args.scenes && !args.count) {
33610
- throw new McpToolError("Provide exactly one of `scenes` or `count`.");
33838
+ }
33839
+ }
33840
+ /**
33841
+ * Collect `model_output` media (image/video/audio) + text + grounding from an
33842
+ * interactions response. Only the `model_output` step is surfaced — `thought`
33843
+ * steps (internal reasoning / draft images) are dropped on purpose so they
33844
+ * can't leak into text or pollute the outputs. Tolerant of snake/camel MIME.
33845
+ */
33846
+ extractInteraction(data, searchTypes) {
33847
+ const images = [];
33848
+ const videos = [];
33849
+ const audios = [];
33850
+ const textParts = [];
33851
+ const searchQueries = [];
33852
+ const searchSuggestions = /* @__PURE__ */ new Set();
33853
+ for (const step of data.steps ?? []) {
33854
+ if (step.type === "google_search_call") {
33855
+ for (const q of step.arguments?.queries ?? []) if (q?.trim()) searchQueries.push(q);
33856
+ continue;
33611
33857
  }
33612
- const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local master image input(s) to the Gemini API", "/v1beta/models/{model}:generateContent", args.master_images);
33613
- if (gate) return gate;
33614
- const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33615
- const fingerprint = fingerprintRequest("gemini_image_set", {
33616
- model,
33617
- master_prompt: args.master_prompt,
33618
- scenes: args.scenes,
33619
- count: args.count,
33620
- reference_mode: args.reference_mode,
33621
- aspect_ratio: args.aspect_ratio,
33622
- image_size: args.image_size,
33623
- thinking_level: args.thinking_level,
33624
- google_search: args.google_search,
33625
- master_images: args.master_images,
33626
- master_images_base64: args.master_images_base64,
33627
- from_clipboard: args.from_clipboard
33628
- });
33629
- return dispatch({ toolName: "gemini_image_set", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33630
- const seed = pickSeed(args.seed);
33631
- const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
33632
- const slug = args.basename ? baseName(args.basename) : slugify2(args.master_prompt);
33633
- const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
33634
- const named = [];
33635
- const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
33636
- const result = await client.generate({
33637
- prompt: args.master_prompt,
33638
- images: masterRefInputs.length > 0 ? masterRefInputs : void 0,
33639
- seed,
33640
- ...cfg
33641
- });
33642
- const master = result.images[0];
33643
- named.push({ image: master, base: `${slug}-master` });
33644
- const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
33645
- const mode = args.reference_mode ?? "master";
33646
- if (mode === "chain") {
33647
- let ref = master;
33648
- for (let i = 0; i < scenePrompts.length; i++) {
33649
- const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
33650
- named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` });
33651
- ref = img;
33652
- }
33653
- } else {
33654
- const scenes = await Promise.all(
33655
- scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
33656
- );
33657
- scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` }));
33858
+ if (step.type === "google_search_result") {
33859
+ if (searchTypes?.includes("image_search")) {
33860
+ for (const r of step.result ?? []) if (r.search_suggestions?.trim()) searchSuggestions.add(r.search_suggestions);
33861
+ }
33862
+ continue;
33863
+ }
33864
+ if (step.type !== "model_output") continue;
33865
+ for (const parts of [step.content ?? [], step.summary ?? []]) {
33866
+ for (const part of parts) {
33867
+ const mime = part.mime_type ?? part.mimeType;
33868
+ if (part.data) {
33869
+ if (part.type === "image") images.push({ base64: part.data, mimeType: mime ?? "image/jpeg" });
33870
+ else if (part.type === "video") videos.push({ base64: part.data, mimeType: mime ?? "video/mp4" });
33871
+ else if (part.type === "audio") audios.push({ base64: part.data, mimeType: mime ?? "audio/mpeg" });
33872
+ } else if (part.type === "text" && part.text?.trim()) {
33873
+ textParts.push(part.text);
33658
33874
  }
33659
- return result;
33660
- });
33661
- const masterText = masterResult.text;
33662
- const meta3 = buildMeta(model, seed, args);
33663
- if (masterText) meta3.text = masterText;
33664
- if (masterResult.grounding) meta3.grounding = masterResult.grounding;
33665
- const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length });
33666
- if (risk) meta3.timeout_risk = risk;
33667
- return emit(named, args, meta3);
33875
+ }
33876
+ }
33877
+ }
33878
+ let grounding;
33879
+ if (searchQueries.length > 0 || searchSuggestions.size > 0) {
33880
+ grounding = {};
33881
+ if (searchQueries.length > 0) grounding.queries = searchQueries;
33882
+ if (searchSuggestions.size > 0) grounding.search_suggestions = [...searchSuggestions];
33883
+ }
33884
+ return { images, videos, audios, text: textParts.join("\n") || void 0, grounding };
33885
+ }
33886
+ };
33887
+ var client = new GeminiClient();
33888
+
33889
+ // src/tools/interact.ts
33890
+ init_images();
33891
+
33892
+ // src/sidecar.ts
33893
+ import { readdir, readFile as readFile3, stat as stat2 } from "node:fs/promises";
33894
+ import { existsSync as existsSync2 } from "node:fs";
33895
+ import { join as join4 } from "node:path";
33896
+ var SIDECAR_SUFFIX = ".json";
33897
+ async function readSidecars(dir) {
33898
+ let entries;
33899
+ try {
33900
+ entries = await readdir(dir);
33901
+ } catch {
33902
+ return [];
33903
+ }
33904
+ const records = [];
33905
+ for (const entry of entries) {
33906
+ if (!entry.endsWith(SIDECAR_SUFFIX)) continue;
33907
+ const sidecarPath = join4(dir, entry);
33908
+ try {
33909
+ const parsed = JSON.parse(await readFile3(sidecarPath, "utf8"));
33910
+ if (typeof parsed !== "object" || parsed === null) continue;
33911
+ const { interaction_id: interactionId, images } = parsed;
33912
+ if (typeof interactionId !== "string" || !interactionId) continue;
33913
+ const paths = Array.isArray(images) ? images.filter((p) => typeof p === "string") : [];
33914
+ records.push({
33915
+ sidecarPath,
33916
+ interactionId,
33917
+ images: paths.filter((p) => existsSync2(p)),
33918
+ mtimeMs: (await stat2(sidecarPath)).mtimeMs
33668
33919
  });
33920
+ } catch {
33921
+ continue;
33669
33922
  }
33670
- );
33923
+ }
33924
+ return records.sort((a, b) => b.mtimeMs - a.mtimeMs);
33925
+ }
33926
+ async function findInteractionImages(dir, interactionId) {
33927
+ const records = await readSidecars(dir);
33928
+ return records.find((r) => r.interactionId === interactionId)?.images ?? [];
33929
+ }
33930
+ async function latestInteractionId(dir) {
33931
+ return (await readSidecars(dir))[0]?.interactionId;
33671
33932
  }
33672
33933
 
33673
33934
  // src/tools/interact.ts
33674
- init_zod();
33675
- init_dist();
33676
- var lastInteractionId;
33677
- var writtenOutputs = /* @__PURE__ */ new Set();
33678
33935
  var NEW_REFERENCES_ONLY = "NEW reference images only (e.g. a style or target photo). When chaining with previous_interaction_id, do NOT re-attach the prior turn's output \u2014 the interaction already contains it, and re-sending it anchors the model against your edit.";
33679
- function splitReattachedOutputs(images) {
33936
+ function splitReattachedOutputs(images, writtenOutputs) {
33680
33937
  const kept = [];
33681
33938
  const dropped = [];
33682
33939
  for (const p of images) {
@@ -33689,16 +33946,18 @@ function splitReattachedOutputs(images) {
33689
33946
  }
33690
33947
  return { kept, dropped };
33691
33948
  }
33692
- function registerInteractTools(server) {
33949
+ function registerInteractTools(server, client2) {
33950
+ const onDiskRuntime = client2.mediaSink?.persistsFiles ?? true;
33951
+ 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. ";
33693
33952
  server.registerTool(
33694
33953
  "gemini_interact",
33695
33954
  {
33696
- description: "Preferred tool for iterative or multi-step refinement of a single image \u2014 multi-turn generation/editing via Gemini's Interactions API. To refine, capture the returned interaction `id` and pass it as `previous_interaction_id` on the next call \u2014 do NOT start a new interaction or re-upload the image for each tweak. `continue_last: true` chains from this session's most recent interaction without threading the id. 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). Output is JPEG.",
33955
+ description: "Preferred tool for iterative or multi-step refinement of a single image \u2014 multi-turn generation/editing via Gemini's Interactions API. To refine, capture the returned interaction `id` and pass it as `previous_interaction_id` on the next call \u2014 do NOT start a new interaction or re-upload the image for each tweak. `continue_last: true` chains from this session's most recent interaction without threading the id. " + recoveryDescription + "Output is JPEG.",
33697
33956
  annotations: { readOnlyHint: false, openWorldHint: true },
33698
33957
  inputSchema: {
33699
33958
  input: external_exports.string().min(1).describe("Text prompt or editing instruction"),
33700
33959
  previous_interaction_id: external_exports.string().optional().describe("ID from a prior gemini_interact call \u2014 continues that multi-turn conversation"),
33701
- continue_last: external_exports.boolean().optional().describe("Continue from the most recent interaction this server created (convenience for previous_interaction_id; an explicit id wins)"),
33960
+ continue_last: external_exports.boolean().optional().describe("Continue from the most recent interaction this server created (convenience for previous_interaction_id; an explicit id wins). Survives a server restart by falling back to the newest <image>.json sidecar in the output dir."),
33702
33961
  images: external_exports.array(external_exports.string().min(1)).optional().describe(`Paths to reference input images. ${NEW_REFERENCES_ONLY}`),
33703
33962
  images_base64: external_exports.array(external_exports.string().min(1)).optional().describe(`Reference images as base64 strings or data URIs. ${NEW_REFERENCES_ONLY}`),
33704
33963
  model: external_exports.string().optional().describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
@@ -33722,19 +33981,26 @@ function registerInteractTools(server) {
33722
33981
  }
33723
33982
  },
33724
33983
  async (args, extra) => {
33984
+ assertLocalInputsAvailable(client2.mediaSink, args);
33985
+ const onDisk = client2.mediaSink?.persistsFiles ?? true;
33725
33986
  let previousInteractionId = args.previous_interaction_id;
33987
+ let continuedFromSidecar = false;
33726
33988
  if (!previousInteractionId && args.continue_last) {
33727
- if (!lastInteractionId) {
33989
+ previousInteractionId = client2.session.lastInteractionId;
33990
+ if (!previousInteractionId && onDisk) {
33991
+ previousInteractionId = await latestInteractionId(resolveOutputDir(args.output_dir));
33992
+ continuedFromSidecar = previousInteractionId !== void 0;
33993
+ }
33994
+ if (!previousInteractionId) {
33728
33995
  throw new McpToolError("No previous interaction to continue in this session.", {
33729
- hint: "Call gemini_interact once without continue_last first, or pass an explicit previous_interaction_id."
33996
+ hint: onDisk ? "Call gemini_interact once without continue_last first, or pass an explicit previous_interaction_id." : "Call gemini_interact once without continue_last first, or pass an explicit previous_interaction_id. (This hosted connector has no filesystem, so there are no <image>.json sidecars to recover an id from across restarts.)"
33730
33997
  });
33731
33998
  }
33732
- previousInteractionId = lastInteractionId;
33733
33999
  }
33734
34000
  let images = args.images;
33735
34001
  let dropped = [];
33736
34002
  if (previousInteractionId && images?.length) {
33737
- ({ kept: images, dropped } = splitReattachedOutputs(images));
34003
+ ({ kept: images, dropped } = splitReattachedOutputs(images, client2.session.writtenOutputs));
33738
34004
  }
33739
34005
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local file input(s) to the Gemini Interactions API", "/v1beta/interactions", images, args.video_path);
33740
34006
  if (gate) return gate;
@@ -33754,40 +34020,69 @@ function registerInteractTools(server) {
33754
34020
  video_url: args.video_url,
33755
34021
  video_path: args.video_path
33756
34022
  });
33757
- return dispatch({ toolName: "gemini_interact", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
34023
+ return client2.session.jobs.dispatch({ toolName: "gemini_interact", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33758
34024
  const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33759
- const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args));
33760
- const r = await withProgressHeartbeat(extra, "Generating image (Gemini Interactions API)", () => client.interact({
34025
+ const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args, client2));
34026
+ const callOpts = {
33761
34027
  input: args.input,
33762
- images: inputs.length ? inputs : void 0,
33763
34028
  model: args.model,
33764
34029
  aspectRatio: args.aspect_ratio,
33765
34030
  imageSize: args.image_size,
33766
34031
  thinkingLevel: args.thinking_level,
33767
- previousInteractionId,
33768
34032
  googleSearch: args.google_search,
33769
34033
  searchTypes: args.search_types,
33770
34034
  videoUrl: video.videoUrl,
33771
34035
  videoMimeType: video.videoMimeType,
33772
34036
  timeoutMs: args.timeout_ms
33773
- }));
33774
- lastInteractionId = r.id;
34037
+ };
34038
+ let chainRecovered;
34039
+ const r = await withProgressHeartbeat(extra, "Generating image (Gemini Interactions API)", async () => {
34040
+ try {
34041
+ return await client2.interact({ ...callOpts, images: inputs.length ? inputs : void 0, previousInteractionId });
34042
+ } catch (err) {
34043
+ if (!(err instanceof ChainedRequest404Error)) throw err;
34044
+ if (!onDisk) throw err;
34045
+ const reanchorOn = await findInteractionImages(resolveOutputDir(args.output_dir), err.previousInteractionId);
34046
+ if (!reanchorOn.length) throw err;
34047
+ const carried = await Promise.all(reanchorOn.map((p) => readImageAsInline(p)));
34048
+ try {
34049
+ const probe = await client2.interact({ ...callOpts, images: [...carried, ...inputs], previousInteractionId: void 0 });
34050
+ chainRecovered = { expired_interaction_id: err.previousInteractionId, reanchored_on: reanchorOn };
34051
+ return probe;
34052
+ } catch (probeErr) {
34053
+ if (!(probeErr instanceof ApiError && probeErr.status === 404)) throw probeErr;
34054
+ throw new McpToolError(
34055
+ // Remediation goes in the MESSAGE, not just `hint`: MCP error
34056
+ // serialization surfaces the message and drops the hint, so a
34057
+ // hint-only fix is invisible exactly when it's needed.
34058
+ `The interaction id is not the cause of this 404: the same request re-issued WITHOUT previous_interaction_id ("${err.previousInteractionId}") also returned 404. Upstream said: ${probeErr.message}. Check that the model id ("${model}") resolves \u2014 see gemini_list_models \u2014 and that any files/\u2026 uri you referenced hasn't expired (~48h TTL). Re-chaining or re-anchoring will not help.`,
34059
+ {
34060
+ hint: `Check the model id ("${model}") resolves \u2014 see gemini_list_models \u2014 and that any files/\u2026 uri you referenced hasn't expired (~48h TTL). Re-chaining or re-anchoring will not help.`,
34061
+ cause: probeErr
34062
+ }
34063
+ );
34064
+ }
34065
+ }
34066
+ });
34067
+ client2.session.lastInteractionId = r.id;
33775
34068
  const meta3 = { model, interaction_id: r.id };
33776
- if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
34069
+ if (chainRecovered) meta3.chain_recovered = chainRecovered;
34070
+ else if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
34071
+ if (continuedFromSidecar) meta3.continued_from_sidecar = true;
33777
34072
  if (dropped.length) meta3.dropped_previous_output = dropped;
33778
34073
  meta3.hint = `To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true) \u2014 do NOT re-attach the returned image as an input; the interaction already contains it.`;
33779
34074
  if (r.text) meta3.text = r.text;
33780
34075
  if (r.grounding) meta3.grounding = r.grounding;
33781
34076
  if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
33782
- const risk = timeoutRiskHint({ model, imageSize: args.image_size });
34077
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: onDisk });
33783
34078
  if (risk) meta3.timeout_risk = risk;
33784
34079
  const slug = args.filename ? baseName(args.filename) : slugify2(args.input);
33785
34080
  const named = r.images.map((image, i) => ({
33786
34081
  image,
33787
34082
  base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
33788
34083
  }));
33789
- return emit(named, args, meta3, async (paths) => {
33790
- for (const p of paths) writtenOutputs.add(p);
34084
+ return emit(named, { ...args, sink: client2.mediaSink }, meta3, async (paths) => {
34085
+ for (const p of paths) client2.session.writtenOutputs.add(p);
33791
34086
  try {
33792
34087
  for (const p of paths) await writeSidecar(p, { ...meta3, images: paths });
33793
34088
  } catch (err) {
@@ -33802,10 +34097,10 @@ function registerInteractTools(server) {
33802
34097
  // src/tools/video.ts
33803
34098
  init_zod();
33804
34099
  init_dist();
34100
+ init_images();
33805
34101
  var VIDEO_ASPECT_RATIOS = ["16:9", "9:16"];
33806
34102
  var VIDEO_TASKS = ["text_to_video", "image_to_video", "reference_to_video", "edit"];
33807
- var lastVideoInteractionId;
33808
- function registerVideoTools(server) {
34103
+ function registerVideoTools(server, client2) {
33809
34104
  server.registerTool(
33810
34105
  "gemini_video_generate",
33811
34106
  {
@@ -33830,14 +34125,15 @@ function registerVideoTools(server) {
33830
34125
  }
33831
34126
  },
33832
34127
  async (args, extra) => {
34128
+ assertLocalInputsAvailable(client2.mediaSink, args);
33833
34129
  let previousInteractionId = args.previous_interaction_id;
33834
34130
  if (!previousInteractionId && args.continue_last) {
33835
- if (!lastVideoInteractionId) {
34131
+ if (!client2.session.lastVideoInteractionId) {
33836
34132
  throw new McpToolError("No previous video interaction to continue in this session.", {
33837
34133
  hint: "Generate a video first, or pass an explicit previous_interaction_id."
33838
34134
  });
33839
34135
  }
33840
- previousInteractionId = lastVideoInteractionId;
34136
+ previousInteractionId = client2.session.lastVideoInteractionId;
33841
34137
  }
33842
34138
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local image input(s) to the Gemini omni API", "/v1beta/interactions", args.images);
33843
34139
  if (gate) return gate;
@@ -33852,9 +34148,9 @@ function registerVideoTools(server) {
33852
34148
  from_clipboard: args.from_clipboard,
33853
34149
  previous_interaction_id: previousInteractionId
33854
34150
  });
33855
- return dispatch({ toolName: "gemini_video_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
34151
+ return client2.session.jobs.dispatch({ toolName: "gemini_video_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33856
34152
  const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33857
- const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client.generateVideo({
34153
+ const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client2.generateVideo({
33858
34154
  input: args.prompt,
33859
34155
  images: inputs.length ? inputs : void 0,
33860
34156
  model: args.model,
@@ -33863,7 +34159,7 @@ function registerVideoTools(server) {
33863
34159
  previousInteractionId,
33864
34160
  timeoutMs: args.timeout_ms
33865
34161
  }));
33866
- lastVideoInteractionId = r.id;
34162
+ client2.session.lastVideoInteractionId = r.id;
33867
34163
  const meta3 = { model, interaction_id: r.id };
33868
34164
  if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
33869
34165
  if (r.text) meta3.text = r.text;
@@ -33873,7 +34169,7 @@ function registerVideoTools(server) {
33873
34169
  media,
33874
34170
  base: r.videos.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
33875
34171
  }));
33876
- return emitMedia(named, "video", { output_dir: args.output_dir }, meta3);
34172
+ return emitMedia(named, "video", { output_dir: args.output_dir, sink: client2.mediaSink }, meta3);
33877
34173
  });
33878
34174
  }
33879
34175
  );
@@ -33882,13 +34178,13 @@ function registerVideoTools(server) {
33882
34178
  // src/tools/music.ts
33883
34179
  init_zod();
33884
34180
  init_dist();
34181
+ init_images();
33885
34182
  var MUSIC_MODELS = ["lyria-3-clip-preview", "lyria-3-pro-preview"];
33886
34183
  var AUDIO_FORMATS = ["mp3", "wav"];
33887
34184
  function isProModel(model) {
33888
34185
  return /pro/i.test(model);
33889
34186
  }
33890
- var lastMusicInteractionId;
33891
- function registerMusicTools(server) {
34187
+ function registerMusicTools(server, client2) {
33892
34188
  server.registerTool(
33893
34189
  "gemini_music_generate",
33894
34190
  {
@@ -33913,6 +34209,7 @@ function registerMusicTools(server) {
33913
34209
  }
33914
34210
  },
33915
34211
  async (args, extra) => {
34212
+ assertLocalInputsAvailable(client2.mediaSink, args);
33916
34213
  const model = args.model ?? DEFAULT_MUSIC_MODEL;
33917
34214
  if (args.audio_format === "wav" && !isProModel(model)) {
33918
34215
  throw new McpToolError(`WAV output requires a Pro model; ${model} only produces MP3.`, {
@@ -33921,12 +34218,12 @@ function registerMusicTools(server) {
33921
34218
  }
33922
34219
  let previousInteractionId = args.previous_interaction_id;
33923
34220
  if (!previousInteractionId && args.continue_last) {
33924
- if (!lastMusicInteractionId) {
34221
+ if (!client2.session.lastMusicInteractionId) {
33925
34222
  throw new McpToolError("No previous music interaction to continue in this session.", {
33926
34223
  hint: "Generate music first, or pass an explicit previous_interaction_id."
33927
34224
  });
33928
34225
  }
33929
- previousInteractionId = lastMusicInteractionId;
34226
+ previousInteractionId = client2.session.lastMusicInteractionId;
33930
34227
  }
33931
34228
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local image input(s) to the Gemini Lyria API", "/v1beta/interactions", args.images);
33932
34229
  if (gate) return gate;
@@ -33939,9 +34236,9 @@ function registerMusicTools(server) {
33939
34236
  from_clipboard: args.from_clipboard,
33940
34237
  previous_interaction_id: previousInteractionId
33941
34238
  });
33942
- return dispatch({ toolName: "gemini_music_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
34239
+ return client2.session.jobs.dispatch({ toolName: "gemini_music_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33943
34240
  const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33944
- const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client.generateMusic({
34241
+ const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client2.generateMusic({
33945
34242
  input: args.prompt,
33946
34243
  images: inputs.length ? inputs : void 0,
33947
34244
  model: args.model,
@@ -33949,7 +34246,7 @@ function registerMusicTools(server) {
33949
34246
  previousInteractionId,
33950
34247
  timeoutMs: args.timeout_ms
33951
34248
  }));
33952
- lastMusicInteractionId = r.id;
34249
+ client2.session.lastMusicInteractionId = r.id;
33953
34250
  const meta3 = { model, interaction_id: r.id };
33954
34251
  if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
33955
34252
  if (r.text) meta3.text = r.text;
@@ -33959,7 +34256,7 @@ function registerMusicTools(server) {
33959
34256
  media,
33960
34257
  base: r.audios.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
33961
34258
  }));
33962
- return emitMedia(named, "audio", { inline: args.inline, output_dir: args.output_dir }, meta3);
34259
+ return emitMedia(named, "audio", { inline: args.inline, output_dir: args.output_dir, sink: client2.mediaSink }, meta3);
33963
34260
  });
33964
34261
  }
33965
34262
  );
@@ -33967,24 +34264,26 @@ function registerMusicTools(server) {
33967
34264
 
33968
34265
  // src/tools/jobs.ts
33969
34266
  init_zod();
33970
- function registerJobTools(server) {
34267
+ function registerJobTools(server, client2) {
33971
34268
  server.registerTool(
33972
34269
  "gemini_get_result",
33973
34270
  {
33974
- description: 'Retrieve an async generation started with `async: true`. Pass the returned `job_id`: while running it reports status "running"; on completion it returns the normal result (image paths / inline images + meta); on failure it raises the recorded error. Jobs are per-process and expire ~10 min after completion \u2014 if a job id is unknown, check the output dir / <image>.json sidecar.',
34271
+ description: 'Retrieve an async generation started with `async: true`. Pass the returned `job_id`: while running it reports status "running"; on completion it returns the normal result (image paths / inline images + meta); on failure it raises the recorded error. Jobs belong to your session and expire ~10 min after completion \u2014 if a job id is unknown, check the output dir / <image>.json sidecar.',
33975
34272
  annotations: { readOnlyHint: true, openWorldHint: false },
33976
34273
  inputSchema: {
33977
34274
  job_id: external_exports.string().min(1).describe("The job_id returned by a generation tool called with async: true")
33978
34275
  }
33979
34276
  },
33980
- async (args) => getJobResult(args.job_id)
34277
+ async (args) => client2.session.jobs.getResult(args.job_id)
33981
34278
  );
33982
34279
  }
33983
34280
 
33984
34281
  // src/index.ts
34282
+ await loadStdioDotenv();
33985
34283
  await runMcp({
33986
34284
  name: "gemini-mcp",
33987
34285
  version: VERSION,
33988
34286
  banner: "[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.",
33989
- tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools]
34287
+ tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools],
34288
+ deps: client
33990
34289
  });