@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/client.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { basename, dirname, join } from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { loadDotenvSafely, readEnvVar, McpToolError, ApiError, createApiClient, formatApiError, fileBlob } from '@chrischall/mcp-utils';
4
- import { resolveModel, filterImageModels } from './models.js';
4
+ import { resolveModel, filterImageModels, DEFAULT_VIDEO_MODEL, DEFAULT_MUSIC_MODEL } from './models.js';
5
5
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
6
  await loadDotenvSafely({ path: join(__dirname, '..', '.env'), override: false });
7
7
  const BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'; // v1 lacks gemini-3-pro-image; confirmed via Task 5
@@ -288,22 +288,80 @@ export class GeminiClient {
288
288
  searchTool.search_types = opts.searchTypes;
289
289
  body.tools = [searchTool];
290
290
  }
291
+ const data = await this.postInteraction(body, opts);
292
+ const { images, text, grounding } = this.extractInteraction(data, opts.searchTypes);
293
+ if (images.length === 0) {
294
+ throw new McpToolError('Gemini returned no image', {
295
+ hint: 'The request may have been blocked by safety filters — try rephrasing the prompt.',
296
+ });
297
+ }
298
+ return { id: data.id, images, text, grounding };
299
+ }
300
+ /**
301
+ * Generate video via the omni model (Interactions API, inline delivery). Same
302
+ * plumbing as {@link interact} — text/image input parts, a `video`
303
+ * response_format, optional `video_config.task`, and `previous_interaction_id`
304
+ * for edits. Preview model: shapes are docs-derived; parsing stays tolerant.
305
+ */
306
+ async generateVideo(opts) {
307
+ const model = opts.model ?? DEFAULT_VIDEO_MODEL;
308
+ const inputParts = [{ type: 'text', text: opts.input }];
309
+ for (const img of opts.images ?? [])
310
+ inputParts.push({ type: 'image', mime_type: img.mimeType, data: img.base64 });
311
+ const responseFormat = { type: 'video', delivery: 'inline' };
312
+ if (opts.aspectRatio)
313
+ responseFormat.aspect_ratio = opts.aspectRatio;
314
+ const body = { model, input: inputParts, response_format: responseFormat };
315
+ if (opts.task)
316
+ body.generation_config = { video_config: { task: opts.task } };
317
+ if (opts.previousInteractionId !== undefined)
318
+ body.previous_interaction_id = opts.previousInteractionId;
319
+ const data = await this.postInteraction(body, opts);
320
+ const { videos, text } = this.extractInteraction(data);
321
+ if (videos.length === 0) {
322
+ throw new McpToolError('Gemini returned no video', {
323
+ hint: 'The request may have been blocked by a safety filter, or the clip exceeded the ~4MB inline limit — try a shorter/simpler prompt or a different aspect ratio.',
324
+ });
325
+ }
326
+ return { id: data.id, videos, text };
327
+ }
328
+ /**
329
+ * Generate music via a Lyria model (Interactions API, inline audio). Reuses
330
+ * the interact plumbing with an `audio` response_format. Preview model.
331
+ */
332
+ async generateMusic(opts) {
333
+ const model = opts.model ?? DEFAULT_MUSIC_MODEL;
334
+ const inputParts = [{ type: 'text', text: opts.input }];
335
+ for (const img of opts.images ?? [])
336
+ inputParts.push({ type: 'image', mime_type: img.mimeType, data: img.base64 });
337
+ const responseFormat = { type: 'audio' };
338
+ if (opts.audioFormat)
339
+ responseFormat.audio_format = opts.audioFormat;
340
+ const body = { model, input: inputParts, response_format: responseFormat };
341
+ if (opts.previousInteractionId !== undefined)
342
+ body.previous_interaction_id = opts.previousInteractionId;
343
+ const data = await this.postInteraction(body, opts);
344
+ const { audios, text } = this.extractInteraction(data);
345
+ if (audios.length === 0) {
346
+ throw new McpToolError('Gemini returned no audio', {
347
+ hint: 'The request may have been blocked by a safety filter — try rephrasing the prompt.',
348
+ });
349
+ }
350
+ return { id: data.id, audios, text };
351
+ }
352
+ /** POST `/interactions` with the chained-404 retry. Shared by all media. */
353
+ async postInteraction(body, opts) {
291
354
  const { interactionsApi } = this.apisFor(resolveTimeoutMs(opts.timeoutMs, opts.imageSize));
292
- let data;
293
355
  for (let attempt = 0;; attempt++) {
294
356
  try {
295
- data = await interactionsApi.fetchJson('POST', '/interactions', {
296
- body,
297
- });
298
- break;
357
+ return await interactionsApi.fetchJson('POST', '/interactions', { body });
299
358
  }
300
359
  catch (err) {
301
- // A 404 on a chained call means the referenced interaction wasn't
302
- // found. Two causes: (a) store lag right after the id was created —
303
- // retry with backoff; (b) genuinely gone — interactions are retained
304
- // 55 days (paid tier) / 1 day (free tier) and are scoped to the API
305
- // key's project. The chain is recoverable from the last output file,
306
- // so the exhausted-retries error says how.
360
+ // A 404 on a chained call means the referenced interaction wasn't found:
361
+ // (a) store lag right after the id was created — retry with backoff; or
362
+ // (b) genuinely gone — interactions are retained 55 days (paid) / 1 day
363
+ // (free), scoped to the API key's project. The chain is recoverable from
364
+ // the last output file, so the exhausted-retries error says how.
307
365
  if (!(opts.previousInteractionId && err instanceof ApiError && err.status === 404))
308
366
  throw err;
309
367
  if (attempt < CHAIN_404_RETRIES) {
@@ -311,26 +369,27 @@ export class GeminiClient {
311
369
  continue;
312
370
  }
313
371
  throw new McpToolError(`Previous interaction "${opts.previousInteractionId}" was not found (retried ${CHAIN_404_RETRIES}× — 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.`, {
314
- 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.',
372
+ 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.',
315
373
  cause: err,
316
374
  });
317
375
  }
318
376
  }
319
- // Only surface the `model_output` step — that's the caller-facing result.
320
- // `thought` steps hold internal reasoning (and, with includeThoughts, draft
321
- // "thinking" images); collecting those would leak reasoning into `text` and
322
- // pollute the returned images. The verified contract puts the output image
323
- // in `model_output`.
377
+ }
378
+ /**
379
+ * Collect `model_output` media (image/video/audio) + text + grounding from an
380
+ * interactions response. Only the `model_output` step is surfaced — `thought`
381
+ * steps (internal reasoning / draft images) are dropped on purpose so they
382
+ * can't leak into text or pollute the outputs. Tolerant of snake/camel MIME.
383
+ */
384
+ extractInteraction(data, searchTypes) {
324
385
  const images = [];
386
+ const videos = [];
387
+ const audios = [];
325
388
  const textParts = [];
326
389
  const searchQueries = [];
327
390
  const searchSuggestions = new Set();
328
391
  for (const step of data.steps ?? []) {
329
392
  // Grounding queries live on the `google_search_call` step's arguments.
330
- // (The `google_search_result` step only carries HTML `search_suggestions`
331
- // chips — no clean source uri/title list like generateContent's
332
- // groundingChunks — so interact surfaces queries, plus the suggestion
333
- // chips when image_search was requested, ToS below.)
334
393
  if (step.type === 'google_search_call') {
335
394
  for (const q of step.arguments?.queries ?? [])
336
395
  if (q?.trim())
@@ -338,11 +397,9 @@ export class GeminiClient {
338
397
  continue;
339
398
  }
340
399
  // ToS: image_search results' `search_suggestions` chips MUST be displayed
341
- // by the caller, so surface them (deduped — the chips repeat) only when
342
- // image_search grounding was requested; web-only grounding keeps the
343
- // lean queries-only meta.
400
+ // by the caller — surface them (deduped) only when image_search was asked.
344
401
  if (step.type === 'google_search_result') {
345
- if (opts.searchTypes?.includes('image_search')) {
402
+ if (searchTypes?.includes('image_search')) {
346
403
  for (const r of step.result ?? [])
347
404
  if (r.search_suggestions?.trim())
348
405
  searchSuggestions.add(r.search_suggestions);
@@ -353,8 +410,14 @@ export class GeminiClient {
353
410
  continue;
354
411
  for (const parts of [step.content ?? [], step.summary ?? []]) {
355
412
  for (const part of parts) {
356
- if (part.type === 'image' && part.data) {
357
- images.push({ base64: part.data, mimeType: part.mime_type ?? 'image/jpeg' });
413
+ const mime = part.mime_type ?? part.mimeType;
414
+ if (part.data) {
415
+ if (part.type === 'image')
416
+ images.push({ base64: part.data, mimeType: mime ?? 'image/jpeg' });
417
+ else if (part.type === 'video')
418
+ videos.push({ base64: part.data, mimeType: mime ?? 'video/mp4' });
419
+ else if (part.type === 'audio')
420
+ audios.push({ base64: part.data, mimeType: mime ?? 'audio/mpeg' });
358
421
  }
359
422
  else if (part.type === 'text' && part.text?.trim()) {
360
423
  textParts.push(part.text);
@@ -362,12 +425,6 @@ export class GeminiClient {
362
425
  }
363
426
  }
364
427
  }
365
- if (images.length === 0) {
366
- throw new McpToolError('Gemini returned no image', {
367
- hint: 'The request may have been blocked by safety filters — try rephrasing the prompt.',
368
- });
369
- }
370
- const resultText = textParts.join('\n') || undefined;
371
428
  let grounding;
372
429
  if (searchQueries.length > 0 || searchSuggestions.size > 0) {
373
430
  grounding = {};
@@ -376,7 +433,7 @@ export class GeminiClient {
376
433
  if (searchSuggestions.size > 0)
377
434
  grounding.search_suggestions = [...searchSuggestions];
378
435
  }
379
- return { id: data.id, images, text: resultText, grounding };
436
+ return { images, videos, audios, text: textParts.join('\n') || undefined, grounding };
380
437
  }
381
438
  }
382
439
  /** Module-level singleton shared by every tool module (deferred-config-error). */
package/dist/images.js CHANGED
@@ -49,14 +49,42 @@ function sniffMimeBytes(buf) {
49
49
  }
50
50
  return 'image/png';
51
51
  }
52
- /** Decode base64 image bytes and write to disk (creating dir). Returns the absolute path. */
53
- export async function writeImage(dir, base, base64, mimeType) {
52
+ /** File extension for a media MIME (image/video/audio). Falls back to the MIME
53
+ * subtype, so an unforeseen preview type still writes with a sane extension. */
54
+ export function mediaExt(mimeType) {
55
+ const m = mimeType.toLowerCase();
56
+ if (m.includes('jpeg'))
57
+ return 'jpg';
58
+ if (m.includes('webp'))
59
+ return 'webp';
60
+ if (m.includes('png'))
61
+ return 'png';
62
+ if (m.includes('mp4'))
63
+ return 'mp4';
64
+ if (m.includes('quicktime'))
65
+ return 'mov';
66
+ if (m.includes('webm'))
67
+ return 'webm';
68
+ if (m.includes('wav'))
69
+ return 'wav';
70
+ if (m.includes('mpeg') || m.includes('mp3'))
71
+ return 'mp3';
72
+ if (m.includes('ogg'))
73
+ return 'ogg';
74
+ return m.split('/')[1]?.split(';')[0]?.replace(/^x-/, '') || 'bin';
75
+ }
76
+ /** Decode base64 media bytes (image/video/audio) and write to disk (creating
77
+ * dir), picking the extension from the MIME. Returns the absolute path. */
78
+ export async function writeMedia(dir, base, base64, mimeType) {
54
79
  await mkdir(dir, { recursive: true });
55
- const ext = mimeType.includes('jpeg') ? 'jpg' : mimeType.includes('webp') ? 'webp' : 'png';
56
- const path = await uniquePath(dir, base, ext);
80
+ const path = await uniquePath(dir, base, mediaExt(mimeType));
57
81
  await writeFile(path, Buffer.from(base64, 'base64'));
58
82
  return resolve(path);
59
83
  }
84
+ /** Decode base64 image bytes and write to disk. Returns the absolute path. */
85
+ export async function writeImage(dir, base, base64, mimeType) {
86
+ return writeMedia(dir, base, base64, mimeType);
87
+ }
60
88
  /**
61
89
  * Write `<imagePath>.json` recording generation metadata (notably the
62
90
  * interaction id) next to a written image. The MCP host's tools/call timeout
@@ -208,8 +236,9 @@ export async function gatherImageInputs(opts) {
208
236
  }
209
237
  /** Caller-supplied filename → safe base name (no extension). */
210
238
  export function baseName(name) {
211
- // Strip known image extensions
212
- const stripped = name.replace(/\.(png|jpe?g|webp)$/i, '');
239
+ // Strip a known media extension (image / video / audio) so writeMedia doesn't
240
+ // double it (e.g. "clip.mp4" → "clip" → "clip.mp4").
241
+ const stripped = name.replace(/\.(png|jpe?g|webp|mp4|mov|webm|mp3|wav|ogg)$/i, '');
213
242
  // Replace non-safe chars with hyphens, collapse repeats, trim edges
214
243
  const safe = stripped
215
244
  .replace(/[^A-Za-z0-9._-]+/g, '-')
package/dist/index.js CHANGED
@@ -5,6 +5,9 @@ import { registerModelTools } from './tools/models.js';
5
5
  import { registerGenerateTools } from './tools/generate.js';
6
6
  import { registerSetTools } from './tools/set.js';
7
7
  import { registerInteractTools } from './tools/interact.js';
8
+ import { registerVideoTools } from './tools/video.js';
9
+ import { registerMusicTools } from './tools/music.js';
10
+ import { registerJobTools } from './tools/jobs.js';
8
11
  // The GeminiClient is a module-level singleton (imported by each tool module)
9
12
  // that defers its config error to the first request — so the server boots and
10
13
  // answers the host's install-time tools/list probe even without GEMINI_API_KEY.
@@ -12,5 +15,5 @@ await runMcp({
12
15
  name: 'gemini-mcp',
13
16
  version: VERSION,
14
17
  banner: '[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.',
15
- tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools],
18
+ tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools],
16
19
  });
package/dist/jobs.js ADDED
Binary file
package/dist/models.js CHANGED
@@ -5,6 +5,11 @@
5
5
  * `model` param when the task warrants it (see MODEL_CHOICE_GUIDE).
6
6
  */
7
7
  export const DEFAULT_IMAGE_MODEL = 'gemini-3.1-flash-image';
8
+ /** Default video model (omni, Interactions API). Preview — overridable per-call. */
9
+ export const DEFAULT_VIDEO_MODEL = 'gemini-omni-flash-preview';
10
+ /** Default music model (Lyria clips, Interactions API). `lyria-3-pro-preview` is
11
+ * the longer-form opt-in; both are preview and need a funded account. */
12
+ export const DEFAULT_MUSIC_MODEL = 'lyria-3-clip-preview';
8
13
  /** Per-call → env override → hardcoded default. Blank/whitespace counts as unset. */
9
14
  export function resolveModel(perCall, envOverride) {
10
15
  return perCall?.trim() || envOverride?.trim() || DEFAULT_IMAGE_MODEL;
@@ -3,7 +3,8 @@ import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
4
  import { client } from '../client.js';
5
5
  import { slugify, baseName, gatherImageInputs } from '../images.js';
6
- import { emit, sharedImageSchema, pickSeed, buildMeta, resolveVideoInput, videoPathSchema, withProgressHeartbeat } from './shared.js';
6
+ import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat } from './shared.js';
7
+ import { dispatch, fingerprintRequest } from '../jobs.js';
7
8
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
8
9
  const GENERATE_ENDPOINT = '/v1beta/models/{model}:generateContent';
9
10
  const SEND_LOCAL_ACTION = 'Send local file input(s) to the Gemini API';
@@ -12,7 +13,7 @@ const SEND_LOCAL_ACTION = 'Send local file input(s) to the Gemini API';
12
13
  const REFINE_HINT = 'To iteratively refine this image, use gemini_interact (pass the output file via `images` on the first call, ' +
13
14
  'then chain `previous_interaction_id` on each subsequent call).';
14
15
  export function registerGenerateTools(server) {
15
- server.registerTool('gemini_generate_image', {
16
+ server.registerTool('gemini_image_generate', {
16
17
  description: 'Generate image(s) from a text prompt with a Gemini image model (Nano Banana / Nano Banana Pro). ' +
17
18
  'If the result will likely be refined iteratively, prefer gemini_interact (multi-turn) as the entry point.',
18
19
  annotations: { readOnlyHint: false, openWorldHint: true },
@@ -35,57 +36,70 @@ export function registerGenerateTools(server) {
35
36
  if (gate)
36
37
  return gate;
37
38
  const count = args.count ?? 1;
38
- const seed = pickSeed(args.seed);
39
39
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
40
- const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
41
- const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
42
- // Upload ONCE (before the count loop) — every generate call references
43
- // the same files/… uri.
44
- const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args));
45
- const named = [];
46
- let capturedText;
47
- // For count>1, surface the FIRST call's grounding (each call grounds
48
- // independently; one representative set of sources beats concatenating N).
49
- let capturedGrounding;
50
- await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : 'image'} (${model})`, async () => {
51
- for (let i = 0; i < count; i++) {
52
- // Distinct seed per image (seed+0 for the single-image case == echoed seed)
53
- // so count>1 yields N *different* images, not N duplicates.
54
- const result = await client.generate({
55
- prompt: args.prompt,
56
- images: refInputs.length > 0 ? refInputs : undefined,
57
- model: args.model,
58
- aspectRatio: args.aspect_ratio,
59
- imageSize: args.image_size,
60
- seed: seed + i,
61
- thinkingLevel: args.thinking_level,
62
- googleSearch: args.google_search,
63
- videoUrl: video.videoUrl,
64
- videoMimeType: video.videoMimeType,
65
- timeoutMs: args.timeout_ms,
66
- });
67
- const { images: [img], text } = result;
68
- if (text && !capturedText)
69
- capturedText = text;
70
- if (result.grounding && !capturedGrounding)
71
- capturedGrounding = result.grounding;
72
- named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, '0')}` });
73
- }
40
+ // Fingerprint the request identity (excl. server-picked seed / output path)
41
+ // so a blind re-issue after a host timeout dedups instead of re-billing.
42
+ const fingerprint = fingerprintRequest('gemini_image_generate', {
43
+ model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
44
+ thinking_level: args.thinking_level, google_search: args.google_search, count,
45
+ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
46
+ video_url: args.video_url, video_path: args.video_path,
47
+ });
48
+ return dispatch({ toolName: 'gemini_image_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
49
+ const seed = pickSeed(args.seed);
50
+ const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
51
+ const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
52
+ // Upload ONCE (before the count loop) — every generate call references
53
+ // the same files/… uri.
54
+ const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args));
55
+ const named = [];
56
+ let capturedText;
57
+ // For count>1, surface the FIRST call's grounding (each call grounds
58
+ // independently; one representative set of sources beats concatenating N).
59
+ let capturedGrounding;
60
+ await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : 'image'} (${model})`, async () => {
61
+ for (let i = 0; i < count; i++) {
62
+ // Distinct seed per image (seed+0 for the single-image case == echoed seed)
63
+ // so count>1 yields N *different* images, not N duplicates.
64
+ const result = await client.generate({
65
+ prompt: args.prompt,
66
+ images: refInputs.length > 0 ? refInputs : undefined,
67
+ model: args.model,
68
+ aspectRatio: args.aspect_ratio,
69
+ imageSize: args.image_size,
70
+ seed: seed + i,
71
+ thinkingLevel: args.thinking_level,
72
+ googleSearch: args.google_search,
73
+ videoUrl: video.videoUrl,
74
+ videoMimeType: video.videoMimeType,
75
+ timeoutMs: args.timeout_ms,
76
+ });
77
+ const { images: [img], text } = result;
78
+ if (text && !capturedText)
79
+ capturedText = text;
80
+ if (result.grounding && !capturedGrounding)
81
+ capturedGrounding = result.grounding;
82
+ named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, '0')}` });
83
+ }
84
+ });
85
+ const meta = buildMeta(model, seed, args);
86
+ if (capturedText)
87
+ meta.text = capturedText;
88
+ if (capturedGrounding)
89
+ meta.grounding = capturedGrounding;
90
+ if (video.videoFileMeta)
91
+ meta.video_file = video.videoFileMeta;
92
+ meta.hint = REFINE_HINT;
93
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size, count });
94
+ if (risk)
95
+ meta.timeout_risk = risk;
96
+ return emit(named, args, meta);
74
97
  });
