@chrischall/gemini-mcp 0.9.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 +6 -4
- package/SKILL.md +44 -16
- package/dist/bundle.js +285 -43
- package/dist/client.js +92 -35
- package/dist/images.js +35 -6
- package/dist/index.js +3 -1
- package/dist/models.js +5 -0
- package/dist/tools/generate.js +8 -8
- package/dist/tools/music.js +90 -0
- package/dist/tools/set.js +3 -3
- package/dist/tools/shared.js +26 -19
- 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,8 @@ 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';
|
|
8
10
|
import { registerJobTools } from './tools/jobs.js';
|
|
9
11
|
// The GeminiClient is a module-level singleton (imported by each tool module)
|
|
10
12
|
// that defers its config error to the first request — so the server boots and
|
|
@@ -13,5 +15,5 @@ await runMcp({
|
|
|
13
15
|
name: 'gemini-mcp',
|
|
14
16
|
version: VERSION,
|
|
15
17
|
banner: '[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.',
|
|
16
|
-
tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerJobTools],
|
|
18
|
+
tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools],
|
|
17
19
|
});
|
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
|
@@ -13,7 +13,7 @@ const SEND_LOCAL_ACTION = 'Send local file input(s) to the Gemini API';
|
|
|
13
13
|
const REFINE_HINT = 'To iteratively refine this image, use gemini_interact (pass the output file via `images` on the first call, ' +
|
|
14
14
|
'then chain `previous_interaction_id` on each subsequent call).';
|
|
15
15
|
export function registerGenerateTools(server) {
|
|
16
|
-
server.registerTool('
|
|
16
|
+
server.registerTool('gemini_image_generate', {
|
|
17
17
|
description: 'Generate image(s) from a text prompt with a Gemini image model (Nano Banana / Nano Banana Pro). ' +
|
|
18
18
|
'If the result will likely be refined iteratively, prefer gemini_interact (multi-turn) as the entry point.',
|
|
19
19
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
@@ -39,13 +39,13 @@ export function registerGenerateTools(server) {
|
|
|
39
39
|
const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
40
40
|
// Fingerprint the request identity (excl. server-picked seed / output path)
|
|
41
41
|
// so a blind re-issue after a host timeout dedups instead of re-billing.
|
|
42
|
-
const fingerprint = fingerprintRequest('
|
|
42
|
+
const fingerprint = fingerprintRequest('gemini_image_generate', {
|
|
43
43
|
model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
44
44
|
thinking_level: args.thinking_level, google_search: args.google_search, count,
|
|
45
45
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
46
46
|
video_url: args.video_url, video_path: args.video_path,
|
|
47
47
|
});
|
|
48
|
-
return dispatch({ toolName: '
|
|
48
|
+
return dispatch({ toolName: 'gemini_image_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
49
49
|
const seed = pickSeed(args.seed);
|
|
50
50
|
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
51
51
|
const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
@@ -96,10 +96,10 @@ export function registerGenerateTools(server) {
|
|
|
96
96
|
return emit(named, args, meta);
|
|
97
97
|
});
|
|
98
98
|
});
|
|
99
|
-
server.registerTool('
|
|
99
|
+
server.registerTool('gemini_image_edit', {
|
|
100
100
|
description: 'Edit or compose images: provide one or more input images (paths or base64), plus a text instruction. ' +
|
|
101
101
|
'For a SERIES of successive edits to the same image, prefer gemini_interact (multi-turn) — it keeps edit context ' +
|
|
102
|
-
'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. ' +
|
|
103
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.',
|
|
104
104
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
105
105
|
inputSchema: {
|
|
@@ -116,18 +116,18 @@ export function registerGenerateTools(server) {
|
|
|
116
116
|
if (!hasPaths && !hasBase64 && !args.from_clipboard) {
|
|
117
117
|
throw new McpToolError('Provide at least one input image via `images`, `images_base64`, or `from_clipboard`.');
|
|
118
118
|
}
|
|
119
|
-
// Confirm-gate local file inputs (see
|
|
119
|
+
// Confirm-gate local file inputs (see gemini_image_generate). base64 /
|
|
120
120
|
// clipboard inputs are not local-path reads and pass through ungated.
|
|
121
121
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
|
|
122
122
|
if (gate)
|
|
123
123
|
return gate;
|
|
124
124
|
const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
125
|
-
const fingerprint = fingerprintRequest('
|
|
125
|
+
const fingerprint = fingerprintRequest('gemini_image_edit', {
|
|
126
126
|
model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
127
127
|
thinking_level: args.thinking_level, google_search: args.google_search,
|
|
128
128
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
129
129
|
});
|
|
130
|
-
return dispatch({ toolName: '
|
|
130
|
+
return dispatch({ toolName: 'gemini_image_edit', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
131
131
|
const seed = pickSeed(args.seed);
|
|
132
132
|
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
133
133
|
const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { McpToolError } from '@chrischall/mcp-utils';
|
|
3
|
+
import { client } from '../client.js';
|
|
4
|
+
import { slugify, baseName, gatherImageInputs } from '../images.js';
|
|
5
|
+
import { DEFAULT_MUSIC_MODEL } from '../models.js';
|
|
6
|
+
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat } from './shared.js';
|
|
7
|
+
import { dispatch, fingerprintRequest } from '../jobs.js';
|
|
8
|
+
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
9
|
+
const MUSIC_MODELS = ['lyria-3-clip-preview', 'lyria-3-pro-preview'];
|
|
10
|
+
const AUDIO_FORMATS = ['mp3', 'wav'];
|
|
11
|
+
/** WAV is Lyria-3-Pro-only (docs); reject it on the clip model up front. */
|
|
12
|
+
function isProModel(model) { return /pro/i.test(model); }
|
|
13
|
+
// Most-recent music interaction id (this process), for continue_last.
|
|
14
|
+
let lastMusicInteractionId;
|
|
15
|
+
/** Test-only: clear the module-level session memory. */
|
|
16
|
+
export function __resetMusicMemory() { lastMusicInteractionId = undefined; }
|
|
17
|
+
export function registerMusicTools(server) {
|
|
18
|
+
server.registerTool('gemini_music_generate', {
|
|
19
|
+
description: 'Generate music from a text prompt (mood, genre, instruments, structure, or lyrics inline) via a Lyria model ' +
|
|
20
|
+
'(preview): lyria-3-clip-preview (~30s clips, default) or lyria-3-pro-preview (longer, WAV-capable). Written to ' +
|
|
21
|
+
'disk as MP3/WAV (or returned inline). Runs long — use `async: true` + gemini_get_result, or raise `timeout_ms`. ' +
|
|
22
|
+
'Preview model: needs a funded account.',
|
|
23
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
24
|
+
inputSchema: {
|
|
25
|
+
prompt: z.string().min(1).describe('Description of the music: mood, genre, instruments, tempo, structure, or lyrics'),
|
|
26
|
+
model: z.enum(MUSIC_MODELS).optional().describe(`Lyria model (default: ${DEFAULT_MUSIC_MODEL}). Pro is longer-form and supports WAV.`),
|
|
27
|
+
audio_format: z.enum(AUDIO_FORMATS).optional().describe('Output format (default mp3). wav is lyria-3-pro-preview-only.'),
|
|
28
|
+
images: z.array(z.string().min(1)).optional().describe('Optional reference image path(s) to condition the music'),
|
|
29
|
+
images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs'),
|
|
30
|
+
from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS clipboard as a reference'),
|
|
31
|
+
filename: z.string().optional().describe('Base filename for the output audio (extension stripped; default: slugified prompt)'),
|
|
32
|
+
output_dir: z.string().optional().describe('Directory to write audio to (default: $GEMINI_OUTPUT_DIR or cwd)'),
|
|
33
|
+
inline: z.boolean().optional().describe('Return base64 audio inline instead of writing to disk'),
|
|
34
|
+
previous_interaction_id: z.string().optional().describe('Interaction id to continue from'),
|
|
35
|
+
continue_last: z.boolean().optional().describe('Continue from the most recent music interaction this server created (explicit previous_interaction_id wins)'),
|
|
36
|
+
timeout_ms: timeoutMsSchema,
|
|
37
|
+
idempotency_key: idempotencyKeySchema,
|
|
38
|
+
async: asyncSchema,
|
|
39
|
+
confirm: schemaConfirm,
|
|
40
|
+
},
|
|
41
|
+
}, async (args, extra) => {
|
|
42
|
+
const model = args.model ?? DEFAULT_MUSIC_MODEL;
|
|
43
|
+
if (args.audio_format === 'wav' && !isProModel(model)) {
|
|
44
|
+
throw new McpToolError(`WAV output requires a Pro model; ${model} only produces MP3.`, {
|
|
45
|
+
hint: 'Use model: "lyria-3-pro-preview" for WAV, or drop audio_format to get MP3.',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
let previousInteractionId = args.previous_interaction_id;
|
|
49
|
+
if (!previousInteractionId && args.continue_last) {
|
|
50
|
+
if (!lastMusicInteractionId) {
|
|
51
|
+
throw new McpToolError('No previous music interaction to continue in this session.', {
|
|
52
|
+
hint: 'Generate music first, or pass an explicit previous_interaction_id.',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
previousInteractionId = lastMusicInteractionId;
|
|
56
|
+
}
|
|
57
|
+
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Send local image input(s) to the Gemini Lyria API', '/v1beta/interactions', args.images);
|
|
58
|
+
if (gate)
|
|
59
|
+
return gate;
|
|
60
|
+
const fingerprint = fingerprintRequest('gemini_music_generate', {
|
|
61
|
+
model, prompt: args.prompt, audio_format: args.audio_format,
|
|
62
|
+
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
63
|
+
previous_interaction_id: previousInteractionId,
|
|
64
|
+
});
|
|
65
|
+
return dispatch({ toolName: 'gemini_music_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
66
|
+
const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
67
|
+
const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client.generateMusic({
|
|
68
|
+
input: args.prompt,
|
|
69
|
+
images: inputs.length ? inputs : undefined,
|
|
70
|
+
model: args.model,
|
|
71
|
+
audioFormat: args.audio_format,
|
|
72
|
+
previousInteractionId,
|
|
73
|
+
timeoutMs: args.timeout_ms,
|
|
74
|
+
}));
|
|
75
|
+
lastMusicInteractionId = r.id;
|
|
76
|
+
const meta = { model, interaction_id: r.id };
|
|
77
|
+
if (previousInteractionId)
|
|
78
|
+
meta.previous_interaction_id = previousInteractionId;
|
|
79
|
+
if (r.text)
|
|
80
|
+
meta.text = r.text;
|
|
81
|
+
meta.hint = `To continue this track, call gemini_music_generate again with previous_interaction_id: "${r.id}" (or continue_last: true).`;
|
|
82
|
+
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
83
|
+
const named = r.audios.map((media, i) => ({
|
|
84
|
+
media,
|
|
85
|
+
base: r.audios.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
|
|
86
|
+
}));
|
|
87
|
+
return emitMedia(named, 'audio', { inline: args.inline, output_dir: args.output_dir }, meta);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
package/dist/tools/set.js
CHANGED
|
@@ -7,7 +7,7 @@ import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProg
|
|
|
7
7
|
import { dispatch, fingerprintRequest } from '../jobs.js';
|
|
8
8
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
9
9
|
export function registerSetTools(server) {
|
|
10
|
-
server.registerTool('
|
|
10
|
+
server.registerTool('gemini_image_set', {
|
|
11
11
|
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).',
|
|
12
12
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
13
13
|
inputSchema: {
|
|
@@ -32,13 +32,13 @@ export function registerSetTools(server) {
|
|
|
32
32
|
if (gate)
|
|
33
33
|
return gate;
|
|
34
34
|
const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
35
|
-
const fingerprint = fingerprintRequest('
|
|
35
|
+
const fingerprint = fingerprintRequest('gemini_image_set', {
|
|
36
36
|
model, master_prompt: args.master_prompt, scenes: args.scenes, count: args.count,
|
|
37
37
|
reference_mode: args.reference_mode, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
38
38
|
thinking_level: args.thinking_level, google_search: args.google_search,
|
|
39
39
|
master_images: args.master_images, master_images_base64: args.master_images_base64, from_clipboard: args.from_clipboard,
|
|
40
40
|
});
|
|
41
|
-
return dispatch({ toolName: '
|
|
41
|
+
return dispatch({ toolName: 'gemini_image_set', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
42
42
|
const seed = pickSeed(args.seed);
|
|
43
43
|
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 };
|
|
44
44
|
const slug = args.basename ? baseName(args.basename) : slugify(args.master_prompt);
|
package/dist/tools/shared.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, McpToolError, readEnvVar } from '@chrischall/mcp-utils';
|
|
3
3
|
import { client } from '../client.js';
|
|
4
|
-
import {
|
|
4
|
+
import { writeMedia, resolveOutputDir, resolveVideoPath, videoMimeType } from '../images.js';
|
|
5
5
|
/** Supported output aspect ratios (Gemini image API). */
|
|
6
6
|
export const ASPECT_RATIOS = ['1:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9', '1:4', '4:1', '1:8', '8:1'];
|
|
7
7
|
/** Supported output resolutions. `512` is Flash-only (0.5K); `1K`/`2K`/`4K` work on all image models. */
|
|
@@ -204,28 +204,35 @@ export function buildMeta(model, seed, opts) {
|
|
|
204
204
|
return meta;
|
|
205
205
|
}
|
|
206
206
|
/**
|
|
207
|
-
*
|
|
208
|
-
* return
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
* files; an async callback is awaited before the result is returned.
|
|
207
|
+
* Return generated media inline (base64 content blocks) or write to disk and
|
|
208
|
+
* return the paths. `kind` selects the MCP inline block type: `image`/`audio`
|
|
209
|
+
* are native MCP content types; **`video` has no inline block type, so it is
|
|
210
|
+
* always written to disk** (an `inline` request is downgraded and noted in the
|
|
211
|
+
* result). Disk mode returns `{ <kind>s: [paths], ...meta }`. `onWritten`
|
|
212
|
+
* receives the written paths (disk mode only), awaited before returning.
|
|
214
213
|
*/
|
|
215
|
-
export async function
|
|
216
|
-
|
|
217
|
-
|
|
214
|
+
export async function emitMedia(named, kind, opts, meta, onWritten) {
|
|
215
|
+
const inline = opts.inline && kind !== 'video';
|
|
216
|
+
if (inline) {
|
|
217
|
+
const blocks = named.map((n) => ({ type: kind, data: n.media.base64, mimeType: n.media.mimeType }));
|
|
218
218
|
if (meta && Object.keys(meta).length > 0) {
|
|
219
|
-
return {
|
|
220
|
-
content: [{ type: 'text', text: JSON.stringify(meta, null, 2) }, ...imageBlocks],
|
|
221
|
-
};
|
|
219
|
+
return { content: [{ type: 'text', text: JSON.stringify(meta, null, 2) }, ...blocks] };
|
|
222
220
|
}
|
|
223
|
-
return { content:
|
|
221
|
+
return { content: blocks };
|
|
224
222
|
}
|
|
225
223
|
const dir = resolveOutputDir(opts.output_dir);
|
|
226
|
-
const
|
|
224
|
+
const paths = [];
|
|
227
225
|
for (const n of named)
|
|
228
|
-
|
|
229
|
-
await onWritten?.(
|
|
230
|
-
|
|
226
|
+
paths.push(await writeMedia(dir, n.base, n.media.base64, n.media.mimeType));
|
|
227
|
+
await onWritten?.(paths);
|
|
228
|
+
// Note a downgraded inline-video request so the caller isn't left wondering.
|
|
229
|
+
const note = opts.inline && kind === 'video' ? { inline_unsupported: 'video has no MCP inline content type — written to disk instead' } : {};
|
|
230
|
+
return textResult({ [`${kind}s`]: paths, ...note, ...meta });
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Image convenience wrapper over {@link emitMedia} — preserves the original
|
|
234
|
+
* `emit(named, opts, meta, onWritten)` signature used by the image tools.
|
|
235
|
+
*/
|
|
236
|
+
export async function emit(named, opts, meta, onWritten) {
|
|
237
|
+
return emitMedia(named.map((n) => ({ media: n.image, base: n.base })), 'image', opts, meta, onWritten);
|
|
231
238
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { McpToolError } from '@chrischall/mcp-utils';
|
|
3
|
+
import { client } from '../client.js';
|
|
4
|
+
import { slugify, baseName, gatherImageInputs } from '../images.js';
|
|
5
|
+
import { DEFAULT_VIDEO_MODEL } from '../models.js';
|
|
6
|
+
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat } from './shared.js';
|
|
7
|
+
import { dispatch, fingerprintRequest } from '../jobs.js';
|
|
8
|
+
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
9
|
+
/** omni's own aspect-ratio enum — NOT the image tools' ASPECT_RATIOS. */
|
|
10
|
+
const VIDEO_ASPECT_RATIOS = ['16:9', '9:16'];
|
|
11
|
+
const VIDEO_TASKS = ['text_to_video', 'image_to_video', 'reference_to_video', 'edit'];
|
|
12
|
+
// Most-recent video interaction id (this process), for continue_last. Separate
|
|
13
|
+
// from the image interact memory — a "last video" and "last image" are distinct.
|
|
14
|
+
let lastVideoInteractionId;
|
|
15
|
+
/** Test-only: clear the module-level session memory. */
|
|
16
|
+
export function __resetVideoMemory() { lastVideoInteractionId = undefined; }
|
|
17
|
+
export function registerVideoTools(server) {
|
|
18
|
+
server.registerTool('gemini_video_generate', {
|
|
19
|
+
description: 'Generate a short video via the Gemini omni model (preview): text→video, image→video / reference→video ' +
|
|
20
|
+
'(supply reference image[s]), or edit a prior video (task: "edit" + previous_interaction_id / continue_last). ' +
|
|
21
|
+
'Output is written to disk as MP4 (video has no inline MCP block). Video runs long — use `async: true` to get a ' +
|
|
22
|
+
'job_id immediately and poll gemini_get_result, or raise `timeout_ms`. Preview model: needs a funded account.',
|
|
23
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
24
|
+
inputSchema: {
|
|
25
|
+
prompt: z.string().min(1).describe('Description of the video to generate (or the edit instruction when task=edit)'),
|
|
26
|
+
aspect_ratio: z.enum(VIDEO_ASPECT_RATIOS).optional().describe('Output aspect ratio (omni: 16:9 or 9:16)'),
|
|
27
|
+
task: z.enum(VIDEO_TASKS).optional().describe('text_to_video (default), image_to_video / reference_to_video (need image input), or edit (needs previous_interaction_id)'),
|
|
28
|
+
images: z.array(z.string().min(1)).optional().describe('Reference image path(s) for image_to_video / reference_to_video'),
|
|
29
|
+
images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs'),
|
|
30
|
+
from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS clipboard as a reference'),
|
|
31
|
+
filename: z.string().optional().describe('Base filename for the output video (extension stripped; default: slugified prompt)'),
|
|
32
|
+
output_dir: z.string().optional().describe('Directory to write the video to (default: $GEMINI_OUTPUT_DIR or cwd)'),
|
|
33
|
+
model: z
|
|
34
|
+
.string()
|
|
35
|
+
.regex(/^[\w.-]+$/, 'must be a bare model id (letters, digits, ".", "_", "-")')
|
|
36
|
+
.optional()
|
|
37
|
+
.describe(`Model id override (default: ${DEFAULT_VIDEO_MODEL})`),
|
|
38
|
+
previous_interaction_id: z.string().optional().describe('Interaction id to edit/continue (with task: "edit")'),
|
|
39
|
+
continue_last: z.boolean().optional().describe('Continue from the most recent video interaction this server created (explicit previous_interaction_id wins)'),
|
|
40
|
+
timeout_ms: timeoutMsSchema,
|
|
41
|
+
idempotency_key: idempotencyKeySchema,
|
|
42
|
+
async: asyncSchema,
|
|
43
|
+
confirm: schemaConfirm,
|
|
44
|
+
},
|
|
45
|
+
}, async (args, extra) => {
|
|
46
|
+
let previousInteractionId = args.previous_interaction_id;
|
|
47
|
+
if (!previousInteractionId && args.continue_last) {
|
|
48
|
+
if (!lastVideoInteractionId) {
|
|
49
|
+
throw new McpToolError('No previous video interaction to continue in this session.', {
|
|
50
|
+
hint: 'Generate a video first, or pass an explicit previous_interaction_id.',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
previousInteractionId = lastVideoInteractionId;
|
|
54
|
+
}
|
|
55
|
+
// Confirm-gate local file inputs (a prompt-injected `images` path could
|
|
56
|
+
// exfiltrate a local file); base64 / clipboard pass through ungated.
|
|
57
|
+
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Send local image input(s) to the Gemini omni API', '/v1beta/interactions', args.images);
|
|
58
|
+
if (gate)
|
|
59
|
+
return gate;
|
|
60
|
+
const model = args.model?.trim() || DEFAULT_VIDEO_MODEL;
|
|
61
|
+
const fingerprint = fingerprintRequest('gemini_video_generate', {
|
|
62
|
+
model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, task: args.task,
|
|
63
|
+
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
64
|
+
previous_interaction_id: previousInteractionId,
|
|
65
|
+
});
|
|
66
|
+
return dispatch({ toolName: 'gemini_video_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
67
|
+
const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
68
|
+
const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client.generateVideo({
|
|
69
|
+
input: args.prompt,
|
|
70
|
+
images: inputs.length ? inputs : undefined,
|
|
71
|
+
model: args.model,
|
|
72
|
+
aspectRatio: args.aspect_ratio,
|
|
73
|
+
task: args.task,
|
|
74
|
+
previousInteractionId,
|
|
75
|
+
timeoutMs: args.timeout_ms,
|
|
76
|
+
}));
|
|
77
|
+
lastVideoInteractionId = r.id;
|
|
78
|
+
const meta = { model, interaction_id: r.id };
|
|
79
|
+
if (previousInteractionId)
|
|
80
|
+
meta.previous_interaction_id = previousInteractionId;
|
|
81
|
+
if (r.text)
|
|
82
|
+
meta.text = r.text;
|
|
83
|
+
meta.hint = `To edit this video, call gemini_video_generate again with task: "edit" and previous_interaction_id: "${r.id}" (or continue_last: true).`;
|
|
84
|
+
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
85
|
+
const named = r.videos.map((media, i) => ({
|
|
86
|
+
media,
|
|
87
|
+
base: r.videos.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
|
|
88
|
+
}));
|
|
89
|
+
return emitMedia(named, 'video', { output_dir: args.output_dir }, meta);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Single source of the server version. release-please bumps the literal below. */
|
|
2
|
-
export const VERSION = '0.
|
|
2
|
+
export const VERSION = '1.0.0'; // x-release-please-version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrischall/gemini-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/gemini-mcp",
|
|
5
5
|
"description": "Gemini image-generation MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|