@chrischall/gemini-mcp 0.8.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bundle.js CHANGED
@@ -32606,7 +32606,7 @@ var init_clipboard = __esm({
32606
32606
  init_dist();
32607
32607
 
32608
32608
  // src/version.ts
32609
- var VERSION = "0.8.0";
32609
+ var VERSION = "1.0.0";
32610
32610
 
32611
32611
  // src/tools/models.ts
32612
32612
  init_dist();
@@ -32618,6 +32618,8 @@ import { fileURLToPath } from "node:url";
32618
32618
 
32619
32619
  // src/models.ts
32620
32620
  var DEFAULT_IMAGE_MODEL = "gemini-3.1-flash-image";
32621
+ var DEFAULT_VIDEO_MODEL = "gemini-omni-flash-preview";
32622
+ var DEFAULT_MUSIC_MODEL = "lyria-3-clip-preview";
32621
32623
  function resolveModel(perCall, envOverride) {
32622
32624
  return perCall?.trim() || envOverride?.trim() || DEFAULT_IMAGE_MODEL;
32623
32625
  }
@@ -32881,14 +32883,66 @@ var GeminiClient = class {
32881
32883
  if (opts.searchTypes?.length) searchTool.search_types = opts.searchTypes;
32882
32884
  body.tools = [searchTool];
32883
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."
32936
+ });
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) {
32884
32942
  const { interactionsApi } = this.apisFor(resolveTimeoutMs(opts.timeoutMs, opts.imageSize));
32885
- let data;
32886
32943
  for (let attempt = 0; ; attempt++) {
32887
32944
  try {
32888
- data = await interactionsApi.fetchJson("POST", "/interactions", {
32889
- body
32890
- });
32891
- break;
32945
+ return await interactionsApi.fetchJson("POST", "/interactions", { body });
32892
32946
  } catch (err) {
32893
32947
  if (!(opts.previousInteractionId && err instanceof ApiError && err.status === 404)) throw err;
32894
32948
  if (attempt < CHAIN_404_RETRIES) {
@@ -32898,13 +32952,23 @@ var GeminiClient = class {
32898
32952
  throw new McpToolError(
32899
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.`,
32900
32954
  {
32901
- hint: "Retry once more in a few seconds; if it keeps failing, start a new chain: call gemini_interact again with the last output image passed via `images` (or `images_base64`) plus the same instruction, then chain the NEW interaction id.",
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.",
32902
32956
  cause: err
32903
32957
  }
32904
32958
  );
32905
32959
  }
32906
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) {
32907
32969
  const images = [];
32970
+ const videos = [];
32971
+ const audios = [];
32908
32972
  const textParts = [];
32909
32973
  const searchQueries = [];
32910
32974
  const searchSuggestions = /* @__PURE__ */ new Set();
@@ -32914,7 +32978,7 @@ var GeminiClient = class {
32914
32978
  continue;
32915
32979
  }
32916
32980
  if (step.type === "google_search_result") {
32917
- if (opts.searchTypes?.includes("image_search")) {
32981
+ if (searchTypes?.includes("image_search")) {
32918
32982
  for (const r of step.result ?? []) if (r.search_suggestions?.trim()) searchSuggestions.add(r.search_suggestions);
32919
32983
  }
32920
32984
  continue;
@@ -32922,27 +32986,24 @@ var GeminiClient = class {
32922
32986
  if (step.type !== "model_output") continue;
32923
32987
  for (const parts of [step.content ?? [], step.summary ?? []]) {
32924
32988
  for (const part of parts) {
32925
- if (part.type === "image" && part.data) {
32926
- images.push({ base64: part.data, mimeType: part.mime_type ?? "image/jpeg" });
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" });
32927
32994
  } else if (part.type === "text" && part.text?.trim()) {
32928
32995
  textParts.push(part.text);
32929
32996
  }
32930
32997
  }
32931
32998
  }
32932
32999
  }
32933
- if (images.length === 0) {
32934
- throw new McpToolError("Gemini returned no image", {
32935
- hint: "The request may have been blocked by safety filters \u2014 try rephrasing the prompt."
32936
- });
32937
- }
32938
- const resultText = textParts.join("\n") || void 0;
32939
33000
  let grounding;
32940
33001
  if (searchQueries.length > 0 || searchSuggestions.size > 0) {
32941
33002
  grounding = {};
32942
33003
  if (searchQueries.length > 0) grounding.queries = searchQueries;
32943
33004
  if (searchSuggestions.size > 0) grounding.search_suggestions = [...searchSuggestions];
32944
33005
  }
32945
- return { id: data.id, images, text: resultText, grounding };
33006
+ return { images, videos, audios, text: textParts.join("\n") || void 0, grounding };
32946
33007
  }
32947
33008
  };
32948
33009
  var client = new GeminiClient();
@@ -33004,10 +33065,22 @@ function sniffMimeBytes(buf) {
33004
33065
  }
33005
33066
  return "image/png";
33006
33067
  }
33007
- async function writeImage(dir, base, base643, mimeType) {
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) {
33008
33082
  await mkdir(dir, { recursive: true });
33009
- const ext = mimeType.includes("jpeg") ? "jpg" : mimeType.includes("webp") ? "webp" : "png";
33010
- const path = await uniquePath(dir, base, ext);
33083
+ const path = await uniquePath(dir, base, mediaExt(mimeType));
33011
33084
  await writeFile(path, Buffer.from(base643, "base64"));
33012
33085
  return resolve(path);
33013
33086
  }
@@ -33104,7 +33177,7 @@ async function gatherImageInputs(opts) {
33104
33177
  return [...clipboardImages, ...fileImages];
33105
33178
  }
33106
33179
  function baseName(name) {
33107
- const stripped = name.replace(/\.(png|jpe?g|webp)$/i, "");
33180
+ const stripped = name.replace(/\.(png|jpe?g|webp|mp4|mov|webm|mp3|wav|ogg)$/i, "");
33108
33181
  const safe = stripped.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
33109
33182
  return safe || "image";
33110
33183
  }
@@ -33121,6 +33194,12 @@ var MODEL_CHOICE_GUIDE = "gemini-3.1-flash-image (Nano Banana 2) is the versatil
33121
33194
  var timeoutMsSchema = external_exports.number().int().positive().optional().describe(
33122
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)"
33123
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
+ );
33124
33203
  var sharedImageSchema = {
33125
33204
  // Bare id only: the model is interpolated into the URL path
33126
33205
  // (`/models/${model}:generateContent`), so slashes/colons/queries must be
@@ -33134,7 +33213,9 @@ var sharedImageSchema = {
33134
33213
  thinking_level: external_exports.enum(["minimal", "high"]).optional().describe("Reasoning depth (Gemini 3 models); higher can help complex/structural edits"),
33135
33214
  google_search: external_exports.boolean().optional().describe("Ground the image in live Google Search results (current events, weather, data)"),
33136
33215
  from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)"),
33137
- timeout_ms: timeoutMsSchema
33216
+ timeout_ms: timeoutMsSchema,
33217
+ idempotency_key: idempotencyKeySchema,
33218
+ async: asyncSchema
33138
33219
  };
33139
33220
  async function resolveVideoInput(args) {
33140
33221
  if (args.video_url && args.video_path) {
@@ -33159,16 +33240,28 @@ async function withProgressHeartbeat(extra, message, fn) {
33159
33240
  const send = extra?.sendNotification;
33160
33241
  const env = Number(readEnvVar("GEMINI_HEARTBEAT_MS"));
33161
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
+ }
33252
+ }
33162
33253
  if (progressToken === void 0 || !send || intervalMs <= 0) return fn();
33163
33254
  let ticks = 0;
33164
33255
  const timer = setInterval(() => {
33165
33256
  ticks++;
33257
+ const elapsed = Math.round(ticks * intervalMs / 1e3);
33258
+ if (debug) console.error(`[gemini-mcp] heartbeat: ${message} \u2014 still working (${elapsed}s)`);
33166
33259
  void send({
33167
33260
  method: "notifications/progress",
33168
33261
  params: {
33169
33262
  progressToken,
33170
33263
  progress: ticks,
33171
- message: `${message} \u2014 still working (${Math.round(ticks * intervalMs / 1e3)}s)`
33264
+ message: `${message} \u2014 still working (${elapsed}s)`
33172
33265
  }
33173
33266
  }).catch(() => {
33174
33267
  });
@@ -33180,27 +33273,150 @@ async function withProgressHeartbeat(extra, message, fn) {
33180
33273
  clearInterval(timer);
33181
33274
  }
33182
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
+ }
33183
33284
  function buildMeta(model, seed, opts) {
33184
33285
  const meta3 = { model, seed };
33185
33286
  if (opts.aspect_ratio) meta3.aspect_ratio = opts.aspect_ratio;
33186
33287
  if (opts.image_size) meta3.image_size = opts.image_size;
33187
33288
  return meta3;
33188
33289
  }
33189
- async function emit(named, opts, meta3, onWritten) {
33190
- if (opts.inline) {
33191
- const imageBlocks = named.map((n) => ({ type: "image", data: n.image.base64, mimeType: n.image.mimeType }));
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 }));
33192
33294
  if (meta3 && Object.keys(meta3).length > 0) {
33193
- return {
33194
- content: [{ type: "text", text: JSON.stringify(meta3, null, 2) }, ...imageBlocks]
33195
- };
33295
+ return { content: [{ type: "text", text: JSON.stringify(meta3, null, 2) }, ...blocks] };
33196
33296
  }
33197
- return { content: imageBlocks };
33297
+ return { content: blocks };
33198
33298
  }
33199
33299
  const dir = resolveOutputDir(opts.output_dir);
33200
- const images = [];
33201
- for (const n of named) images.push(await writeImage(dir, n.base, n.image.base64, n.image.mimeType));
33202
- await onWritten?.(images);
33203
- return textResult({ images, ...meta3 });
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);
33324
+ }
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);
33330
+ }
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 = {};
33349
+ }
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();
33400
+ }
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."
33412
+ });
33413
+ }
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);
33417
+ }
33418
+ if (!entry.result) throw new McpToolError(`Job "${jobId}" completed without a recorded result.`);
33419
+ return entry.result;
33204
33420
  }
33205
33421
 
33206
33422
  // src/tools/_confirm.ts
@@ -33231,7 +33447,7 @@ var SEND_LOCAL_ACTION = "Send local file input(s) to the Gemini API";
33231
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).";
33232
33448
  function registerGenerateTools(server) {
33233
33449
  server.registerTool(
33234
- "gemini_generate_image",
33450
+ "gemini_image_generate",
33235
33451
  {
33236
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.",
33237
33453
  annotations: { readOnlyHint: false, openWorldHint: true },
@@ -33251,47 +33467,65 @@ function registerGenerateTools(server) {
33251
33467
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images, args.video_path);
33252
33468
  if (gate) return gate;
33253
33469
  const count = args.count ?? 1;
33254
- const seed = pickSeed(args.seed);
33255
33470
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33256
- const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33257
- const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33258
- const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args));
33259
- const named = [];
33260
- let capturedText;
33261
- let capturedGrounding;
33262
- await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : "image"} (${model})`, async () => {
33263
- for (let i = 0; i < count; i++) {
33264
- const result = await client.generate({
33265
- prompt: args.prompt,
33266
- images: refInputs.length > 0 ? refInputs : void 0,
33267
- model: args.model,
33268
- aspectRatio: args.aspect_ratio,
33269
- imageSize: args.image_size,
33270
- seed: seed + i,
33271
- thinkingLevel: args.thinking_level,
33272
- googleSearch: args.google_search,
33273
- videoUrl: video.videoUrl,
33274
- videoMimeType: video.videoMimeType,
33275
- timeoutMs: args.timeout_ms
33276
- });
33277
- const { images: [img], text } = result;
33278
- if (text && !capturedText) capturedText = text;
33279
- if (result.grounding && !capturedGrounding) capturedGrounding = result.grounding;
33280
- named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, "0")}` });
33281
- }
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
33484
+ });
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);
33282
33522
  });
33283
- const meta3 = buildMeta(model, seed, args);
33284
- if (capturedText) meta3.text = capturedText;
33285
- if (capturedGrounding) meta3.grounding = capturedGrounding;
33286
- if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
33287
- meta3.hint = REFINE_HINT;
33288
- return emit(named, args, meta3);
33289
33523
  }
33290
33524
  );
33291
33525
  server.registerTool(
33292
- "gemini_edit_image",
33526
+ "gemini_image_edit",
33293
33527
  {
33294
- 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_edit_image 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.",
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.",
33295
33529
  annotations: { readOnlyHint: false, openWorldHint: true },
33296
33530
  inputSchema: {
33297
33531
  prompt: external_exports.string().min(1).describe("Instruction describing the edit or composition"),
@@ -33310,27 +33544,42 @@ function registerGenerateTools(server) {
33310
33544
  }
33311
33545
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
33312
33546
  if (gate) return gate;
33313
- const seed = pickSeed(args.seed);
33314
33547
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33315
- const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33316
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33317
- const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
33548
+ const fingerprint = fingerprintRequest("gemini_image_edit", {
33549
+ model,
33318
33550
  prompt: args.prompt,
33319
- images: inputs,
33320
- model: args.model,
33321
- aspectRatio: args.aspect_ratio,
33322
- imageSize: args.image_size,
33323
- seed,
33324
- thinkingLevel: args.thinking_level,
33325
- googleSearch: args.google_search,
33326
- timeoutMs: args.timeout_ms
33327
- }));
33328
- const { images: [img], text } = result;
33329
- const meta3 = buildMeta(model, seed, args);
33330
- if (text) meta3.text = text;
33331
- if (result.grounding) meta3.grounding = result.grounding;
33332
- meta3.hint = REFINE_HINT;
33333
- return emit([{ image: img, base: slug }], args, meta3);
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);
33582
+ });
33334
33583
  }
33335
33584
  );
33336
33585
  }
@@ -33340,7 +33589,7 @@ init_zod();
33340
33589
  init_dist();
33341
33590
  function registerSetTools(server) {
33342
33591
  server.registerTool(
33343
- "gemini_generate_set",
33592
+ "gemini_image_set",
33344
33593
  {
33345
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).",
33346
33595
  annotations: { readOnlyHint: false, openWorldHint: true },
@@ -33362,43 +33611,61 @@ function registerSetTools(server) {
33362
33611
  }
33363
33612
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local master image input(s) to the Gemini API", "/v1beta/models/{model}:generateContent", args.master_images);
33364
33613
  if (gate) return gate;
33365
- const seed = pickSeed(args.seed);
33366
33614
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33367
- 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 };
33368
- const slug = args.basename ? baseName(args.basename) : slugify2(args.master_prompt);
33369
- const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
33370
- const named = [];
33371
- const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
33372
- const result = await client.generate({
33373
- prompt: args.master_prompt,
33374
- images: masterRefInputs.length > 0 ? masterRefInputs : void 0,
33375
- seed,
33376
- ...cfg
33377
- });
33378
- const master = result.images[0];
33379
- named.push({ image: master, base: `${slug}-master` });
33380
- const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
33381
- const mode = args.reference_mode ?? "master";
33382
- if (mode === "chain") {
33383
- let ref = master;
33384
- for (let i = 0; i < scenePrompts.length; i++) {
33385
- const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
33386
- named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` });
33387
- ref = img;
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")}` }));
33388
33658
  }
33389
- } else {
33390
- const scenes = await Promise.all(
33391
- scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
33392
- );
33393
- scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` }));
33394
- }
33395
- return result;
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);
33396
33668
  });
33397
- const masterText = masterResult.text;
33398
- const meta3 = buildMeta(model, seed, args);
33399
- if (masterText) meta3.text = masterText;
33400
- if (masterResult.grounding) meta3.grounding = masterResult.grounding;
33401
- return emit(named, args, meta3);
33402
33669
  }
33403
33670
  );
33404
33671
  }
@@ -33448,6 +33715,8 @@ function registerInteractTools(server) {
33448
33715
  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)"),
33449
33716
  video_path: videoPathSchema,
33450
33717
  timeout_ms: timeoutMsSchema,
33718
+ idempotency_key: idempotencyKeySchema,
33719
+ async: asyncSchema,
33451
33720
  from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)"),
33452
33721
  confirm: schemaConfirm
33453
33722
  }
@@ -33469,52 +33738,253 @@ function registerInteractTools(server) {
33469
33738
  }
33470
33739
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local file input(s) to the Gemini Interactions API", "/v1beta/interactions", images, args.video_path);
33471
33740
  if (gate) return gate;
33472
- const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33473
- const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args));
33474
- const r = await withProgressHeartbeat(extra, "Generating image (Gemini Interactions API)", () => client.interact({
33475
- input: args.input,
33476
- images: inputs.length ? inputs : void 0,
33477
- model: args.model,
33478
- aspectRatio: args.aspect_ratio,
33479
- imageSize: args.image_size,
33480
- thinkingLevel: args.thinking_level,
33481
- previousInteractionId,
33482
- googleSearch: args.google_search,
33483
- searchTypes: args.search_types,
33484
- videoUrl: video.videoUrl,
33485
- videoMimeType: video.videoMimeType,
33486
- timeoutMs: args.timeout_ms
33487
- }));
33488
- lastInteractionId = r.id;
33489
33741
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33490
- const meta3 = { model, interaction_id: r.id };
33491
- if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
33492
- if (dropped.length) meta3.dropped_previous_output = dropped;
33493
- 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.`;
33494
- if (r.text) meta3.text = r.text;
33495
- if (r.grounding) meta3.grounding = r.grounding;
33496
- if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
33497
- const slug = args.filename ? baseName(args.filename) : slugify2(args.input);
33498
- const named = r.images.map((image, i) => ({
33499
- image,
33500
- base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
33501
- }));
33502
- return emit(named, args, meta3, async (paths) => {
33503
- for (const p of paths) writtenOutputs.add(p);
33504
- try {
33505
- for (const p of paths) await writeSidecar(p, { ...meta3, images: paths });
33506
- } catch (err) {
33507
- meta3.sidecar_error = err instanceof Error ? err.message : String(err);
33742
+ const fingerprint = fingerprintRequest("gemini_interact", {
33743
+ model,
33744
+ input: args.input,
33745
+ aspect_ratio: args.aspect_ratio,
33746
+ image_size: args.image_size,
33747
+ thinking_level: args.thinking_level,
33748
+ previous_interaction_id: previousInteractionId,
33749
+ google_search: args.google_search,
33750
+ search_types: args.search_types,
33751
+ images,
33752
+ images_base64: args.images_base64,
33753
+ from_clipboard: args.from_clipboard,
33754
+ video_url: args.video_url,
33755
+ video_path: args.video_path
33756
+ });
33757
+ return dispatch({ toolName: "gemini_interact", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33758
+ 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({
33761
+ input: args.input,
33762
+ images: inputs.length ? inputs : void 0,
33763
+ model: args.model,
33764
+ aspectRatio: args.aspect_ratio,
33765
+ imageSize: args.image_size,
33766
+ thinkingLevel: args.thinking_level,
33767
+ previousInteractionId,
33768
+ googleSearch: args.google_search,
33769
+ searchTypes: args.search_types,
33770
+ videoUrl: video.videoUrl,
33771
+ videoMimeType: video.videoMimeType,
33772
+ timeoutMs: args.timeout_ms
33773
+ }));
33774
+ lastInteractionId = r.id;
33775
+ const meta3 = { model, interaction_id: r.id };
33776
+ if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
33777
+ if (dropped.length) meta3.dropped_previous_output = dropped;
33778
+ 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
+ if (r.text) meta3.text = r.text;
33780
+ if (r.grounding) meta3.grounding = r.grounding;
33781
+ if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
33782
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size });
33783
+ if (risk) meta3.timeout_risk = risk;
33784
+ const slug = args.filename ? baseName(args.filename) : slugify2(args.input);
33785
+ const named = r.images.map((image, i) => ({
33786
+ image,
33787
+ base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
33788
+ }));
33789
+ return emit(named, args, meta3, async (paths) => {
33790
+ for (const p of paths) writtenOutputs.add(p);
33791
+ try {
33792
+ for (const p of paths) await writeSidecar(p, { ...meta3, images: paths });
33793
+ } catch (err) {
33794
+ meta3.sidecar_error = err instanceof Error ? err.message : String(err);
33795
+ }
33796
+ });
33797
+ });
33798
+ }
33799
+ );
33800
+ }
33801
+
33802
+ // src/tools/video.ts
33803
+ init_zod();
33804
+ init_dist();
33805
+ var VIDEO_ASPECT_RATIOS = ["16:9", "9:16"];
33806
+ var VIDEO_TASKS = ["text_to_video", "image_to_video", "reference_to_video", "edit"];
33807
+ var lastVideoInteractionId;
33808
+ function registerVideoTools(server) {
33809
+ server.registerTool(
33810
+ "gemini_video_generate",
33811
+ {
33812
+ description: 'Generate a short video via the Gemini omni model (preview): text\u2192video, image\u2192video / reference\u2192video (supply reference image[s]), or edit a prior video (task: "edit" + previous_interaction_id / continue_last). Output is written to disk as MP4 (video has no inline MCP block). Video runs long \u2014 use `async: true` to get a job_id immediately and poll gemini_get_result, or raise `timeout_ms`. Preview model: needs a funded account.',
33813
+ annotations: { readOnlyHint: false, openWorldHint: true },
33814
+ inputSchema: {
33815
+ prompt: external_exports.string().min(1).describe("Description of the video to generate (or the edit instruction when task=edit)"),
33816
+ aspect_ratio: external_exports.enum(VIDEO_ASPECT_RATIOS).optional().describe("Output aspect ratio (omni: 16:9 or 9:16)"),
33817
+ task: external_exports.enum(VIDEO_TASKS).optional().describe("text_to_video (default), image_to_video / reference_to_video (need image input), or edit (needs previous_interaction_id)"),
33818
+ images: external_exports.array(external_exports.string().min(1)).optional().describe("Reference image path(s) for image_to_video / reference_to_video"),
33819
+ images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs"),
33820
+ from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS clipboard as a reference"),
33821
+ filename: external_exports.string().optional().describe("Base filename for the output video (extension stripped; default: slugified prompt)"),
33822
+ output_dir: external_exports.string().optional().describe("Directory to write the video to (default: $GEMINI_OUTPUT_DIR or cwd)"),
33823
+ model: external_exports.string().regex(/^[\w.-]+$/, 'must be a bare model id (letters, digits, ".", "_", "-")').optional().describe(`Model id override (default: ${DEFAULT_VIDEO_MODEL})`),
33824
+ previous_interaction_id: external_exports.string().optional().describe('Interaction id to edit/continue (with task: "edit")'),
33825
+ continue_last: external_exports.boolean().optional().describe("Continue from the most recent video interaction this server created (explicit previous_interaction_id wins)"),
33826
+ timeout_ms: timeoutMsSchema,
33827
+ idempotency_key: idempotencyKeySchema,
33828
+ async: asyncSchema,
33829
+ confirm: schemaConfirm
33830
+ }
33831
+ },
33832
+ async (args, extra) => {
33833
+ let previousInteractionId = args.previous_interaction_id;
33834
+ if (!previousInteractionId && args.continue_last) {
33835
+ if (!lastVideoInteractionId) {
33836
+ throw new McpToolError("No previous video interaction to continue in this session.", {
33837
+ hint: "Generate a video first, or pass an explicit previous_interaction_id."
33838
+ });
33839
+ }
33840
+ previousInteractionId = lastVideoInteractionId;
33841
+ }
33842
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local image input(s) to the Gemini omni API", "/v1beta/interactions", args.images);
33843
+ if (gate) return gate;
33844
+ const model = args.model?.trim() || DEFAULT_VIDEO_MODEL;
33845
+ const fingerprint = fingerprintRequest("gemini_video_generate", {
33846
+ model,
33847
+ prompt: args.prompt,
33848
+ aspect_ratio: args.aspect_ratio,
33849
+ task: args.task,
33850
+ images: args.images,
33851
+ images_base64: args.images_base64,
33852
+ from_clipboard: args.from_clipboard,
33853
+ previous_interaction_id: previousInteractionId
33854
+ });
33855
+ return dispatch({ toolName: "gemini_video_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33856
+ 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({
33858
+ input: args.prompt,
33859
+ images: inputs.length ? inputs : void 0,
33860
+ model: args.model,
33861
+ aspectRatio: args.aspect_ratio,
33862
+ task: args.task,
33863
+ previousInteractionId,
33864
+ timeoutMs: args.timeout_ms
33865
+ }));
33866
+ lastVideoInteractionId = r.id;
33867
+ const meta3 = { model, interaction_id: r.id };
33868
+ if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
33869
+ if (r.text) meta3.text = r.text;
33870
+ meta3.hint = `To edit this video, call gemini_video_generate again with task: "edit" and previous_interaction_id: "${r.id}" (or continue_last: true).`;
33871
+ const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33872
+ const named = r.videos.map((media, i) => ({
33873
+ media,
33874
+ base: r.videos.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
33875
+ }));
33876
+ return emitMedia(named, "video", { output_dir: args.output_dir }, meta3);
33877
+ });
33878
+ }
33879
+ );
33880
+ }
33881
+
33882
+ // src/tools/music.ts
33883
+ init_zod();
33884
+ init_dist();
33885
+ var MUSIC_MODELS = ["lyria-3-clip-preview", "lyria-3-pro-preview"];
33886
+ var AUDIO_FORMATS = ["mp3", "wav"];
33887
+ function isProModel(model) {
33888
+ return /pro/i.test(model);
33889
+ }
33890
+ var lastMusicInteractionId;
33891
+ function registerMusicTools(server) {
33892
+ server.registerTool(
33893
+ "gemini_music_generate",
33894
+ {
33895
+ description: "Generate music from a text prompt (mood, genre, instruments, structure, or lyrics inline) via a Lyria model (preview): lyria-3-clip-preview (~30s clips, default) or lyria-3-pro-preview (longer, WAV-capable). Written to disk as MP3/WAV (or returned inline). Runs long \u2014 use `async: true` + gemini_get_result, or raise `timeout_ms`. Preview model: needs a funded account.",
33896
+ annotations: { readOnlyHint: false, openWorldHint: true },
33897
+ inputSchema: {
33898
+ prompt: external_exports.string().min(1).describe("Description of the music: mood, genre, instruments, tempo, structure, or lyrics"),
33899
+ model: external_exports.enum(MUSIC_MODELS).optional().describe(`Lyria model (default: ${DEFAULT_MUSIC_MODEL}). Pro is longer-form and supports WAV.`),
33900
+ audio_format: external_exports.enum(AUDIO_FORMATS).optional().describe("Output format (default mp3). wav is lyria-3-pro-preview-only."),
33901
+ images: external_exports.array(external_exports.string().min(1)).optional().describe("Optional reference image path(s) to condition the music"),
33902
+ images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs"),
33903
+ from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS clipboard as a reference"),
33904
+ filename: external_exports.string().optional().describe("Base filename for the output audio (extension stripped; default: slugified prompt)"),
33905
+ output_dir: external_exports.string().optional().describe("Directory to write audio to (default: $GEMINI_OUTPUT_DIR or cwd)"),
33906
+ inline: external_exports.boolean().optional().describe("Return base64 audio inline instead of writing to disk"),
33907
+ previous_interaction_id: external_exports.string().optional().describe("Interaction id to continue from"),
33908
+ continue_last: external_exports.boolean().optional().describe("Continue from the most recent music interaction this server created (explicit previous_interaction_id wins)"),
33909
+ timeout_ms: timeoutMsSchema,
33910
+ idempotency_key: idempotencyKeySchema,
33911
+ async: asyncSchema,
33912
+ confirm: schemaConfirm
33913
+ }
33914
+ },
33915
+ async (args, extra) => {
33916
+ const model = args.model ?? DEFAULT_MUSIC_MODEL;
33917
+ if (args.audio_format === "wav" && !isProModel(model)) {
33918
+ throw new McpToolError(`WAV output requires a Pro model; ${model} only produces MP3.`, {
33919
+ hint: 'Use model: "lyria-3-pro-preview" for WAV, or drop audio_format to get MP3.'
33920
+ });
33921
+ }
33922
+ let previousInteractionId = args.previous_interaction_id;
33923
+ if (!previousInteractionId && args.continue_last) {
33924
+ if (!lastMusicInteractionId) {
33925
+ throw new McpToolError("No previous music interaction to continue in this session.", {
33926
+ hint: "Generate music first, or pass an explicit previous_interaction_id."
33927
+ });
33508
33928
  }
33929
+ previousInteractionId = lastMusicInteractionId;
33930
+ }
33931
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local image input(s) to the Gemini Lyria API", "/v1beta/interactions", args.images);
33932
+ if (gate) return gate;
33933
+ const fingerprint = fingerprintRequest("gemini_music_generate", {
33934
+ model,
33935
+ prompt: args.prompt,
33936
+ audio_format: args.audio_format,
33937
+ images: args.images,
33938
+ images_base64: args.images_base64,
33939
+ from_clipboard: args.from_clipboard,
33940
+ previous_interaction_id: previousInteractionId
33941
+ });
33942
+ return dispatch({ toolName: "gemini_music_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33943
+ 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({
33945
+ input: args.prompt,
33946
+ images: inputs.length ? inputs : void 0,
33947
+ model: args.model,
33948
+ audioFormat: args.audio_format,
33949
+ previousInteractionId,
33950
+ timeoutMs: args.timeout_ms
33951
+ }));
33952
+ lastMusicInteractionId = r.id;
33953
+ const meta3 = { model, interaction_id: r.id };
33954
+ if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
33955
+ if (r.text) meta3.text = r.text;
33956
+ meta3.hint = `To continue this track, call gemini_music_generate again with previous_interaction_id: "${r.id}" (or continue_last: true).`;
33957
+ const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33958
+ const named = r.audios.map((media, i) => ({
33959
+ media,
33960
+ base: r.audios.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
33961
+ }));
33962
+ return emitMedia(named, "audio", { inline: args.inline, output_dir: args.output_dir }, meta3);
33509
33963
  });
33510
33964
  }
33511
33965
  );
33512
33966
  }
33513
33967
 
33968
+ // src/tools/jobs.ts
33969
+ init_zod();
33970
+ function registerJobTools(server) {
33971
+ server.registerTool(
33972
+ "gemini_get_result",
33973
+ {
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.',
33975
+ annotations: { readOnlyHint: true, openWorldHint: false },
33976
+ inputSchema: {
33977
+ job_id: external_exports.string().min(1).describe("The job_id returned by a generation tool called with async: true")
33978
+ }
33979
+ },
33980
+ async (args) => getJobResult(args.job_id)
33981
+ );
33982
+ }
33983
+
33514
33984
  // src/index.ts
33515
33985
  await runMcp({
33516
33986
  name: "gemini-mcp",
33517
33987
  version: VERSION,
33518
33988
  banner: "[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.",
33519
- tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools]
33989
+ tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools]
33520
33990
  });