75
- const meta = buildMeta(model, seed, args);
76
- if (capturedText)
77
- meta.text = capturedText;
78
- if (capturedGrounding)
79
- meta.grounding = capturedGrounding;
80
- if (video.videoFileMeta)
81
- meta.video_file = video.videoFileMeta;
82
- meta.hint = REFINE_HINT;
83
- return emit(named, args, meta);
84
98
  });
85
- server.registerTool('gemini_edit_image', {
99
+ server.registerTool('gemini_image_edit', {
86
100
  description: 'Edit or compose images: provide one or more input images (paths or base64), plus a text instruction. ' +
87
101
  'For a SERIES of successive edits to the same image, prefer gemini_interact (multi-turn) — it keeps edit context ' +
88
- 'and avoids re-processing the full image each round; use gemini_edit_image for one-off edits or composing multiple distinct inputs. ' +
102
+ 'and avoids re-processing the full image each round; use gemini_image_edit for one-off edits or composing multiple distinct inputs. ' +
89
103
  'Gemini over-preserves the input; there is no edit-strength control — for large structural changes, reroll with a different `seed` or more forceful wording.',
90
104
  annotations: { readOnlyHint: false, openWorldHint: true },
91
105
  inputSchema: {
@@ -102,33 +116,43 @@ export function registerGenerateTools(server) {
102
116
  if (!hasPaths && !hasBase64 && !args.from_clipboard) {
103
117
  throw new McpToolError('Provide at least one input image via `images`, `images_base64`, or `from_clipboard`.');
104
118
  }
105
- // Confirm-gate local file inputs (see gemini_generate_image). base64 /
119
+ // Confirm-gate local file inputs (see gemini_image_generate). base64 /
106
120
  // clipboard inputs are not local-path reads and pass through ungated.
107
121
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
108
122
  if (gate)
109
123
  return gate;
110
- const seed = pickSeed(args.seed);
111
124
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
112
- const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
113
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
114
- const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
115
- prompt: args.prompt,
116
- images: inputs,
117
- model: args.model,
118
- aspectRatio: args.aspect_ratio,
119
- imageSize: args.image_size,
120
- seed,
121
- thinkingLevel: args.thinking_level,
122
- googleSearch: args.google_search,
123
- timeoutMs: args.timeout_ms,
124
- }));
125
- const { images: [img], text } = result;
126
- const meta = buildMeta(model, seed, args);
127
- if (text)
128
- meta.text = text;
129
- if (result.grounding)
130
- meta.grounding = result.grounding;
131
- meta.hint = REFINE_HINT;
132
- return emit([{ image: img, base: slug }], args, meta);
125
+ const fingerprint = fingerprintRequest('gemini_image_edit', {
126
+ model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
127
+ thinking_level: args.thinking_level, google_search: args.google_search,
128
+ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
129
+ });
130
+ return dispatch({ toolName: 'gemini_image_edit', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
131
+ const seed = pickSeed(args.seed);
132
+ const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
133
+ const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
134
+ const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
135
+ prompt: args.prompt,
136
+ images: inputs,
137
+ model: args.model,
138
+ aspectRatio: args.aspect_ratio,
139
+ imageSize: args.image_size,
140
+ seed,
141
+ thinkingLevel: args.thinking_level,
142
+ googleSearch: args.google_search,
143
+ timeoutMs: args.timeout_ms,
144
+ }));
145
+ const { images: [img], text } = result;
146
+ const meta = buildMeta(model, seed, args);
147
+ if (text)
148
+ meta.text = text;
149
+ if (result.grounding)
150
+ meta.grounding = result.grounding;
151
+ meta.hint = REFINE_HINT;
152
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size });
153
+ if (risk)
154
+ meta.timeout_risk = risk;
155
+ return emit([{ image: img, base: slug }], args, meta);
156
+ });
133
157
  });
