@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
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getJobResult } from '../jobs.js';
|
|
3
|
+
/**
|
|
4
|
+
* Poll tool for the async generation pattern (issue #52). A generation tool
|
|
5
|
+
* called with `async: true` returns a `job_id` immediately (instead of blocking
|
|
6
|
+
* past a host's tools/call timeout); this tool retrieves that job's status and,
|
|
7
|
+
* once complete, its normal result payload.
|
|
8
|
+
*/
|
|
9
|
+
export function registerJobTools(server) {
|
|
10
|
+
server.registerTool('gemini_get_result', {
|
|
11
|
+
description: 'Retrieve an async generation started with `async: true`. Pass the returned `job_id`: while running it reports status "running"; on completion it returns the normal result (image paths / inline images + meta); on failure it raises the recorded error. Jobs are per-process and expire ~10 min after completion — if a job id is unknown, check the output dir / <image>.json sidecar.',
|
|
12
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
13
|
+
inputSchema: {
|
|
14
|
+
job_id: z.string().min(1).describe('The job_id returned by a generation tool called with async: true'),
|
|
15
|
+
},
|
|
16
|
+
}, async (args) => getJobResult(args.job_id));
|
|
17
|
+
}
|
|
@@ -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
|
@@ -3,10 +3,11 @@ 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, withProgressHeartbeat } from './shared.js';
|
|
6
|
+
import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat } from './shared.js';
|
|
7
|
+
import { dispatch, fingerprintRequest } from '../jobs.js';
|
|
7
8
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
8
9
|
export function registerSetTools(server) {
|
|
9
|
-
server.registerTool('
|
|
10
|
+
server.registerTool('gemini_image_set', {
|
|
10
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).',
|
|
11
12
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
12
13
|
inputSchema: {
|
|
@@ -30,47 +31,60 @@ export function registerSetTools(server) {
|
|
|
30
31
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Send local master image input(s) to the Gemini API', '/v1beta/models/{model}:generateContent', args.master_images);
|
|
31
32
|
if (gate)
|
|
32
33
|
return gate;
|
|
33
|
-
const seed = pickSeed(args.seed);
|
|
34
34
|
const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
});
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
35
|
+
const fingerprint = fingerprintRequest('gemini_image_set', {
|
|
36
|
+
model, master_prompt: args.master_prompt, scenes: args.scenes, count: args.count,
|
|
37
|
+
reference_mode: args.reference_mode, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
38
|
+
thinking_level: args.thinking_level, google_search: args.google_search,
|
|
39
|
+
master_images: args.master_images, master_images_base64: args.master_images_base64, from_clipboard: args.from_clipboard,
|
|
40
|
+
});
|
|
41
|
+
return dispatch({ toolName: 'gemini_image_set', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
42
|
+
const seed = pickSeed(args.seed);
|
|
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
|
+
const slug = args.basename ? baseName(args.basename) : slugify(args.master_prompt);
|
|
45
|
+
// 1. master (optionally seeded from reference images)
|
|
46
|
+
const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
|
|
47
|
+
const named = [];
|
|
48
|
+
const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
|
|
49
|
+
const result = await client.generate({
|
|
50
|
+
prompt: args.master_prompt,
|
|
51
|
+
images: masterRefInputs.length > 0 ? masterRefInputs : undefined,
|
|
52
|
+
seed,
|
|
53
|
+
...cfg,
|
|
54
|
+
});
|
|
55
|
+
const master = result.images[0];
|
|
56
|
+
named.push({ image: master, base: `${slug}-master` });
|
|
57
|
+
// Only the master's text is surfaced (below). Per-scene text is intentionally
|
|
58
|
+
// dropped — a set returns one combined result and merging N captions is noise.
|
|
59
|
+
// 2. scene prompts (explicit, or N repeats of master_prompt for variations)
|
|
60
|
+
const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
|
|
61
|
+
const mode = args.reference_mode ?? 'master';
|
|
62
|
+
if (mode === 'chain') {
|
|
63
|
+
let ref = master;
|
|
64
|
+
for (let i = 0; i < scenePrompts.length; i++) {
|
|
65
|
+
const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
|
|
66
|
+
named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` });
|
|
67
|
+
ref = img;
|
|
68
|
+
}
|
|
60
69
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
else {
|
|
71
|
+
const scenes = await Promise.all(scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0])));
|
|
72
|
+
scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` }));
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
});
|
|
76
|
+
const masterText = masterResult.text;
|
|
77
|
+
const meta = buildMeta(model, seed, args);
|
|
78
|
+
if (masterText)
|
|
79
|
+
meta.text = masterText;
|
|
80
|
+
if (masterResult.grounding)
|
|
81
|
+
meta.grounding = masterResult.grounding;
|
|
82
|
+
// A set is master + N scenes in one tools/call — the most timeout-prone
|
|
83
|
+
// tool. Effective image count drives the risk hint.
|
|
84
|
+
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length });
|
|
85
|
+
if (risk)
|
|
86
|
+
meta.timeout_risk = risk;
|
|
87
|
+
return emit(named, args, meta);
|
|
67
88
|
});
|
|
68
|
-
const masterText = masterResult.text;
|
|
69
|
-
const meta = buildMeta(model, seed, args);
|
|
70
|
-
if (masterText)
|
|
71
|
-
meta.text = masterText;
|
|
72
|
-
if (masterResult.grounding)
|
|
73
|
-
meta.grounding = masterResult.grounding;
|
|
74
|
-
return emit(named, args, meta);
|
|
75
89
|
});
|
|
76
90
|
}
|
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. */
|
|
@@ -26,6 +26,26 @@ export const timeoutMsSchema = z
|
|
|
26
26
|
.positive()
|
|
27
27
|
.optional()
|
|
28
28
|
.describe('Upstream request timeout in ms for this call (default: $GEMINI_TIMEOUT_MS, else 60000 — or 120000 when image_size is 4K, which routinely runs past 60s)');
|
|
29
|
+
/**
|
|
30
|
+
* Idempotency key shared by every generation tool. A repeat call carrying the
|
|
31
|
+
* same key returns the recorded result (`reused: true`) instead of billing a
|
|
32
|
+
* fresh generation — the fix for a host timeout (`-32001`) prompting a blind
|
|
33
|
+
* re-issue of a call the server actually completed.
|
|
34
|
+
*/
|
|
35
|
+
export const idempotencyKeySchema = z
|
|
36
|
+
.string()
|
|
37
|
+
.min(1)
|
|
38
|
+
.optional()
|
|
39
|
+
.describe('Opaque idempotency key: a repeat call with the same key returns the recorded result (reused: true) instead of billing a new generation. Set it when retrying after a host timeout (-32001) to avoid a duplicate charge.');
|
|
40
|
+
/**
|
|
41
|
+
* Async escape hatch shared by every generation tool: return a `job_id`
|
|
42
|
+
* immediately so a long (Pro/4K) generation can't hit a host's fixed tools/call
|
|
43
|
+
* timeout (`-32001`). The caller polls `gemini_get_result`.
|
|
44
|
+
*/
|
|
45
|
+
export const asyncSchema = z
|
|
46
|
+
.boolean()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe('Run in the background and return a job_id immediately instead of the image, so a long (Pro/4K) generation cannot hit the host tools/call timeout (-32001). Poll gemini_get_result with the job_id to fetch the result (jobs are per-process and expire ~10 min after completion).');
|
|
29
49
|
/**
|
|
30
50
|
* The model/aspect/size/output fields every generation tool shares. Defined once
|
|
31
51
|
* here so descriptions can't drift between `generate.ts` and `set.ts`. Spread
|
|
@@ -49,6 +69,8 @@ export const sharedImageSchema = {
|
|
|
49
69
|
google_search: z.boolean().optional().describe('Ground the image in live Google Search results (current events, weather, data)'),
|
|
50
70
|
from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)'),
|
|
51
71
|
timeout_ms: timeoutMsSchema,
|
|
72
|
+
idempotency_key: idempotencyKeySchema,
|
|
73
|
+
async: asyncSchema,
|
|
52
74
|
};
|
|
53
75
|
/**
|
|
54
76
|
* Turn the `video_url` / `video_path` tool args into the client's video opts.
|
|
@@ -99,17 +121,41 @@ export async function withProgressHeartbeat(extra, message, fn) {
|
|
|
99
121
|
const send = extra?.sendNotification;
|
|
100
122
|
const env = Number(readEnvVar('GEMINI_HEARTBEAT_MS'));
|
|
101
123
|
const intervalMs = Number.isFinite(env) ? env : HEARTBEAT_DEFAULT_MS;
|
|
124
|
+
const debug = !!readEnvVar('GEMINI_DEBUG');
|
|
125
|
+
const active = progressToken !== undefined && !!send && intervalMs > 0;
|
|
126
|
+
// Diagnostics (opt-in via GEMINI_DEBUG). Goes to stderr — the only channel
|
|
127
|
+
// safe under the stdio transport, and where the host surfaces it (Claude
|
|
128
|
+
// Desktop: ~/Library/Logs/Claude/mcp-server-*.log). The active/inactive line
|
|
129
|
+
// reveals whether the host even asked for progress: a missing progressToken
|
|
130
|
+
// means the host cannot be kept alive by our heartbeat, so a long call will
|
|
131
|
+
// hit the host's own tools/call timeout no matter what we send.
|
|
132
|
+
if (debug) {
|
|
133
|
+
if (active) {
|
|
134
|
+
console.error(`[gemini-mcp] heartbeat active: progressToken present, interval=${intervalMs}ms — ${message}`);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
const reason = progressToken === undefined
|
|
138
|
+
? 'no progressToken from host (host cannot extend its tools/call timeout; long calls will hit it)'
|
|
139
|
+
: !send
|
|
140
|
+
? 'host provided no sendNotification channel'
|
|
141
|
+
: 'heartbeat disabled (GEMINI_HEARTBEAT_MS=0)';
|
|
142
|
+
console.error(`[gemini-mcp] heartbeat inactive: ${reason} — ${message}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
102
145
|
if (progressToken === undefined || !send || intervalMs <= 0)
|
|
103
146
|
return fn();
|
|
104
147
|
let ticks = 0;
|
|
105
148
|
const timer = setInterval(() => {
|
|
106
149
|
ticks++;
|
|
150
|
+
const elapsed = Math.round((ticks * intervalMs) / 1000);
|
|
151
|
+
if (debug)
|
|
152
|
+
console.error(`[gemini-mcp] heartbeat: ${message} — still working (${elapsed}s)`);
|
|
107
153
|
void send({
|
|
108
154
|
method: 'notifications/progress',
|
|
109
155
|
params: {
|
|
110
156
|
progressToken,
|
|
111
157
|
progress: ticks,
|
|
112
|
-
message: `${message} — still working (${
|
|
158
|
+
message: `${message} — still working (${elapsed}s)`,
|
|
113
159
|
},
|
|
114
160
|
}).catch(() => { });
|
|
115
161
|
}, intervalMs);
|
|
@@ -122,6 +168,32 @@ export async function withProgressHeartbeat(extra, message, fn) {
|
|
|
122
168
|
clearInterval(timer);
|
|
123
169
|
}
|
|
124
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Some configs routinely run long enough to trip a host's tools/call timeout —
|
|
173
|
+
* Pro models, 4K output, or multi-image counts. Claude Desktop caps at ~30s,
|
|
174
|
+
* ignores our progress heartbeat, and offers no config to raise it (verified: the
|
|
175
|
+
* bundled MCP client's hard limit does not reset on notifications/progress), so
|
|
176
|
+
* such a call surfaces as `-32001 Request timed out`. Returned as
|
|
177
|
+
* `meta.timeout_risk` so a caller that *does* get the result is steered off the
|
|
178
|
+
* slow path next time and knows the image still landed on disk when a host bails.
|
|
179
|
+
* `undefined` for fast configs (no field added).
|
|
180
|
+
*/
|
|
181
|
+
export function timeoutRiskHint(opts) {
|
|
182
|
+
// Delimiter-anchored so real Pro ids (gemini-3-pro-image, nano-banana-pro)
|
|
183
|
+
// match but incidental substrings (…-prototype-…, …-flash-proxy) don't.
|
|
184
|
+
const proModel = /(?:^|[-_./])pro(?:[-_./]|$)/i.test(opts.model);
|
|
185
|
+
const fourK = opts.imageSize === '4K';
|
|
186
|
+
const multi = (opts.count ?? 1) > 1;
|
|
187
|
+
if (!proModel && !fourK && !multi)
|
|
188
|
+
return undefined;
|
|
189
|
+
const why = [proModel && 'Pro model', fourK && '4K output', multi && `count=${opts.count}`]
|
|
190
|
+
.filter(Boolean)
|
|
191
|
+
.join(' + ');
|
|
192
|
+
return (`Slow config (${why}) can exceed a host's tools/call timeout (e.g. Claude Desktop ~30s, error -32001). ` +
|
|
193
|
+
`If the host gives up mid-generation the image is still written to disk (and gemini_interact leaves an ` +
|
|
194
|
+
`<image>.json sidecar with the interaction id) — no rerun needed, just look in the output dir. ` +
|
|
195
|
+
`For a faster in-band result use a Flash model, drop image_size to 1K/2K, or — in Claude Code — set MCP_TOOL_TIMEOUT.`);
|
|
196
|
+
}
|
|
125
197
|
/** Build the result metadata echoed to the caller (omitting unset optionals). */
|
|
126
198
|
export function buildMeta(model, seed, opts) {
|
|
127
199
|
const meta = { model, seed };
|
|
@@ -132,28 +204,35 @@ export function buildMeta(model, seed, opts) {
|
|
|
132
204
|
return meta;
|
|
133
205
|
}
|
|
134
206
|
/**
|
|
135
|
-
*
|
|
136
|
-
* return
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* 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.
|
|
142
213
|
*/
|
|
143
|
-
export async function
|
|
144
|
-
|
|
145
|
-
|
|
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 }));
|
|
146
218
|
if (meta && Object.keys(meta).length > 0) {
|
|
147
|
-
return {
|
|
148
|
-
content: [{ type: 'text', text: JSON.stringify(meta, null, 2) }, ...imageBlocks],
|
|
149
|
-
};
|
|
219
|
+
return { content: [{ type: 'text', text: JSON.stringify(meta, null, 2) }, ...blocks] };
|
|
150
220
|
}
|
|
151
|
-
return { content:
|
|
221
|
+
return { content: blocks };
|
|
152
222
|
}
|
|
153
223
|
const dir = resolveOutputDir(opts.output_dir);
|
|
154
|
-
const
|
|
224
|
+
const paths = [];
|
|
155
225
|
for (const n of named)
|
|
156
|
-
|
|
157
|
-
await onWritten?.(
|
|
158
|
-
|
|
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);
|
|
159
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>",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/gemini-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "1.0.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/gemini-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "1.0.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|