@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.
@@ -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
+ }
@@ -1,8 +1,9 @@
1
1
  import { z } from 'zod';
2
- import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
2
+ import { readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
- import { slugify, baseName, gatherImageInputs } from '../images.js';
5
- import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
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
- images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs'),
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
- const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
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
- images_base64: z.array(z.string().min(1)).optional().describe('Input images as base64 strings or data URIs'),
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
- const hasPaths = (args.images?.length ?? 0) > 0;
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 gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
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)
@@ -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, gatherImageInputs, resolveImagePath, writeSidecar, resolveOutputDir, readImageAsInline } from '../images.js';
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 `gatherImageInputs` surfaces its usual error.
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. ${NEW_REFERENCES_ONLY}`),
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 gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
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 });
@@ -1,8 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { McpToolError } from '@chrischall/mcp-utils';
3
- import { slugify, baseName, gatherImageInputs } from '../images.js';
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
- images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs'),
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 gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
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, gatherImageInputs } from '../images.js';
5
- import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
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
- master_images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs for master generation'),
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
- const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
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 });
@@ -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 "use images_base64 / video_url instead"
275
- * instead of an obscure module-resolution or ENOENT crash. A no-op on the disk
276
- * sink, so the stdio behaviour (including its own "Image not found" errors) is
277
- * untouched.
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 = 'Send image bytes as `images_base64` (raw base64 or a data: URI) instead of `images`/`master_images`/`from_clipboard`, ' +
297
- 'and reference video by `video_url` (a public YouTube URL, or a files/… uri uploaded elsewhere) instead of `video_path`.';
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
  }
@@ -1,8 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { McpToolError } from '@chrischall/mcp-utils';
3
- import { slugify, baseName, gatherImageInputs } from '../images.js';
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
- images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs'),
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 gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
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) => ({