@chrischall/gemini-mcp 1.13.0 → 1.14.1
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 +12 -3
- package/dist/bundle.js +7112 -2400
- package/dist/client.js +38 -35
- package/dist/inputs.js +80 -3
- package/dist/jobs.js +47 -13
- package/dist/models.js +14 -4
- package/dist/pricing.js +9 -1
- package/dist/session.js +7 -6
- package/dist/storage/media.js +265 -42
- package/dist/tools/files.js +67 -11
- package/dist/tools/generate.js +5 -5
- package/dist/tools/interact.js +65 -32
- package/dist/tools/music.js +31 -44
- package/dist/tools/set.js +3 -3
- package/dist/tools/shared.js +72 -41
- package/dist/tools/uploads.js +6 -6
- package/dist/tools/video.js +27 -10
- package/dist/upload-url.js +3 -3
- package/dist/version.js +1 -1
- package/package.json +6 -6
- package/server.json +2 -2
- package/skills/gemini-mcp/SKILL.md +16 -8
package/dist/tools/interact.js
CHANGED
|
@@ -5,7 +5,8 @@ import { ChainedRequest404Error } from '../client.js';
|
|
|
5
5
|
import { slugify, baseName, resolveImagePath, writeSidecar, resolveOutputDir, readImageAsInline } from '../images.js';
|
|
6
6
|
import { resolveImageInputs } from '../inputs.js';
|
|
7
7
|
import { findInteractionImages, latestInteractionId } from '../sidecar.js';
|
|
8
|
-
import { emit, reportShape, resolveAspectRatio, orientationSchema, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
|
|
8
|
+
import { emit, reportShape, resolveAspectRatio, orientationSchema, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesBase64Schema } from './shared.js';
|
|
9
|
+
import { bytesToBase64 } from '../bytes.js';
|
|
9
10
|
import { fingerprintRequest } from '../jobs.js';
|
|
10
11
|
import { attachCost } from '../pricing.js';
|
|
11
12
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
@@ -36,6 +37,35 @@ function splitReattachedOutputs(images, writtenOutputs) {
|
|
|
36
37
|
}
|
|
37
38
|
return { kept, dropped };
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The image a given interaction produced, off disk, as inline input.
|
|
42
|
+
*
|
|
43
|
+
* `refs` is what the result reports as `reanchored_on`, so it is the caller's
|
|
44
|
+
* own vocabulary in each deployment: absolute paths here, object keys below.
|
|
45
|
+
*/
|
|
46
|
+
async function reanchorFromDisk(outputDir, interactionId) {
|
|
47
|
+
const refs = await findInteractionImages(outputDir, interactionId);
|
|
48
|
+
return { refs, carried: await Promise.all(refs.map((p) => readImageAsInline(p))) };
|
|
49
|
+
}
|
|
50
|
+
/** The same, from the object store, for a deployment with no filesystem. */
|
|
51
|
+
async function reanchorFromStore(client, interactionId) {
|
|
52
|
+
try {
|
|
53
|
+
const found = await client.mediaSink?.findByInteraction?.(interactionId);
|
|
54
|
+
if (!found)
|
|
55
|
+
return { refs: [], carried: [] };
|
|
56
|
+
const stored = await client.readStoredMedia(found.key);
|
|
57
|
+
return {
|
|
58
|
+
refs: [found.key],
|
|
59
|
+
carried: [{ base64: bytesToBase64(stored.bytes), mimeType: stored.mimeType }],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Recovery is best-effort: a store that cannot answer leaves the caller
|
|
64
|
+
// with the original 404, which is the honest outcome — never a re-anchor
|
|
65
|
+
// onto something else.
|
|
66
|
+
return { refs: [], carried: [] };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
39
69
|
export function registerInteractTools(server, client) {
|
|
40
70
|
// The sidecar/re-anchor half of this description is only true where there is
|
|
41
71
|
// a filesystem. Describing it anyway on the hosted connector would tell the
|
|
@@ -43,23 +73,21 @@ export function registerInteractTools(server, client) {
|
|
|
43
73
|
// description is built from the sink's capability, not hardcoded.
|
|
44
74
|
const onDiskRuntime = client.mediaSink?.persistsFiles ?? true;
|
|
45
75
|
const recoveryDescription = onDiskRuntime
|
|
46
|
-
? 'If a call times out
|
|
47
|
-
'
|
|
48
|
-
'
|
|
49
|
-
'
|
|
50
|
-
'
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
'
|
|
54
|
-
'
|
|
55
|
-
'
|
|
56
|
-
'gemini_get_result for long generations. ';
|
|
76
|
+
? 'If a call times out client-side the generation usually still completes: the image and an `<image>.json` sidecar ' +
|
|
77
|
+
'holding its interaction id land in the output dir, and `continue_last: true` still resumes it — look there before ' +
|
|
78
|
+
're-issuing, which bills a second generation. A chained 404 is re-anchored on the prior output and re-issued ' +
|
|
79
|
+
'un-chained (`chain_recovered`); a second 404 means the interaction id was not the cause — check the model id / ' +
|
|
80
|
+
'files uri. '
|
|
81
|
+
: 'Each result returns an image URL to open or share (`media[].url`) — show it to the user rather than assuming they ' +
|
|
82
|
+
'can see the image. There is no output dir here, but a lost response is still recoverable: the interaction id is ' +
|
|
83
|
+
'stored beside each image, so `continue_last: true` survives a restart and a chained 404 is re-anchored on the ' +
|
|
84
|
+
'image that interaction produced (reported as `chain_recovered`). gemini_list_recent_media shows what each stored ' +
|
|
85
|
+
'image was. Give long generations a `max_wait_ms` budget. ';
|
|
57
86
|
server.registerTool('gemini_interact', {
|
|
58
|
-
description: 'Preferred tool for iterative
|
|
59
|
-
|
|
60
|
-
'
|
|
61
|
-
'
|
|
62
|
-
'`continue_last: true` chains from this session\'s most recent interaction without threading the id. ' +
|
|
87
|
+
description: 'Preferred tool for iterative refinement of a single image — multi-turn generation and editing via the ' +
|
|
88
|
+
'Interactions API. To refine, pass the returned interaction `id` as `previous_interaction_id` on the next call; ' +
|
|
89
|
+
'do NOT start a new interaction or re-send the image for each tweak. `continue_last: true` chains from this ' +
|
|
90
|
+
"session's most recent interaction without threading the id. " +
|
|
63
91
|
recoveryDescription +
|
|
64
92
|
'Output is JPEG.',
|
|
65
93
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
@@ -79,11 +107,7 @@ export function registerInteractTools(server, client) {
|
|
|
79
107
|
.describe(`Paths to reference input images. ${NEW_REFERENCES_ONLY}`),
|
|
80
108
|
images_url: imagesUrlSchema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
|
|
81
109
|
images_file_uris: imagesFileUrisSchema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
|
|
82
|
-
images_base64:
|
|
83
|
-
.array(z.string().min(1))
|
|
84
|
-
.optional()
|
|
85
|
-
.describe(`Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, ` +
|
|
86
|
-
`which keep image bytes out of the conversation. ${NEW_REFERENCES_ONLY}`),
|
|
110
|
+
images_base64: imagesBase64Schema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
|
|
87
111
|
model: z
|
|
88
112
|
.string()
|
|
89
113
|
.optional()
|
|
@@ -153,15 +177,23 @@ export function registerInteractTools(server, client) {
|
|
|
153
177
|
let continuedFromSidecar = false;
|
|
154
178
|
if (!previousInteractionId && args.continue_last) {
|
|
155
179
|
previousInteractionId = client.session.lastInteractionId;
|
|
156
|
-
if (!previousInteractionId
|
|
157
|
-
|
|
180
|
+
if (!previousInteractionId) {
|
|
181
|
+
// Both branches answer the same question from the same kind of
|
|
182
|
+
// record — an `<image>.json` on disk, an object beside the media in
|
|
183
|
+
// the store — so a restart no longer ends a chain on either.
|
|
184
|
+
previousInteractionId = onDisk
|
|
185
|
+
? await latestInteractionId(resolveOutputDir(args.output_dir))
|
|
186
|
+
// 'image' explicitly: video and music write records too, and a
|
|
187
|
+
// chained Lyria call is a documented 400. Resuming the wrong
|
|
188
|
+
// medium's chain is worse than not resuming.
|
|
189
|
+
: await client.mediaSink?.latestInteractionId?.('image');
|
|
158
190
|
continuedFromSidecar = previousInteractionId !== undefined;
|
|
159
191
|
}
|
|
160
192
|
if (!previousInteractionId) {
|
|
161
193
|
throw new McpToolError('No previous interaction to continue in this session.', {
|
|
162
194
|
hint: onDisk
|
|
163
195
|
? 'Call gemini_interact once without continue_last first, or pass an explicit previous_interaction_id.'
|
|
164
|
-
: 'Call gemini_interact once without continue_last first, or pass an explicit previous_interaction_id. (
|
|
196
|
+
: 'Call gemini_interact once without continue_last first, or pass an explicit previous_interaction_id. (Nothing this connector has stored carries an interaction id to resume from.)',
|
|
165
197
|
});
|
|
166
198
|
}
|
|
167
199
|
}
|
|
@@ -231,14 +263,15 @@ export function registerInteractTools(server, client) {
|
|
|
231
263
|
// probe itself could not be completed.
|
|
232
264
|
if (err.chainExists === true)
|
|
233
265
|
throw err;
|
|
234
|
-
// Re-anchoring needs the
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const reanchorOn
|
|
266
|
+
// Re-anchoring needs the image that interaction produced, located
|
|
267
|
+
// by ID — never "the newest image". On disk that index is the
|
|
268
|
+
// `<image>.json` sidecars; hosted it is the record beside each
|
|
269
|
+
// stored object. Same rule either way: no match, no guess.
|
|
270
|
+
const { refs: reanchorOn, carried } = onDisk
|
|
271
|
+
? await reanchorFromDisk(resolveOutputDir(args.output_dir), err.previousInteractionId)
|
|
272
|
+
: await reanchorFromStore(client, err.previousInteractionId);
|
|
239
273
|
if (!reanchorOn.length)
|
|
240
274
|
throw err;
|
|
241
|
-
const carried = await Promise.all(reanchorOn.map((p) => readImageAsInline(p)));
|
|
242
275
|
try {
|
|
243
276
|
const probe = await client.interact({ ...callOpts, images: [...carried, ...inputs], previousInteractionId: undefined });
|
|
244
277
|
chainRecovered = { expired_interaction_id: err.previousInteractionId, reanchored_on: reanchorOn };
|
|
@@ -298,7 +331,7 @@ export function registerInteractTools(server, client) {
|
|
|
298
331
|
// writeImage returns already-resolved absolute paths. `emit` only
|
|
299
332
|
// invokes this callback for a filesystem-backed sink, so there is no
|
|
300
333
|
// branch here claiming a sidecar the hosted connector cannot write.
|
|
301
|
-
return emit(named, { ...args, sink: client.mediaSink }, meta, async (paths) => {
|
|
334
|
+
return emit(named, { ...args, sink: client.mediaSink, sidecar: { prompt: args.input } }, meta, async (paths) => {
|
|
302
335
|
for (const p of paths)
|
|
303
336
|
client.session.writtenOutputs.add(p);
|
|
304
337
|
// Sidecar per image so the interaction id survives a lost MCP response
|
package/dist/tools/music.js
CHANGED
|
@@ -1,42 +1,46 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { McpToolError } from '@chrischall/mcp-utils';
|
|
3
2
|
import { slugify, baseName } from '../images.js';
|
|
4
3
|
import { resolveImageInputs } from '../inputs.js';
|
|
5
4
|
import { DEFAULT_MUSIC_MODEL } from '../models.js';
|
|
6
|
-
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
|
|
5
|
+
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesBase64Schema } from './shared.js';
|
|
7
6
|
import { fingerprintRequest } from '../jobs.js';
|
|
8
7
|
import { attachCost } from '../pricing.js';
|
|
9
8
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
10
|
-
const MUSIC_MODELS = ['lyria-3-clip-preview', 'lyria-3-pro-preview'];
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
9
|
+
const MUSIC_MODELS = ['lyria-3-clip-preview', 'lyria-3.5', 'lyria-3-pro-preview'];
|
|
10
|
+
/**
|
|
11
|
+
* There is no output-format parameter, and there is no chaining.
|
|
12
|
+
*
|
|
13
|
+
* Both were docs-derived and both were wrong (probed live 2026-09-09):
|
|
14
|
+
* `response_format.audio_format` is not a field — every Lyria model answers
|
|
15
|
+
* `400 Unknown parameter 'audio_format' at 'response_format'` — and its real
|
|
16
|
+
* name, `mime_type`, currently refuses every value but MP3 per-model
|
|
17
|
+
* (`Audio MIME type AUDIO_WAV is not supported for models/lyria-3.5`). A
|
|
18
|
+
* chained call dies inside the model, on the track it is handed back:
|
|
19
|
+
* `400 Unsupported input mime type for this model: audio/s16le`.
|
|
20
|
+
*
|
|
21
|
+
* So neither is exposed. A parameter whose every non-default value is a 400 is
|
|
22
|
+
* worse than an absent one: it costs schema tokens on every request and spends
|
|
23
|
+
* a caller's turn discovering it does not work.
|
|
24
|
+
*/
|
|
19
25
|
export function registerMusicTools(server, client) {
|
|
20
26
|
server.registerTool('gemini_music_generate', {
|
|
21
|
-
description: 'Generate music from a text prompt (mood, genre, instruments, structure, or lyrics inline) via a Lyria model ' +
|
|
22
|
-
'
|
|
23
|
-
'
|
|
24
|
-
'
|
|
27
|
+
description: 'Generate music from a text prompt (mood, genre, instruments, structure, or lyrics inline) via a Lyria model: ' +
|
|
28
|
+
'lyria-3-clip-preview (30s instrumental clip, default, cheapest), lyria-3.5 (full-length song with vocals) or ' +
|
|
29
|
+
'lyria-3-pro-preview (longer-form). Output is MP3, written to disk (or returned inline). Single-turn: a track ' +
|
|
30
|
+
'cannot be refined by a follow-up call, so put the whole brief in the prompt. Runs long — give it a `max_wait_ms` ' +
|
|
31
|
+
'budget (or `async: true` + gemini_get_result on a local install), or raise `timeout_ms`. Needs a funded account.',
|
|
25
32
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
26
33
|
inputSchema: {
|
|
27
34
|
prompt: z.string().min(1).describe('Description of the music: mood, genre, instruments, tempo, structure, or lyrics'),
|
|
28
|
-
model: z.enum(MUSIC_MODELS).optional().describe(`Lyria model (default: ${DEFAULT_MUSIC_MODEL}).
|
|
29
|
-
audio_format: z.enum(AUDIO_FORMATS).optional().describe('Output format (default mp3). wav is lyria-3-pro-preview-only.'),
|
|
35
|
+
model: z.enum(MUSIC_MODELS).optional().describe(`Lyria model (default: ${DEFAULT_MUSIC_MODEL} — 30s, $0.04). lyria-3.5 and lyria-3-pro-preview run minutes-long at $0.08.`),
|
|
30
36
|
images: z.array(z.string().min(1)).optional().describe('Optional reference image path(s) to condition the music'),
|
|
31
37
|
images_url: imagesUrlSchema('Reference images'),
|
|
32
38
|
images_file_uris: imagesFileUrisSchema('Reference images'),
|
|
33
|
-
images_base64:
|
|
39
|
+
images_base64: imagesBase64Schema(),
|
|
34
40
|
from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS clipboard as a reference'),
|
|
35
41
|
filename: z.string().optional().describe('Base filename for the output audio (extension stripped; default: slugified prompt)'),
|
|
36
42
|
output_dir: z.string().optional().describe('Directory to write audio to (default: $GEMINI_OUTPUT_DIR or cwd)'),
|
|
37
|
-
inline: z.boolean().optional().describe('Return base64 audio inline instead of writing to disk'),
|
|
38
|
-
previous_interaction_id: z.string().optional().describe('Interaction id to continue from'),
|
|
39
|
-
continue_last: z.boolean().optional().describe('Continue from the most recent music interaction this server created (explicit previous_interaction_id wins)'),
|
|
43
|
+
inline: z.boolean().optional().describe('Return base64 audio inline instead of writing to disk. A 30s track is ~1.9MB of base64 — the default hands back a path instead'),
|
|
40
44
|
background: z.boolean().optional().describe('Run the generation on Google\'s side and poll it, so a killed job can be recovered by gemini_get_result. Off by default — see gemini_video_generate'),
|
|
41
45
|
timeout_ms: timeoutMsSchema,
|
|
42
46
|
idempotency_key: idempotencyKeySchema,
|
|
@@ -47,28 +51,13 @@ export function registerMusicTools(server, client) {
|
|
|
47
51
|
}, async (args, extra) => {
|
|
48
52
|
assertLocalInputsAvailable(client.mediaSink, args);
|
|
49
53
|
const model = args.model ?? DEFAULT_MUSIC_MODEL;
|
|
50
|
-
if (args.audio_format === 'wav' && !isProModel(model)) {
|
|
51
|
-
throw new McpToolError(`WAV output requires a Pro model; ${model} only produces MP3.`, {
|
|
52
|
-
hint: 'Use model: "lyria-3-pro-preview" for WAV, or drop audio_format to get MP3.',
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
let previousInteractionId = args.previous_interaction_id;
|
|
56
|
-
if (!previousInteractionId && args.continue_last) {
|
|
57
|
-
if (!client.session.lastMusicInteractionId) {
|
|
58
|
-
throw new McpToolError('No previous music interaction to continue in this session.', {
|
|
59
|
-
hint: 'Generate music first, or pass an explicit previous_interaction_id.',
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
previousInteractionId = client.session.lastMusicInteractionId;
|
|
63
|
-
}
|
|
64
54
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Send local image input(s) to the Gemini Lyria API', '/v1beta/interactions', args.images);
|
|
65
55
|
if (gate)
|
|
66
56
|
return gate;
|
|
67
57
|
const fingerprint = fingerprintRequest('gemini_music_generate', {
|
|
68
|
-
model, prompt: args.prompt,
|
|
58
|
+
model, prompt: args.prompt,
|
|
69
59
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
70
60
|
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
71
|
-
previous_interaction_id: previousInteractionId,
|
|
72
61
|
});
|
|
73
62
|
return client.session.jobs.dispatch({ toolName: 'gemini_music_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async (ctx) => {
|
|
74
63
|
const { inputs, report } = await resolveImageInputs(args, client);
|
|
@@ -76,17 +65,15 @@ export function registerMusicTools(server, client) {
|
|
|
76
65
|
input: args.prompt,
|
|
77
66
|
images: inputs.length ? inputs : undefined,
|
|
78
67
|
model: args.model,
|
|
79
|
-
audioFormat: args.audio_format,
|
|
80
|
-
previousInteractionId,
|
|
81
68
|
timeoutMs: args.timeout_ms,
|
|
82
69
|
background: args.background,
|
|
83
70
|
// See gemini_video_generate: the id is what survives the executor.
|
|
84
71
|
onInteractionStarted: ctx.reportInteraction,
|
|
85
72
|
}));
|
|
86
|
-
|
|
73
|
+
// The interaction id is still reported — it is the handle
|
|
74
|
+
// gemini_get_result recovers a killed job's output with — but it is not
|
|
75
|
+
// a conversation handle: Lyria has no second turn.
|
|
87
76
|
const meta = { model, interaction_id: r.id };
|
|
88
|
-
if (previousInteractionId)
|
|
89
|
-
meta.previous_interaction_id = previousInteractionId;
|
|
90
77
|
if (r.usage)
|
|
91
78
|
meta.usage = r.usage;
|
|
92
79
|
attachCost(meta, model, r.usage);
|
|
@@ -94,13 +81,13 @@ export function registerMusicTools(server, client) {
|
|
|
94
81
|
meta.text = r.text;
|
|
95
82
|
if (report)
|
|
96
83
|
meta.image_inputs = report;
|
|
97
|
-
meta.hint =
|
|
84
|
+
meta.hint = 'Lyria is single-turn: to change this track, call gemini_music_generate again with a fuller prompt rather than a follow-up instruction.';
|
|
98
85
|
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
99
86
|
const named = r.audios.map((media, i) => ({
|
|
100
87
|
media,
|
|
101
88
|
base: r.audios.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
|
|
102
89
|
}));
|
|
103
|
-
return emitMedia(named, 'audio', { inline: args.inline, output_dir: args.output_dir, sink: client.mediaSink }, meta);
|
|
90
|
+
return emitMedia(named, 'audio', { inline: args.inline, output_dir: args.output_dir, sink: client.mediaSink, sidecar: { prompt: args.prompt } }, meta);
|
|
104
91
|
});
|
|
105
92
|
});
|
|
106
93
|
}
|
package/dist/tools/set.js
CHANGED
|
@@ -3,7 +3,7 @@ import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
|
|
|
3
3
|
import { resolveModel } from '../models.js';
|
|
4
4
|
import { slugify, baseName } from '../images.js';
|
|
5
5
|
import { resolveImageInputs } from '../inputs.js';
|
|
6
|
-
import { emit, resolveAspectRatio, ASPECT_RATIOS, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt, persistBundle, SET_URL_TTL_MS } from './shared.js';
|
|
6
|
+
import { emit, resolveAspectRatio, ASPECT_RATIOS, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesBase64Schema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt, persistBundle, SET_URL_TTL_MS } from './shared.js';
|
|
7
7
|
import { fingerprintRequest } from '../jobs.js';
|
|
8
8
|
import { sumUsage } from '../usage.js';
|
|
9
9
|
import { attachCost } from '../pricing.js';
|
|
@@ -25,7 +25,7 @@ export function registerSetTools(server, client) {
|
|
|
25
25
|
master_images_r2_keys: imagesR2KeysSchema('Reference images passed to the master AND to every scene call'),
|
|
26
26
|
characters: charactersSchema,
|
|
27
27
|
style: styleSchema,
|
|
28
|
-
master_images_base64:
|
|
28
|
+
master_images_base64: imagesBase64Schema('Reference images for the master generation', 'master_images'),
|
|
29
29
|
confirm: schemaConfirm,
|
|
30
30
|
...sharedImageSchema,
|
|
31
31
|
},
|
|
@@ -190,7 +190,7 @@ export function registerSetTools(server, client) {
|
|
|
190
190
|
// A set is a batch someone comes back to: sign its links (and the
|
|
191
191
|
// bundle's) for ~7 days rather than the default ~48h. The sink clamps
|
|
192
192
|
// to the retention window; the disk sink ignores it entirely.
|
|
193
|
-
return emit(named, { ...args, sink: client.mediaSink, urlTtlMs: SET_URL_TTL_MS }, meta);
|
|
193
|
+
return emit(named, { ...args, sink: client.mediaSink, urlTtlMs: SET_URL_TTL_MS, sidecar: { prompt: args.master_prompt } }, meta);
|
|
194
194
|
});
|
|
195
195
|
});
|
|
196
196
|
}
|
package/dist/tools/shared.js
CHANGED
|
@@ -71,19 +71,15 @@ export function resolveAspectRatio(opts, supported) {
|
|
|
71
71
|
export const orientationSchema = z
|
|
72
72
|
.enum(ORIENTATIONS)
|
|
73
73
|
.optional()
|
|
74
|
-
.describe('
|
|
75
|
-
'
|
|
76
|
-
'35mm photo (3:2), print (4:3), social (4:5), cinematic (21:9) — name it with aspect_ratio instead, ' +
|
|
77
|
-
'which overrides this when both are given.');
|
|
74
|
+
.describe('Output shape in plain terms: landscape (16:9), portrait (9:16) or square (1:1). For any other proportion — ' +
|
|
75
|
+
'3:2, 4:3, 4:5, 21:9 — use aspect_ratio, which wins if both are given.');
|
|
78
76
|
/**
|
|
79
77
|
* When to pick which Nano Banana model — shared by every tool's `model` param
|
|
80
78
|
* so the guidance can't drift between them.
|
|
81
79
|
*/
|
|
82
|
-
export const MODEL_CHOICE_GUIDE = 'gemini-3.1-flash-image (Nano Banana 2) is the
|
|
83
|
-
'
|
|
84
|
-
'
|
|
85
|
-
'visual tasks — highest world knowledge, advanced localization, accurate brand consistency, precision creative control. ' +
|
|
86
|
-
'gemini-3.1-flash-lite-image (Nano Banana 2 Lite) is the fastest/cheapest for simple tasks (1K only, no search grounding).';
|
|
80
|
+
export const MODEL_CHOICE_GUIDE = 'gemini-3.1-flash-image (Nano Banana 2, the default) is the generalist: fast, 4K, reliable text, strong ' +
|
|
81
|
+
'multi-reference consistency. gemini-3-pro-image (Pro) is for the hardest work — best world knowledge, brand ' +
|
|
82
|
+
'precision. gemini-3.1-flash-lite-image (Lite) is cheapest: 1K only, no search grounding.';
|
|
87
83
|
/**
|
|
88
84
|
* Per-call upstream timeout override, shared by every generation tool
|
|
89
85
|
* (spread via {@link sharedImageSchema}; `gemini_interact` imports it directly
|
|
@@ -94,7 +90,7 @@ export const timeoutMsSchema = z
|
|
|
94
90
|
.int()
|
|
95
91
|
.positive()
|
|
96
92
|
.optional()
|
|
97
|
-
.describe('Upstream
|
|
93
|
+
.describe('Upstream timeout in ms for this call (default $GEMINI_TIMEOUT_MS, else 60000 — 120000 at 4K, which runs past 60s)');
|
|
98
94
|
/**
|
|
99
95
|
* Idempotency key shared by every generation tool. A repeat call carrying the
|
|
100
96
|
* same key returns the recorded result (`reused: true`) instead of billing a
|
|
@@ -105,7 +101,7 @@ export const idempotencyKeySchema = z
|
|
|
105
101
|
.string()
|
|
106
102
|
.min(1)
|
|
107
103
|
.optional()
|
|
108
|
-
.describe('
|
|
104
|
+
.describe('Repeat calls with this key return the recorded result (reused: true) instead of billing a new generation. Set it when retrying after a host timeout (-32001).');
|
|
109
105
|
/**
|
|
110
106
|
* Async escape hatch shared by every generation tool: return a `job_id`
|
|
111
107
|
* immediately so a long (Pro/4K) generation can't hit a host's fixed tools/call
|
|
@@ -114,10 +110,9 @@ export const idempotencyKeySchema = z
|
|
|
114
110
|
export const asyncSchema = z
|
|
115
111
|
.boolean()
|
|
116
112
|
.optional()
|
|
117
|
-
.describe('
|
|
118
|
-
'
|
|
119
|
-
'
|
|
120
|
-
'request is open, so this option is served there as a bounded wait rather than an immediate hand-off.');
|
|
113
|
+
.describe('Return a job_id immediately instead of the result, so a long generation cannot hit the host tools/call timeout ' +
|
|
114
|
+
'(-32001); poll gemini_get_result. On the hosted connector prefer max_wait_ms — the executor only lives while a ' +
|
|
115
|
+
'request is open, so async is served there as a bounded wait.');
|
|
121
116
|
/**
|
|
122
117
|
* Wait-then-hand-off budget shared by every generation tool: wait up to the
|
|
123
118
|
* budget for the in-band result, and past it return a `{ job_id }` handle
|
|
@@ -130,9 +125,9 @@ export const maxWaitMsSchema = z
|
|
|
130
125
|
.positive()
|
|
131
126
|
.max(600_000)
|
|
132
127
|
.optional()
|
|
133
|
-
.describe('Wait up to this many ms
|
|
134
|
-
'
|
|
135
|
-
'
|
|
128
|
+
.describe('Wait up to this many ms in-band, then hand back { job_id, status: "running" } to poll with gemini_get_result ' +
|
|
129
|
+
'(e.g. 20000 for multi-image sets). Keeps fast results inline and slow ones off the host timeout (-32001). ' +
|
|
130
|
+
'Ignored when async is set.');
|
|
136
131
|
/**
|
|
137
132
|
* Reference images already in THIS connector's object store, by `r2_key`.
|
|
138
133
|
* Hosted connector only — the server reads its own bucket (tenant-gated), so
|
|
@@ -142,25 +137,24 @@ export function imagesR2KeysSchema(label = 'Reference images') {
|
|
|
142
137
|
return z
|
|
143
138
|
.array(z.string().min(1))
|
|
144
139
|
.optional()
|
|
145
|
-
.describe(`${label} by r2_key from
|
|
146
|
-
"generation's media[].r2_key. The server reads its own bucket
|
|
147
|
-
'
|
|
140
|
+
.describe(`${label} by r2_key from this connector's store: a signed upload (gemini_get_upload_url → curl PUT) or an earlier ` +
|
|
141
|
+
"generation's media[].r2_key. The server reads its own bucket — no bytes in the conversation, no ~48h expiry. " +
|
|
142
|
+
'Hosted connector only.');
|
|
148
143
|
}
|
|
149
144
|
/** Saved-character names attached to a generation (hosted connector only). */
|
|
150
145
|
export const charactersSchema = z
|
|
151
146
|
.array(z.string().min(1))
|
|
152
147
|
.max(8)
|
|
153
148
|
.optional()
|
|
154
|
-
.describe('Names of saved characters (
|
|
155
|
-
'
|
|
156
|
-
'Hosted connector only.');
|
|
149
|
+
.describe('Names of saved characters (gemini_list_characters): each one\'s reference image and description are attached ' +
|
|
150
|
+
'automatically, keeping recurring subjects consistent without re-sending anything. Hosted connector only.');
|
|
157
151
|
/** Saved-style name applied to a generation (hosted connector only). */
|
|
158
152
|
export const styleSchema = z
|
|
159
153
|
.string()
|
|
160
154
|
.min(1)
|
|
161
155
|
.optional()
|
|
162
|
-
.describe('Name of a saved style preset (
|
|
163
|
-
'
|
|
156
|
+
.describe('Name of a saved style preset (gemini_list_styles): its prompt fragment, and reference image if it has one, are ' +
|
|
157
|
+
'applied automatically. Hosted connector only.');
|
|
164
158
|
/**
|
|
165
159
|
* Turn `characters: ["finn", …]` / `style: "…"` into reference-image inputs
|
|
166
160
|
* plus prompt text, from the account's saved library.
|
|
@@ -257,9 +251,23 @@ export function imagesUrlSchema(label = 'Reference images') {
|
|
|
257
251
|
return z
|
|
258
252
|
.array(z.string().url())
|
|
259
253
|
.optional()
|
|
260
|
-
.describe(`${label} as public https URLs — the
|
|
261
|
-
|
|
262
|
-
|
|
254
|
+
.describe(`${label} as public https URLs — the server downloads them, so no image bytes cross the conversation. Preferred ` +
|
|
255
|
+
`over images_base64, which costs ~14k tokens per photo. Max ${wholeMb(IMAGE_URL_MAX_BYTES)}MB each, Content-Type image/*.`);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Raw base64 / data URIs — the last-resort input form, and the only one whose
|
|
259
|
+
* bytes have already crossed the conversation by the time the server sees
|
|
260
|
+
* them. The description says so, and says what to do instead NEXT time: the
|
|
261
|
+
* funnel uploads each entry once and reports the `files/<id>` back, so the
|
|
262
|
+
* repeat reference costs a short string rather than another photo.
|
|
263
|
+
*/
|
|
264
|
+
export function imagesBase64Schema(label = 'Reference images', prefix = 'images') {
|
|
265
|
+
return z
|
|
266
|
+
.array(z.string().min(1))
|
|
267
|
+
.optional()
|
|
268
|
+
.describe(`${label} as base64 strings or data URIs. Last resort — about 14k tokens per photo; prefer ${prefix}_url or ` +
|
|
269
|
+
`${prefix}_file_uris. The server uploads each one and reports a file_uri under image_inputs: pass that to ` +
|
|
270
|
+
`${prefix}_file_uris next time instead of re-sending the bytes.`);
|
|
263
271
|
}
|
|
264
272
|
/**
|
|
265
273
|
* Reference images already in the Gemini Files API. The cheapest form there is:
|
|
@@ -269,9 +277,8 @@ export function imagesFileUrisSchema(label = 'Reference images') {
|
|
|
269
277
|
return z
|
|
270
278
|
.array(z.string().min(1))
|
|
271
279
|
.optional()
|
|
272
|
-
.describe(`${label}
|
|
273
|
-
'
|
|
274
|
-
'Files are retained ~48h, after which the reference stops resolving.');
|
|
280
|
+
.describe(`${label} as Files API references ("files/<id>" or the full uri) from gemini_upload_file. Upload once and reuse ` +
|
|
281
|
+
'across calls with no bytes in the conversation; retained ~48h, after which the reference stops resolving.');
|
|
275
282
|
}
|
|
276
283
|
/**
|
|
277
284
|
* The model/aspect/size/output fields every generation tool shares. Defined once
|
|
@@ -294,7 +301,7 @@ export const sharedImageSchema = {
|
|
|
294
301
|
orientation: orientationSchema,
|
|
295
302
|
image_size: z.enum(IMAGE_SIZES).optional().describe('Output resolution (512 = 0.5K, Flash-only)'),
|
|
296
303
|
output_dir: z.string().optional().describe('Directory to write images to (default: $GEMINI_OUTPUT_DIR or cwd)'),
|
|
297
|
-
inline: z.boolean().optional().describe('Return
|
|
304
|
+
inline: z.boolean().optional().describe('Return the image as an inline image block you can SEE, instead of a path (stdio) or link (hosted). The default costs nothing to carry and hands back a reference you can reuse; use this when you need to check the result yourself. On the hosted connector gemini_view_media does the same for an image you already have'),
|
|
298
305
|
seed: z.number().int().optional().describe('Seed for reproducible generation; random if omitted'),
|
|
299
306
|
thinking_level: z.enum(['minimal', 'high']).optional().describe('Reasoning depth (Gemini 3 models); higher can help complex/structural edits'),
|
|
300
307
|
google_search: z.boolean().optional().describe('Ground the image in live Google Search results (current events, weather, data)'),
|
|
@@ -499,6 +506,28 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
|
|
|
499
506
|
// next to, so it is skipped rather than handed URLs it would misinterpret.
|
|
500
507
|
if (sink.persistsFiles)
|
|
501
508
|
await onWritten?.(refs);
|
|
509
|
+
// The hosted twin of the `<image>.json` sidecar `onWritten` writes on disk.
|
|
510
|
+
// Same purpose: the interaction id has to outlive the response, or a dropped
|
|
511
|
+
// turn ends the chain and a 404 has nothing to re-anchor on. Written for
|
|
512
|
+
// EVERY stored object, since a set's scenes are as worth identifying later
|
|
513
|
+
// as its master. Best-effort inside the sink, which never throws.
|
|
514
|
+
const record = {
|
|
515
|
+
...opts.sidecar,
|
|
516
|
+
// The kind is what keeps the three chains apart. `gemini_interact`'s
|
|
517
|
+
// continue_last reads the newest recorded id, and video and music write
|
|
518
|
+
// records too — without this it could resume an omni interaction, or a
|
|
519
|
+
// Lyria one, where a chained call is a documented 400.
|
|
520
|
+
kind,
|
|
521
|
+
...(typeof meta?.interaction_id === 'string' ? { interaction_id: meta.interaction_id } : {}),
|
|
522
|
+
...(typeof meta?.model === 'string' ? { model: meta.model } : {}),
|
|
523
|
+
};
|
|
524
|
+
// `kind` alone is not worth an object — a record earns its place by carrying
|
|
525
|
+
// something a later caller can act on or recognise.
|
|
526
|
+
if (!sink.persistsFiles && sink.writeSidecar && (record.interaction_id || record.prompt)) {
|
|
527
|
+
for (const p of persisted)
|
|
528
|
+
if (p.key)
|
|
529
|
+
await sink.writeSidecar(p.key, record);
|
|
530
|
+
}
|
|
502
531
|
// Note a downgraded inline-video request so the caller isn't left wondering.
|
|
503
532
|
const note = opts.inline && kind === 'video' ? { inline_unsupported: 'video has no MCP inline content type — written to disk instead' } : {};
|
|
504
533
|
// Say where the bytes actually went whenever they are NOT local paths — the
|
|
@@ -605,8 +634,9 @@ export async function persistBundle(named, sink, base) {
|
|
|
605
634
|
}
|
|
606
635
|
}
|
|
607
636
|
/**
|
|
608
|
-
* Inputs that can only come off a local filesystem.
|
|
609
|
-
*
|
|
637
|
+
* Inputs that can only come off a local filesystem. The hosted deployment runs
|
|
638
|
+
* as a child process on mcp-host with no access to the CALLER's machine, so
|
|
639
|
+
* none of them exist there:
|
|
610
640
|
*
|
|
611
641
|
* - `images` / `master_images` — file paths read with `node:fs`.
|
|
612
642
|
* - `from_clipboard` — shells out to `osascript`/`sips` via `child_process`.
|
|
@@ -642,12 +672,13 @@ export function assertLocalInputsAvailable(sink, args) {
|
|
|
642
672
|
// exactly when it is needed.
|
|
643
673
|
const fix = 'Reference images WITHOUT putting bytes in the conversation, in order of preference: ' +
|
|
644
674
|
'(1) `images_url` — public https URLs the server fetches itself; ' +
|
|
645
|
-
'(2) `
|
|
646
|
-
'(
|
|
647
|
-
|
|
648
|
-
'
|
|
649
|
-
'
|
|
675
|
+
'(2) `images_r2_keys` — mint a signed upload URL with `gemini_get_upload_url`, PUT the file to it, then pass the r2_key back ' +
|
|
676
|
+
'(the server reads its own store; nothing crosses the conversation); ' +
|
|
677
|
+
'(3) `images_file_uris` — upload once with `gemini_upload_file` (it takes a `url` or `data_base64`) and reuse the returned files/… uri. ' +
|
|
678
|
+
'If you CANNOT make HTTP requests at all, routes (1) and (2) are closed to you and the only way to reference a local file ' +
|
|
679
|
+
'is `images_base64`, which costs ~14k tokens per photo and breaks on a truncated read — the server uploads it once and ' +
|
|
680
|
+
'returns a files/… uri under image_inputs to reuse on later calls. ' +
|
|
650
681
|
'Reference video by `video_url` (a public YouTube URL, or a files/… uri uploaded elsewhere) instead of `video_path`.';
|
|
651
682
|
throw new McpToolError(`${unavailable.join(', ')} ${unavailable.length > 1 ? 'are' : 'is'} unavailable on the hosted connector: ` +
|
|
652
|
-
`it runs on a
|
|
683
|
+
`it runs on a remote machine with no access to your filesystem or clipboard. ${fix}`, { hint: fix });
|
|
653
684
|
}
|
package/dist/tools/uploads.js
CHANGED
|
@@ -9,12 +9,12 @@ import { step } from '../errors.js';
|
|
|
9
9
|
*
|
|
10
10
|
* curl -sS -X PUT -H "Content-Type: image/jpeg" --data-binary @photo.jpg "<upload_url>"
|
|
11
11
|
*
|
|
12
|
-
* This closes the last gap in the no-bytes-in-the-conversation story. The
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* `POST /upload`
|
|
16
|
-
* shell
|
|
17
|
-
* the shell.
|
|
12
|
+
* This closes the last gap in the no-bytes-in-the-conversation story. The
|
|
13
|
+
* alternatives each exclude a common caller: `images_base64` costs ~14k tokens
|
|
14
|
+
* per photo and corrupts silently on a truncated read, and the Worker's old
|
|
15
|
+
* `POST /upload` (retired with the Worker) needed the MCP session's OAuth
|
|
16
|
+
* bearer token, which a sandboxed shell does not have. A signed URL is mintable
|
|
17
|
+
* by the session and usable by the shell.
|
|
18
18
|
*
|
|
19
19
|
* Hosted only — stdio has no HTTP surface to PUT against (and no need: it reads
|
|
20
20
|
* local paths directly). Gated on `client.uploadUrls`, the same pattern as
|