134
158
  }
@@ -3,7 +3,8 @@ import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
4
  import { client } from '../client.js';
5
5
  import { slugify, baseName, gatherImageInputs, resolveImagePath, writeSidecar } from '../images.js';
6
- import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, withProgressHeartbeat } from './shared.js';
6
+ import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, withProgressHeartbeat } from './shared.js';
7
+ import { dispatch, fingerprintRequest } from '../jobs.js';
7
8
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
8
9
  // The most recent interaction id this server process created — what
9
10
  // `continue_last: true` resumes. In-memory only: an MCP server lives for the
@@ -118,6 +119,8 @@ export function registerInteractTools(server) {
118
119
  .describe('Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video→image; use a Flash model e.g. gemini-3.1-flash-image)'),
119
120
  video_path: videoPathSchema,
120
121
  timeout_ms: timeoutMsSchema,
122
+ idempotency_key: idempotencyKeySchema,
123
+ async: asyncSchema,
121
124
  from_clipboard: z
122
125
  .boolean()
123
126
  .optional()
@@ -146,57 +149,69 @@ export function registerInteractTools(server) {
146
149
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Send local file input(s) to the Gemini Interactions API', '/v1beta/interactions', images, args.video_path);
147
150
  if (gate)
148
151
  return gate;
149
- const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
150
- const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args));
151
- const r = await withProgressHeartbeat(extra, 'Generating image (Gemini Interactions API)', () => client.interact({
152
- input: args.input,
153
- images: inputs.length ? inputs : undefined,
154
- model: args.model,
155
- aspectRatio: args.aspect_ratio,
156
- imageSize: args.image_size,
157
- thinkingLevel: args.thinking_level,
158
- previousInteractionId,
159
- googleSearch: args.google_search,
160
- searchTypes: args.search_types,
161
- videoUrl: video.videoUrl,
162
- videoMimeType: video.videoMimeType,
163
- timeoutMs: args.timeout_ms,
164
- }));
165
- lastInteractionId = r.id;
166
152
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
167
- const meta = { model, interaction_id: r.id };
168
- if (previousInteractionId)
169
- meta.previous_interaction_id = previousInteractionId;
170
- if (dropped.length)
171
- meta.dropped_previous_output = dropped;
172
- meta.hint =
173
- `To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true) — ` +
174
- 'do NOT re-attach the returned image as an input; the interaction already contains it.';
175
- if (r.text)
176
- meta.text = r.text;
177
- if (r.grounding)
178
- meta.grounding = r.grounding;
179
- if (video.videoFileMeta)
180
- meta.video_file = video.videoFileMeta;
181
- const slug = args.filename ? baseName(args.filename) : slugify(args.input);
182
- const named = r.images.map((image, i) => ({
183
- image,
184
- base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
185
- }));
186
- // writeImage returns already-resolved absolute paths.
187
- return emit(named, args, meta, async (paths) => {
188
- for (const p of paths)
189
- writtenOutputs.add(p);
190
- // Sidecar per image so the interaction id survives a lost MCP response
191
- // (host timeout). Best-effort: the image is already on disk and the id
192
- // is in the result — a sidecar failure shouldn't fail the call.
193
- try {
153
+ const fingerprint = fingerprintRequest('gemini_interact', {
154
+ model, input: args.input, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
155
+ thinking_level: args.thinking_level, previous_interaction_id: previousInteractionId,
156
+ google_search: args.google_search, search_types: args.search_types,
157
+ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
158
+ video_url: args.video_url, video_path: args.video_path,
159
+ });
160
+ return dispatch({ toolName: 'gemini_interact', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
161
+ const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
162
+ const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args));
163
+ const r = await withProgressHeartbeat(extra, 'Generating image (Gemini Interactions API)', () => client.interact({
164
+ input: args.input,
165
+ images: inputs.length ? inputs : undefined,
166
+ model: args.model,
167
+ aspectRatio: args.aspect_ratio,
168
+ imageSize: args.image_size,
169
+ thinkingLevel: args.thinking_level,
170
+ previousInteractionId,
171
+ googleSearch: args.google_search,
172
+ searchTypes: args.search_types,
173
+ videoUrl: video.videoUrl,
174
+ videoMimeType: video.videoMimeType,
175
+ timeoutMs: args.timeout_ms,
176
+ }));
177
+ lastInteractionId = r.id;
178
+ const meta = { model, interaction_id: r.id };
179
+ if (previousInteractionId)
180
+ meta.previous_interaction_id = previousInteractionId;
181
+ if (dropped.length)
182
+ meta.dropped_previous_output = dropped;
183
+ meta.hint =
184
+ `To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true) — ` +
185
+ 'do NOT re-attach the returned image as an input; the interaction already contains it.';
186
+ if (r.text)
187
+ meta.text = r.text;
188
+ if (r.grounding)
189
+ meta.grounding = r.grounding;
190
+ if (video.videoFileMeta)
191
+ meta.video_file = video.videoFileMeta;
192
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size });
193
+ if (risk)
194
+ meta.timeout_risk = risk;
195
+ const slug = args.filename ? baseName(args.filename) : slugify(args.input);
196
+ const named = r.images.map((image, i) => ({
197
+ image,
198
+ base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
199
+ }));
200
+ // writeImage returns already-resolved absolute paths.
201
+ return emit(named, args, meta, async (paths) => {
194
202
  for (const p of paths)
195
- await writeSidecar(p, { ...meta, images: paths });
196
- }
197
- catch (err) {
198
- meta.sidecar_error = err instanceof Error ? err.message : String(err);
199
- }
203
+ writtenOutputs.add(p);
204
+ // Sidecar per image so the interaction id survives a lost MCP response
205
+ // (host timeout). Best-effort: the image is already on disk and the id
206
+ // is in the result — a sidecar failure shouldn't fail the call.
207
+ try {
208
+ for (const p of paths)
209
+ await writeSidecar(p, { ...meta, images: paths });
210
+ }
211
+ catch (err) {
212
+ meta.sidecar_error = err instanceof Error ? err.message : String(err);
213
+ }
214
+ });
200
215
  });
201
216
  });
202
217
  }