@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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +17 -6
- package/SKILL.md +44 -16
- package/dist/bundle.js +630 -160
- package/dist/client.js +92 -35
- package/dist/images.js +35 -6
- package/dist/index.js +4 -1
- package/dist/jobs.js +0 -0
- package/dist/models.js +5 -0
- package/dist/tools/generate.js +95 -71
- package/dist/tools/interact.js +65 -50
- package/dist/tools/jobs.js +17 -0
- package/dist/tools/music.js +90 -0
- package/dist/tools/set.js +55 -41
- package/dist/tools/shared.js +99 -20
- package/dist/tools/video.js +92 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
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
|
-
|
|
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
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
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
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
357
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
53
|
-
|
|
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
|
|
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
|
|
212
|
-
|
|
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;
|
package/dist/tools/generate.js
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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('
|
|
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
|
|
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
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
}
|
package/dist/tools/interact.js
CHANGED
|
@@ -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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
}
|