@chrischall/gemini-mcp 1.0.1 → 1.0.3

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