@chrischall/gemini-mcp 1.0.4 → 1.2.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 +75 -1
- package/dist/bundle.js +854 -137
- package/dist/bytes.js +46 -0
- package/dist/client.js +194 -17
- package/dist/fetch-image.js +361 -0
- package/dist/images.js +0 -19
- package/dist/index.js +2 -1
- package/dist/inputs.js +165 -0
- package/dist/session.js +39 -0
- package/dist/storage/media.js +2 -13
- package/dist/tools/files.js +188 -0
- package/dist/tools/generate.js +21 -12
- package/dist/tools/interact.js +12 -5
- package/dist/tools/music.js +10 -4
- package/dist/tools/set.js +29 -7
- package/dist/tools/shared.js +44 -6
- package/dist/tools/video.js +10 -4
- package/dist/upload-endpoint.js +79 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/gemini-mcp/SKILL.md +65 -6
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { McpToolError, textResult } from '@chrischall/mcp-utils';
|
|
3
|
+
import { withProgressHeartbeat } from './shared.js';
|
|
4
|
+
import { base64ToBytes, wholeMb } from '../bytes.js';
|
|
5
|
+
import { fileNameFromUrl } from '../fetch-image.js';
|
|
6
|
+
import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
7
|
+
/**
|
|
8
|
+
* First-class Gemini Files API access.
|
|
9
|
+
*
|
|
10
|
+
* The problem these solve: on a hosted connector there is no filesystem, so the
|
|
11
|
+
* only way to hand over a picture used to be `images_base64` — and a base64
|
|
12
|
+
* JPEG is ~14k tokens of the model's context window per photo, corrupted
|
|
13
|
+
* outright whenever the file read that produced it was truncated. A Files API
|
|
14
|
+
* reference is a 20-character string that any number of later calls can reuse.
|
|
15
|
+
*
|
|
16
|
+
* Three ways in, none of which put bytes in the conversation:
|
|
17
|
+
*
|
|
18
|
+
* - `gemini_upload_file` with `url` — the SERVER downloads it.
|
|
19
|
+
* - `POST /upload` on the connector with the raw bytes as the request body —
|
|
20
|
+
* the intended path for an agent with a shell (see README).
|
|
21
|
+
* - `gemini_upload_file` with `path` — stdio only, reads the local file.
|
|
22
|
+
*
|
|
23
|
+
* `data_base64` exists as a deliberate last resort: it is the one form that
|
|
24
|
+
* *does* cost context, and it is documented as such.
|
|
25
|
+
*
|
|
26
|
+
* Uploads are retained ~48h. After that the uri stops resolving — which,
|
|
27
|
+
* unhelpfully, surfaces as the same generic 404 an unknown model id produces
|
|
28
|
+
* (see `ChainedRequest404Error` in client.ts), so the expiry is stated
|
|
29
|
+
* everywhere a uri is returned.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* A `url` upload is buffered whole before being forwarded (fetched bytes, then
|
|
33
|
+
* a Blob copy of them), so the cap has to be one the runtime can actually hold
|
|
34
|
+
* — an advertised limit that fails as an OOM is worse than a smaller honest
|
|
35
|
+
* one. A Cloudflare isolate gets 128MB total, hence the much lower hosted
|
|
36
|
+
* figure; Node has no comparable ceiling.
|
|
37
|
+
*
|
|
38
|
+
* `POST /upload` is unaffected: it streams, and is bounded only by the Files
|
|
39
|
+
* API's own 2 GB cap.
|
|
40
|
+
*/
|
|
41
|
+
const UPLOAD_URL_MAX_BYTES_DISK = 100 * 1024 * 1024;
|
|
42
|
+
const UPLOAD_URL_MAX_BYTES_HOSTED = 25 * 1024 * 1024;
|
|
43
|
+
/** What a `url` upload will accept: the media the Files API is useful for. */
|
|
44
|
+
const UPLOAD_ACCEPT = ['image/', 'video/', 'audio/'];
|
|
45
|
+
const RETENTION_NOTE = 'Gemini retains uploaded files for ~48h. Until then this reference can be reused across any number of calls ' +
|
|
46
|
+
'(images_file_uris on gemini_image_generate / _edit / _set / gemini_interact, or video_url) without re-sending the bytes.';
|
|
47
|
+
/** Shape returned by every upload path. */
|
|
48
|
+
function uploadResult(file) {
|
|
49
|
+
return textResult({
|
|
50
|
+
// The `files/<id>` form is what every tool parameter documents, so it leads.
|
|
51
|
+
file_uri: file.name,
|
|
52
|
+
// The absolute uri, for callers that would rather pass it through verbatim.
|
|
53
|
+
uri: file.uri,
|
|
54
|
+
name: file.name,
|
|
55
|
+
mime_type: file.mimeType,
|
|
56
|
+
...(file.sizeBytes ? { size_bytes: Number(file.sizeBytes) } : {}),
|
|
57
|
+
...(file.displayName ? { display_name: file.displayName } : {}),
|
|
58
|
+
expires: file.expirationTime ?? '~48h from upload',
|
|
59
|
+
hint: `Pass "${file.name}" in images_file_uris (or video_url for video) on any image tool. ${RETENTION_NOTE}`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
export function registerFileTools(server, client) {
|
|
63
|
+
const onDisk = client.mediaSink?.persistsFiles ?? true;
|
|
64
|
+
const urlMaxBytes = onDisk ? UPLOAD_URL_MAX_BYTES_DISK : UPLOAD_URL_MAX_BYTES_HOSTED;
|
|
65
|
+
server.registerTool('gemini_upload_file', {
|
|
66
|
+
description: 'Upload an image (or video/audio) to the Gemini Files API ONCE and get back a reusable `files/<id>` reference. ' +
|
|
67
|
+
'Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever ' +
|
|
68
|
+
'enter the conversation, and it can be reused across many generations until it expires (~48h). ' +
|
|
69
|
+
'Provide exactly one of `url` (the server downloads it), `data_base64`' +
|
|
70
|
+
(onDisk ? ', or `path` (a local file).' : '.') +
|
|
71
|
+
(onDisk
|
|
72
|
+
? ''
|
|
73
|
+
: ' This connector also accepts raw bytes over HTTP: POST them to /upload with the same Authorization header ' +
|
|
74
|
+
'and a Content-Type of image/*, which avoids base64 entirely.'),
|
|
75
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
76
|
+
inputSchema: {
|
|
77
|
+
url: z
|
|
78
|
+
.string()
|
|
79
|
+
.url()
|
|
80
|
+
.optional()
|
|
81
|
+
.describe(`Public https URL the SERVER downloads and uploads (image/video/audio, up to ${wholeMb(urlMaxBytes)}MB). ` +
|
|
82
|
+
'No bytes pass through the conversation.' +
|
|
83
|
+
(onDisk ? '' : ' Larger files: POST the raw bytes to /upload, which streams instead of buffering.')),
|
|
84
|
+
data_base64: z
|
|
85
|
+
.string()
|
|
86
|
+
.min(1)
|
|
87
|
+
.optional()
|
|
88
|
+
.describe('Raw base64 or a data: URI. Last resort — this is the one form that costs model context (~14k tokens for a modest JPEG).'),
|
|
89
|
+
path: z
|
|
90
|
+
.string()
|
|
91
|
+
.min(1)
|
|
92
|
+
.optional()
|
|
93
|
+
.describe(onDisk
|
|
94
|
+
? 'Path to a local file (absolute, or resolved against $GEMINI_INPUT_DIR). Confirm-gated like every other local-file input.'
|
|
95
|
+
: 'Unavailable on this hosted connector (no filesystem) — use `url`, `data_base64`, or POST /upload.'),
|
|
96
|
+
mime_type: z
|
|
97
|
+
.string()
|
|
98
|
+
.regex(/^[\w.+-]+\/[\w.+-]+$/, 'must be a MIME type like image/png')
|
|
99
|
+
.optional()
|
|
100
|
+
.describe('Override the detected MIME type (sniffed from the bytes / taken from the server response otherwise)'),
|
|
101
|
+
display_name: z.string().min(1).optional().describe('Human-readable name recorded against the upload'),
|
|
102
|
+
confirm: schemaConfirm,
|
|
103
|
+
},
|
|
104
|
+
}, async (args, extra) => {
|
|
105
|
+
const sources = [args.url && 'url', args.data_base64 && 'data_base64', args.path && 'path'].filter(Boolean);
|
|
106
|
+
if (sources.length !== 1) {
|
|
107
|
+
throw new McpToolError(sources.length === 0
|
|
108
|
+
? 'Provide exactly one of `url`, `data_base64`' + (onDisk ? ' or `path`.' : '.')
|
|
109
|
+
: `Provide exactly one source, not ${sources.length} (${sources.join(', ')}).`);
|
|
110
|
+
}
|
|
111
|
+
if (args.path) {
|
|
112
|
+
if (!onDisk) {
|
|
113
|
+
throw new McpToolError('`path` is unavailable on the hosted connector: it runs on a Cloudflare Worker, which has no filesystem. ' +
|
|
114
|
+
'Use `url` (the server downloads it), `data_base64`, or POST the raw bytes to /upload with the same Authorization header.', { hint: 'Use `url`, `data_base64`, or POST /upload.' });
|
|
115
|
+
}
|
|
116
|
+
// Same gate as every other local-file input: a prompt-injected `path`
|
|
117
|
+
// would otherwise ship an arbitrary local file to Google, and an
|
|
118
|
+
// uploaded file is readable by anyone holding the api key for ~48h.
|
|
119
|
+
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Upload a local file to the Gemini Files API', '/upload/v1beta/files', [args.path]);
|
|
120
|
+
if (gate)
|
|
121
|
+
return gate;
|
|
122
|
+
}
|
|
123
|
+
const uploaded = await withProgressHeartbeat(extra, 'Uploading to the Gemini Files API', async () => {
|
|
124
|
+
if (args.url) {
|
|
125
|
+
const fetched = await client.fetchRemoteImage(args.url, { maxBytes: urlMaxBytes, accept: UPLOAD_ACCEPT });
|
|
126
|
+
return client.uploadBytes(fetched.bytes, args.mime_type ?? fetched.mimeType,
|
|
127
|
+
// 'upload', matching the data_base64 and path branches below —
|
|
128
|
+
// this tool takes video and audio too, so 'image' would be a lie.
|
|
129
|
+
args.display_name ?? fileNameFromUrl(args.url, 'upload'));
|
|
130
|
+
}
|
|
131
|
+
if (args.data_base64) {
|
|
132
|
+
const { decodeImageInput } = await import('../images.js');
|
|
133
|
+
const decoded = decodeImageInput(args.data_base64);
|
|
134
|
+
const bytes = base64ToBytes(decoded.base64);
|
|
135
|
+
return client.uploadBytes(bytes, args.mime_type ?? decoded.mimeType, args.display_name ?? 'upload');
|
|
136
|
+
}
|
|
137
|
+
const { readImageAsInline, resolveImagePath } = await import('../images.js');
|
|
138
|
+
const resolved = resolveImagePath(args.path);
|
|
139
|
+
const inline = await readImageAsInline(resolved);
|
|
140
|
+
const bytes = base64ToBytes(inline.base64);
|
|
141
|
+
return client.uploadBytes(bytes, args.mime_type ?? inline.mimeType, args.display_name ?? (resolved.split(/[\\/]/).pop() || 'upload'));
|
|
142
|
+
});
|
|
143
|
+
return uploadResult(uploaded);
|
|
144
|
+
});
|
|
145
|
+
server.registerTool('gemini_list_files', {
|
|
146
|
+
description: 'List the files currently uploaded to the Gemini Files API under this API key, with their `files/<id>` references, ' +
|
|
147
|
+
'MIME types and expiry times. Retention is ~48h, so an entry that has vanished has expired rather than failed.',
|
|
148
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
149
|
+
inputSchema: {
|
|
150
|
+
page_size: z.number().int().positive().max(100).optional().describe('Maximum files to return (1-100, default 100)'),
|
|
151
|
+
},
|
|
152
|
+
}, async (args) => {
|
|
153
|
+
const files = await client.listFiles(args.page_size ?? 100);
|
|
154
|
+
return textResult({
|
|
155
|
+
count: files.length,
|
|
156
|
+
files: files.map((f) => ({
|
|
157
|
+
file_uri: f.name,
|
|
158
|
+
uri: f.uri,
|
|
159
|
+
mime_type: f.mimeType,
|
|
160
|
+
...(f.displayName ? { display_name: f.displayName } : {}),
|
|
161
|
+
...(f.sizeBytes ? { size_bytes: Number(f.sizeBytes) } : {}),
|
|
162
|
+
...(f.state ? { state: f.state } : {}),
|
|
163
|
+
...(f.createTime ? { created: f.createTime } : {}),
|
|
164
|
+
...(f.expirationTime ? { expires: f.expirationTime } : {}),
|
|
165
|
+
})),
|
|
166
|
+
hint: files.length
|
|
167
|
+
? `Pass any file_uri above in images_file_uris on an image tool. ${RETENTION_NOTE}`
|
|
168
|
+
: 'No files uploaded (or all have expired — retention is ~48h). Upload one with gemini_upload_file.',
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
server.registerTool('gemini_delete_file', {
|
|
172
|
+
description: 'Delete an uploaded file from the Gemini Files API before its ~48h expiry. Any tool call still referencing it ' +
|
|
173
|
+
'will then fail with a generic 404, so delete only references you are finished with.',
|
|
174
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
175
|
+
inputSchema: {
|
|
176
|
+
file_uri: z.string().min(1).describe('The `files/<id>` reference (or full uri) to delete'),
|
|
177
|
+
confirm: schemaConfirm,
|
|
178
|
+
},
|
|
179
|
+
}, async (args) => {
|
|
180
|
+
// Deleting is a remote mutation with no undo — confirm-gated per the
|
|
181
|
+
// fleet convention (unlike writing a generated image, which is local).
|
|
182
|
+
const gate = previewUnlessConfirmed(args.confirm, 'Delete an uploaded Gemini file', 'DELETE', `/v1beta/${args.file_uri}`);
|
|
183
|
+
if (gate)
|
|
184
|
+
return gate;
|
|
185
|
+
await client.deleteFile(args.file_uri);
|
|
186
|
+
return textResult({ deleted: args.file_uri });
|
|
187
|
+
});
|
|
188
|
+
}
|
package/dist/tools/generate.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
3
3
|
import { resolveModel } from '../models.js';
|
|
4
|
-
import { slugify, baseName
|
|
5
|
-
import {
|
|
4
|
+
import { slugify, baseName } from '../images.js';
|
|
5
|
+
import { resolveImageInputs, requireImageInput } from '../inputs.js';
|
|
6
|
+
import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
|
|
6
7
|
import { fingerprintRequest } from '../jobs.js';
|
|
7
8
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
8
9
|
const GENERATE_ENDPOINT = '/v1beta/models/{model}:generateContent';
|
|
@@ -21,7 +22,9 @@ export function registerGenerateTools(server, client) {
|
|
|
21
22
|
count: z.number().int().positive().max(8).optional().describe('Number of independent images (default 1)'),
|
|
22
23
|
filename: z.string().optional().describe('Base filename for the output image (extension stripped; default: slugified prompt)'),
|
|
23
24
|
images: z.array(z.string().min(1)).optional().describe('Paths to reference input images (image-conditioned generation)'),
|
|
24
|
-
|
|
25
|
+
images_url: imagesUrlSchema(),
|
|
26
|
+
images_file_uris: imagesFileUrisSchema(),
|
|
27
|
+
images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation'),
|
|
25
28
|
video_url: z.string().url().optional().describe('Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video→image; use a Flash model e.g. gemini-3.1-flash-image)'),
|
|
26
29
|
video_path: videoPathSchema,
|
|
27
30
|
confirm: schemaConfirm,
|
|
@@ -46,12 +49,15 @@ export function registerGenerateTools(server, client) {
|
|
|
46
49
|
model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
47
50
|
thinking_level: args.thinking_level, google_search: args.google_search, count,
|
|
48
51
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
52
|
+
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
49
53
|
video_url: args.video_url, video_path: args.video_path,
|
|
50
54
|
});
|
|
51
55
|
return client.session.jobs.dispatch({ toolName: 'gemini_image_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
52
56
|
const seed = pickSeed(args.seed);
|
|
53
57
|
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
54
|
-
|
|
58
|
+
// Resolved ONCE, before the count loop: a URL is fetched (and a large
|
|
59
|
+
// one uploaded) a single time no matter how many images are generated.
|
|
60
|
+
const { inputs: refInputs, report } = await withProgressHeartbeat(extra, 'Resolving reference images', () => resolveImageInputs(args, client));
|
|
55
61
|
// Upload ONCE (before the count loop) — every generate call references
|
|
56
62
|
// the same files/… uri.
|
|
57
63
|
const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args, client));
|
|
@@ -90,6 +96,8 @@ export function registerGenerateTools(server, client) {
|
|
|
90
96
|
meta.text = capturedText;
|
|
91
97
|
if (capturedGrounding)
|
|
92
98
|
meta.grounding = capturedGrounding;
|
|
99
|
+
if (report)
|
|
100
|
+
meta.image_inputs = report;
|
|
93
101
|
if (video.videoFileMeta)
|
|
94
102
|
meta.video_file = video.videoFileMeta;
|
|
95
103
|
meta.hint = REFINE_HINT;
|
|
@@ -108,18 +116,16 @@ export function registerGenerateTools(server, client) {
|
|
|
108
116
|
inputSchema: {
|
|
109
117
|
prompt: z.string().min(1).describe('Instruction describing the edit or composition'),
|
|
110
118
|
images: z.array(z.string().min(1)).optional().describe('Paths to input image file(s) (1 = edit, 2+ = compose)'),
|
|
111
|
-
|
|
119
|
+
images_url: imagesUrlSchema('Input images'),
|
|
120
|
+
images_file_uris: imagesFileUrisSchema('Input images'),
|
|
121
|
+
images_base64: z.array(z.string().min(1)).optional().describe('Input images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation'),
|
|
112
122
|
filename: z.string().optional().describe('Base filename for the output image (extension stripped; default: slugified prompt)'),
|
|
113
123
|
confirm: schemaConfirm,
|
|
114
124
|
...sharedImageSchema,
|
|
115
125
|
},
|
|
116
126
|
}, async (args, extra) => {
|
|
117
127
|
assertLocalInputsAvailable(client.mediaSink, args);
|
|
118
|
-
|
|
119
|
-
const hasBase64 = (args.images_base64?.length ?? 0) > 0;
|
|
120
|
-
if (!hasPaths && !hasBase64 && !args.from_clipboard) {
|
|
121
|
-
throw new McpToolError('Provide at least one input image via `images`, `images_base64`, or `from_clipboard`.');
|
|
122
|
-
}
|
|
128
|
+
requireImageInput(args);
|
|
123
129
|
// Confirm-gate local file inputs (see gemini_image_generate). base64 /
|
|
124
130
|
// clipboard inputs are not local-path reads and pass through ungated.
|
|
125
131
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
|
|
@@ -130,11 +136,12 @@ export function registerGenerateTools(server, client) {
|
|
|
130
136
|
model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
131
137
|
thinking_level: args.thinking_level, google_search: args.google_search,
|
|
132
138
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
139
|
+
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
133
140
|
});
|
|
134
141
|
return client.session.jobs.dispatch({ toolName: 'gemini_image_edit', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
135
142
|
const seed = pickSeed(args.seed);
|
|
136
143
|
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
137
|
-
const inputs = await
|
|
144
|
+
const { inputs, report } = await withProgressHeartbeat(extra, 'Resolving input images', () => resolveImageInputs(args, client));
|
|
138
145
|
const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
|
|
139
146
|
prompt: args.prompt,
|
|
140
147
|
images: inputs,
|
|
@@ -152,6 +159,8 @@ export function registerGenerateTools(server, client) {
|
|
|
152
159
|
meta.text = text;
|
|
153
160
|
if (result.grounding)
|
|
154
161
|
meta.grounding = result.grounding;
|
|
162
|
+
if (report)
|
|
163
|
+
meta.image_inputs = report;
|
|
155
164
|
meta.hint = REFINE_HINT;
|
|
156
165
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: client.mediaSink?.persistsFiles });
|
|
157
166
|
if (risk)
|
package/dist/tools/interact.js
CHANGED
|
@@ -2,9 +2,10 @@ import { z } from 'zod';
|
|
|
2
2
|
import { McpToolError, ApiError, readEnvVar } from '@chrischall/mcp-utils';
|
|
3
3
|
import { resolveModel } from '../models.js';
|
|
4
4
|
import { ChainedRequest404Error } from '../client.js';
|
|
5
|
-
import { slugify, baseName,
|
|
5
|
+
import { slugify, baseName, resolveImagePath, writeSidecar, resolveOutputDir, readImageAsInline } from '../images.js';
|
|
6
|
+
import { resolveImageInputs } from '../inputs.js';
|
|
6
7
|
import { findInteractionImages, latestInteractionId } from '../sidecar.js';
|
|
7
|
-
import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
|
|
8
|
+
import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
|
|
8
9
|
import { fingerprintRequest } from '../jobs.js';
|
|
9
10
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
10
11
|
// The last-interaction id and the written-outputs set used to live here, at
|
|
@@ -19,7 +20,7 @@ const NEW_REFERENCES_ONLY = 'NEW reference images only (e.g. a style or target p
|
|
|
19
20
|
/**
|
|
20
21
|
* On a chained call, split `images` into genuinely new references vs paths
|
|
21
22
|
* this server itself generated (which the interaction already contains).
|
|
22
|
-
* Unresolvable paths are kept so `
|
|
23
|
+
* Unresolvable paths are kept so `resolveImageInputs` surfaces its usual error.
|
|
23
24
|
*/
|
|
24
25
|
function splitReattachedOutputs(images, writtenOutputs) {
|
|
25
26
|
const kept = [];
|
|
@@ -73,10 +74,13 @@ export function registerInteractTools(server, client) {
|
|
|
73
74
|
.array(z.string().min(1))
|
|
74
75
|
.optional()
|
|
75
76
|
.describe(`Paths to reference input images. ${NEW_REFERENCES_ONLY}`),
|
|
77
|
+
images_url: imagesUrlSchema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
|
|
78
|
+
images_file_uris: imagesFileUrisSchema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
|
|
76
79
|
images_base64: z
|
|
77
80
|
.array(z.string().min(1))
|
|
78
81
|
.optional()
|
|
79
|
-
.describe(`Reference images as base64 strings or data URIs.
|
|
82
|
+
.describe(`Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, ` +
|
|
83
|
+
`which keep image bytes out of the conversation. ${NEW_REFERENCES_ONLY}`),
|
|
80
84
|
model: z
|
|
81
85
|
.string()
|
|
82
86
|
.optional()
|
|
@@ -171,10 +175,11 @@ export function registerInteractTools(server, client) {
|
|
|
171
175
|
thinking_level: args.thinking_level, previous_interaction_id: previousInteractionId,
|
|
172
176
|
google_search: args.google_search, search_types: args.search_types,
|
|
173
177
|
images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
178
|
+
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
174
179
|
video_url: args.video_url, video_path: args.video_path,
|
|
175
180
|
});
|
|
176
181
|
return client.session.jobs.dispatch({ toolName: 'gemini_interact', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
177
|
-
const inputs = await
|
|
182
|
+
const { inputs, report } = await resolveImageInputs({ ...args, images }, client);
|
|
178
183
|
const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args, client));
|
|
179
184
|
const callOpts = {
|
|
180
185
|
input: args.input,
|
|
@@ -254,6 +259,8 @@ export function registerInteractTools(server, client) {
|
|
|
254
259
|
meta.text = r.text;
|
|
255
260
|
if (r.grounding)
|
|
256
261
|
meta.grounding = r.grounding;
|
|
262
|
+
if (report)
|
|
263
|
+
meta.image_inputs = report;
|
|
257
264
|
if (video.videoFileMeta)
|
|
258
265
|
meta.video_file = video.videoFileMeta;
|
|
259
266
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: onDisk });
|
package/dist/tools/music.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { McpToolError } from '@chrischall/mcp-utils';
|
|
3
|
-
import { slugify, baseName
|
|
3
|
+
import { slugify, baseName } from '../images.js';
|
|
4
|
+
import { resolveImageInputs } from '../inputs.js';
|
|
4
5
|
import { DEFAULT_MUSIC_MODEL } from '../models.js';
|
|
5
|
-
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
|
|
6
|
+
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
|
|
6
7
|
import { fingerprintRequest } from '../jobs.js';
|
|
7
8
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
8
9
|
const MUSIC_MODELS = ['lyria-3-clip-preview', 'lyria-3-pro-preview'];
|
|
@@ -26,7 +27,9 @@ export function registerMusicTools(server, client) {
|
|
|
26
27
|
model: z.enum(MUSIC_MODELS).optional().describe(`Lyria model (default: ${DEFAULT_MUSIC_MODEL}). Pro is longer-form and supports WAV.`),
|
|
27
28
|
audio_format: z.enum(AUDIO_FORMATS).optional().describe('Output format (default mp3). wav is lyria-3-pro-preview-only.'),
|
|
28
29
|
images: z.array(z.string().min(1)).optional().describe('Optional reference image path(s) to condition the music'),
|
|
29
|
-
|
|
30
|
+
images_url: imagesUrlSchema('Reference images'),
|
|
31
|
+
images_file_uris: imagesFileUrisSchema('Reference images'),
|
|
32
|
+
images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation'),
|
|
30
33
|
from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS clipboard as a reference'),
|
|
31
34
|
filename: z.string().optional().describe('Base filename for the output audio (extension stripped; default: slugified prompt)'),
|
|
32
35
|
output_dir: z.string().optional().describe('Directory to write audio to (default: $GEMINI_OUTPUT_DIR or cwd)'),
|
|
@@ -61,10 +64,11 @@ export function registerMusicTools(server, client) {
|
|
|
61
64
|
const fingerprint = fingerprintRequest('gemini_music_generate', {
|
|
62
65
|
model, prompt: args.prompt, audio_format: args.audio_format,
|
|
63
66
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
67
|
+
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
64
68
|
previous_interaction_id: previousInteractionId,
|
|
65
69
|
});
|
|
66
70
|
return client.session.jobs.dispatch({ toolName: 'gemini_music_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
67
|
-
const inputs = await
|
|
71
|
+
const { inputs, report } = await resolveImageInputs(args, client);
|
|
68
72
|
const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client.generateMusic({
|
|
69
73
|
input: args.prompt,
|
|
70
74
|
images: inputs.length ? inputs : undefined,
|
|
@@ -79,6 +83,8 @@ export function registerMusicTools(server, client) {
|
|
|
79
83
|
meta.previous_interaction_id = previousInteractionId;
|
|
80
84
|
if (r.text)
|
|
81
85
|
meta.text = r.text;
|
|
86
|
+
if (report)
|
|
87
|
+
meta.image_inputs = report;
|
|
82
88
|
meta.hint = `To continue this track, call gemini_music_generate again with previous_interaction_id: "${r.id}" (or continue_last: true).`;
|
|
83
89
|
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
84
90
|
const named = r.audios.map((media, i) => ({
|
package/dist/tools/set.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
|
|
3
3
|
import { resolveModel } from '../models.js';
|
|
4
|
-
import { slugify, baseName
|
|
5
|
-
import {
|
|
4
|
+
import { slugify, baseName } from '../images.js';
|
|
5
|
+
import { resolveImageInputs } from '../inputs.js';
|
|
6
|
+
import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
|
|
6
7
|
import { fingerprintRequest } from '../jobs.js';
|
|
7
8
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
8
9
|
export function registerSetTools(server, client) {
|
|
@@ -16,7 +17,9 @@ export function registerSetTools(server, client) {
|
|
|
16
17
|
reference_mode: z.enum(['master', 'chain']).optional().describe('master: every image references the master (default). chain: each references the previous.'),
|
|
17
18
|
basename: z.string().optional().describe('Base filename prefix for output images (default: slugified master_prompt)'),
|
|
18
19
|
master_images: z.array(z.string().min(1)).optional().describe('Reference image paths passed to the master generation call'),
|
|
19
|
-
|
|
20
|
+
master_images_url: imagesUrlSchema('Reference images passed to the master AND to every scene call (fetched once)'),
|
|
21
|
+
master_images_file_uris: imagesFileUrisSchema('Reference images passed to the master AND to every scene call'),
|
|
22
|
+
master_images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs for master generation. Last resort: prefer master_images_url or master_images_file_uris, which keep image bytes out of the conversation'),
|
|
20
23
|
confirm: schemaConfirm,
|
|
21
24
|
...sharedImageSchema,
|
|
22
25
|
},
|
|
@@ -37,13 +40,30 @@ export function registerSetTools(server, client) {
|
|
|
37
40
|
reference_mode: args.reference_mode, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
38
41
|
thinking_level: args.thinking_level, google_search: args.google_search,
|
|
39
42
|
master_images: args.master_images, master_images_base64: args.master_images_base64, from_clipboard: args.from_clipboard,
|
|
43
|
+
master_images_url: args.master_images_url, master_images_file_uris: args.master_images_file_uris,
|
|
40
44
|
});
|
|
41
45
|
return client.session.jobs.dispatch({ toolName: 'gemini_image_set', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
42
46
|
const seed = pickSeed(args.seed);
|
|
43
47
|
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
48
|
const slug = args.basename ? baseName(args.basename) : slugify(args.master_prompt);
|
|
45
|
-
// 1. master (optionally seeded from reference images)
|
|
46
|
-
|
|
49
|
+
// 1. master (optionally seeded from reference images).
|
|
50
|
+
//
|
|
51
|
+
// Two resolutions, because the two groups have different lifetimes:
|
|
52
|
+
//
|
|
53
|
+
// - `carried` — URL and Files API references. Fetched/uploaded ONCE for
|
|
54
|
+
// the whole set (a URL repeated across master + N scenes is one
|
|
55
|
+
// download, and a `files/…` reference costs nothing to repeat), and
|
|
56
|
+
// passed to EVERY scene call, where they do the most good: they are
|
|
57
|
+
// what keeps the subject consistent scene to scene.
|
|
58
|
+
// - `masterOnly` — path / base64 / clipboard references. Master-only,
|
|
59
|
+
// exactly as before these parameters existed, so pre-existing calls
|
|
60
|
+
// still build byte-identical requests.
|
|
61
|
+
const carried = await resolveImageInputs({ images_url: args.master_images_url, images_file_uris: args.master_images_file_uris }, client);
|
|
62
|
+
const masterOnly = await resolveImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard }, client);
|
|
63
|
+
const masterRefInputs = [...masterOnly.inputs, ...carried.inputs];
|
|
64
|
+
const report = carried.report || masterOnly.report
|
|
65
|
+
? { ...masterOnly.report, ...carried.report }
|
|
66
|
+
: undefined;
|
|
47
67
|
const named = [];
|
|
48
68
|
const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
|
|
49
69
|
const result = await client.generate({
|
|
@@ -62,13 +82,13 @@ export function registerSetTools(server, client) {
|
|
|
62
82
|
if (mode === 'chain') {
|
|
63
83
|
let ref = master;
|
|
64
84
|
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 });
|
|
85
|
+
const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref, ...carried.inputs], seed: seed + i + 1, ...cfg });
|
|
66
86
|
named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` });
|
|
67
87
|
ref = img;
|
|
68
88
|
}
|
|
69
89
|
}
|
|
70
90
|
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])));
|
|
91
|
+
const scenes = await Promise.all(scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master, ...carried.inputs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0])));
|
|
72
92
|
scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` }));
|
|
73
93
|
}
|
|
74
94
|
return result;
|
|
@@ -79,6 +99,8 @@ export function registerSetTools(server, client) {
|
|
|
79
99
|
meta.text = masterText;
|
|
80
100
|
if (masterResult.grounding)
|
|
81
101
|
meta.grounding = masterResult.grounding;
|
|
102
|
+
if (report)
|
|
103
|
+
meta.image_inputs = report;
|
|
82
104
|
// A set is master + N scenes in one tools/call — the most timeout-prone
|
|
83
105
|
// tool. Effective image count drives the risk hint.
|
|
84
106
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client.mediaSink?.persistsFiles });
|
package/dist/tools/shared.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, McpToolError, readEnvVar } from '@chrischall/mcp-utils';
|
|
3
3
|
import { resolveVideoPath, videoMimeType } from '../images.js';
|
|
4
|
+
import { IMAGE_URL_MAX_BYTES } from '../fetch-image.js';
|
|
5
|
+
import { wholeMb } from '../bytes.js';
|
|
4
6
|
import { createDiskSink } from '../storage/media.js';
|
|
5
7
|
/**
|
|
6
8
|
* Fallback sink for call sites that pass no explicit one (every pre-existing
|
|
@@ -52,6 +54,31 @@ export const asyncSchema = z
|
|
|
52
54
|
.boolean()
|
|
53
55
|
.optional()
|
|
54
56
|
.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).');
|
|
57
|
+
/**
|
|
58
|
+
* Reference images the SERVER fetches, so the bytes never pass through the
|
|
59
|
+
* model's context window. Defined once and reused under both the `images_url`
|
|
60
|
+
* and `master_images_url` names.
|
|
61
|
+
*/
|
|
62
|
+
export function imagesUrlSchema(label = 'Reference images') {
|
|
63
|
+
return z
|
|
64
|
+
.array(z.string().url())
|
|
65
|
+
.optional()
|
|
66
|
+
.describe(`${label} as public https URLs — the SERVER downloads them, so no image bytes travel through the conversation. ` +
|
|
67
|
+
'Preferred over images_base64, which costs ~14k tokens per photo and breaks if a file read was truncated. ' +
|
|
68
|
+
`Max ${wholeMb(IMAGE_URL_MAX_BYTES)}MB each; must be a directly-linked image (Content-Type image/*).`);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Reference images already in the Gemini Files API. The cheapest form there is:
|
|
72
|
+
* a short string, reusable across calls until the file expires.
|
|
73
|
+
*/
|
|
74
|
+
export function imagesFileUrisSchema(label = 'Reference images') {
|
|
75
|
+
return z
|
|
76
|
+
.array(z.string().min(1))
|
|
77
|
+
.optional()
|
|
78
|
+
.describe(`${label} by Gemini Files API reference ("files/<id>", or the full uri) from gemini_upload_file or POST /upload. ` +
|
|
79
|
+
'Upload once, then reference it across as many calls as you like — no bytes are re-sent and none enter the conversation. ' +
|
|
80
|
+
'Files are retained ~48h, after which the reference stops resolving.');
|
|
81
|
+
}
|
|
55
82
|
/**
|
|
56
83
|
* The model/aspect/size/output fields every generation tool shares. Defined once
|
|
57
84
|
* here so descriptions can't drift between `generate.ts` and `set.ts`. Spread
|
|
@@ -271,10 +298,15 @@ export async function emit(named, opts, meta, onWritten) {
|
|
|
271
298
|
* - `video_path` — the Files API upload streams the file off disk.
|
|
272
299
|
*
|
|
273
300
|
* Called at the top of every generation handler, BEFORE any billable upstream
|
|
274
|
-
* call, so a caller gets a clear
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
301
|
+
* call, so a caller gets a clear alternative instead of an obscure
|
|
302
|
+
* module-resolution or ENOENT crash. A no-op on the disk sink, so the stdio
|
|
303
|
+
* behaviour (including its own "Image not found" errors) is untouched.
|
|
304
|
+
*
|
|
305
|
+
* The remediation deliberately does NOT lead with `images_base64` any more.
|
|
306
|
+
* Inlining a photo as a tool argument costs ~14k tokens of the model's context
|
|
307
|
+
* and silently corrupts whenever the read that produced it was truncated — so
|
|
308
|
+
* it is named last, after the three routes that move bytes without the model
|
|
309
|
+
* ever seeing them.
|
|
278
310
|
*/
|
|
279
311
|
export function assertLocalInputsAvailable(sink, args) {
|
|
280
312
|
if ((sink ?? DEFAULT_SINK).persistsFiles)
|
|
@@ -293,8 +325,14 @@ export function assertLocalInputsAvailable(sink, args) {
|
|
|
293
325
|
// Remediation goes in the MESSAGE, not just `hint`: MCP error serialization
|
|
294
326
|
// surfaces the message and drops the hint, so hint-only advice is invisible
|
|
295
327
|
// exactly when it is needed.
|
|
296
|
-
const fix = '
|
|
297
|
-
'
|
|
328
|
+
const fix = 'Reference images WITHOUT putting bytes in the conversation, in order of preference: ' +
|
|
329
|
+
'(1) `images_url` — public https URLs the server fetches itself; ' +
|
|
330
|
+
'(2) `images_file_uris` — upload once with `gemini_upload_file` (it takes a `url` or `data_base64`) and reuse the returned files/… uri; ' +
|
|
331
|
+
'(3) `POST /upload` on this connector with the raw bytes as the body ' +
|
|
332
|
+
"(`curl -X POST <connector-url>/upload -H 'Authorization: Bearer <token>' -H 'Content-Type: image/jpeg' --data-binary @photo.jpg`), " +
|
|
333
|
+
'then pass the returned file_uri. ' +
|
|
334
|
+
'As a last resort `images_base64` still works, but it costs ~14k tokens per photo and breaks on a truncated read. ' +
|
|
335
|
+
'Reference video by `video_url` (a public YouTube URL, or a files/… uri uploaded elsewhere) instead of `video_path`.';
|
|
298
336
|
throw new McpToolError(`${unavailable.join(', ')} ${unavailable.length > 1 ? 'are' : 'is'} unavailable on the hosted connector: ` +
|
|
299
337
|
`it runs on a Cloudflare Worker, which has no filesystem and no local clipboard. ${fix}`, { hint: fix });
|
|
300
338
|
}
|
package/dist/tools/video.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { McpToolError } from '@chrischall/mcp-utils';
|
|
3
|
-
import { slugify, baseName
|
|
3
|
+
import { slugify, baseName } from '../images.js';
|
|
4
|
+
import { resolveImageInputs } from '../inputs.js';
|
|
4
5
|
import { DEFAULT_VIDEO_MODEL } from '../models.js';
|
|
5
|
-
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
|
|
6
|
+
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
|
|
6
7
|
import { fingerprintRequest } from '../jobs.js';
|
|
7
8
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
8
9
|
/** omni's own aspect-ratio enum — NOT the image tools' ASPECT_RATIOS. */
|
|
@@ -27,7 +28,9 @@ export function registerVideoTools(server, client) {
|
|
|
27
28
|
aspect_ratio: z.enum(VIDEO_ASPECT_RATIOS).optional().describe('Output aspect ratio (omni: 16:9 or 9:16)'),
|
|
28
29
|
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)'),
|
|
29
30
|
images: z.array(z.string().min(1)).optional().describe('Reference image path(s) for image_to_video / reference_to_video'),
|
|
30
|
-
|
|
31
|
+
images_url: imagesUrlSchema('Reference stills'),
|
|
32
|
+
images_file_uris: imagesFileUrisSchema('Reference stills'),
|
|
33
|
+
images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation'),
|
|
31
34
|
from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS clipboard as a reference'),
|
|
32
35
|
filename: z.string().optional().describe('Base filename for the output video (extension stripped; default: slugified prompt)'),
|
|
33
36
|
output_dir: z.string().optional().describe('Directory to write the video to (default: $GEMINI_OUTPUT_DIR or cwd)'),
|
|
@@ -63,10 +66,11 @@ export function registerVideoTools(server, client) {
|
|
|
63
66
|
const fingerprint = fingerprintRequest('gemini_video_generate', {
|
|
64
67
|
model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, task: args.task,
|
|
65
68
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
69
|
+
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
66
70
|
previous_interaction_id: previousInteractionId,
|
|
67
71
|
});
|
|
68
72
|
return client.session.jobs.dispatch({ toolName: 'gemini_video_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
69
|
-
const inputs = await
|
|
73
|
+
const { inputs, report } = await resolveImageInputs(args, client);
|
|
70
74
|
const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client.generateVideo({
|
|
71
75
|
input: args.prompt,
|
|
72
76
|
images: inputs.length ? inputs : undefined,
|
|
@@ -82,6 +86,8 @@ export function registerVideoTools(server, client) {
|
|
|
82
86
|
meta.previous_interaction_id = previousInteractionId;
|
|
83
87
|
if (r.text)
|
|
84
88
|
meta.text = r.text;
|
|
89
|
+
if (report)
|
|
90
|
+
meta.image_inputs = report;
|
|
85
91
|
meta.hint = `To edit this video, call gemini_video_generate again with task: "edit" and previous_interaction_id: "${r.id}" (or continue_last: true).`;
|
|
86
92
|
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
87
93
|
const named = r.videos.map((media, i) => ({
|