@chrischall/gemini-mcp 1.0.4 → 1.1.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/dist/inputs.js ADDED
@@ -0,0 +1,186 @@
1
+ import { McpToolError } from '@chrischall/mcp-utils';
2
+ import { IMAGE_INLINE_MAX_BYTES } from './fetch-image.js';
3
+ /** True when this call carries at least one image input of any kind. */
4
+ export function hasImageInput(args) {
5
+ return Boolean(args.images?.length ||
6
+ args.images_base64?.length ||
7
+ args.images_url?.length ||
8
+ args.images_file_uris?.length ||
9
+ args.from_clipboard);
10
+ }
11
+ /** Conservative TTL when the API doesn't tell us: documented retention is ~48h. */
12
+ const ASSUMED_TTL_MS = 47 * 60 * 60 * 1000;
13
+ function expiresAtMs(expirationTime, now) {
14
+ if (expirationTime) {
15
+ const t = Date.parse(expirationTime);
16
+ if (Number.isFinite(t))
17
+ return t;
18
+ }
19
+ return now + ASSUMED_TTL_MS;
20
+ }
21
+ /**
22
+ * Resolve every image input a tool was given into {@link ImageInput}s.
23
+ *
24
+ * Order is stable and matches the pre-`images_url` behaviour so existing calls
25
+ * produce byte-identical requests: clipboard, then paths, then base64, then
26
+ * fetched URLs, then Files API references.
27
+ *
28
+ * A URL repeated within one call is fetched ONCE. That is what lets
29
+ * `gemini_image_set` hand the same reference to the master and all N scene
30
+ * generations without paying for N downloads (or N uploads).
31
+ */
32
+ export async function resolveImageInputs(args, client) {
33
+ const report = {};
34
+ const inputs = [];
35
+ const onDisk = client.mediaSink?.persistsFiles ?? true;
36
+ // 1. clipboard (macOS, stdio only). Imported dynamically so `child_process`
37
+ // stays off the default path — and off the Worker's module graph entirely.
38
+ if (args.from_clipboard) {
39
+ inputs.push(await (await import('./clipboard.js')).readClipboardImage());
40
+ }
41
+ // 2. local paths — inline, unless this session has seen the same file before.
42
+ for (const path of args.images ?? []) {
43
+ inputs.push(await resolveLocalPath(path, client, onDisk, report));
44
+ }
45
+ // 3. raw base64 / data URIs
46
+ if (args.images_base64?.length) {
47
+ const { decodeImageInput } = await import('./images.js');
48
+ for (const b64 of args.images_base64)
49
+ inputs.push(decodeImageInput(b64));
50
+ }
51
+ // 4. https URLs, fetched by the server. Deduped within the call.
52
+ const fetched = new Map();
53
+ for (const url of args.images_url ?? []) {
54
+ const hit = fetched.get(url);
55
+ if (hit) {
56
+ inputs.push(hit);
57
+ continue;
58
+ }
59
+ const resolved = await resolveUrl(url, client, report);
60
+ fetched.set(url, resolved);
61
+ inputs.push(resolved);
62
+ }
63
+ // 5. Files API references — no bytes move at all.
64
+ const seenUris = new Map();
65
+ for (const ref of args.images_file_uris ?? []) {
66
+ const hit = seenUris.get(ref);
67
+ if (hit) {
68
+ inputs.push(hit);
69
+ continue;
70
+ }
71
+ const file = await client.getFile(ref);
72
+ const input = { uri: file.uri, mimeType: file.mimeType };
73
+ seenUris.set(ref, input);
74
+ (report.file_uris ??= []).push(file.name);
75
+ inputs.push(input);
76
+ }
77
+ return { inputs, report: Object.keys(report).length > 0 ? report : undefined };
78
+ }
79
+ /**
80
+ * One local path → an inline input, or a Files API reference once this session
81
+ * has referenced the same bytes more than once.
82
+ *
83
+ * Identity is path + mtime + size, so editing the file in place produces a new
84
+ * key rather than silently reusing an upload of the old content.
85
+ */
86
+ async function resolveLocalPath(path, client, onDisk, report) {
87
+ const { readImageAsInline, resolveImagePath } = await import('./images.js');
88
+ // A runtime with no filesystem never gets here — `assertLocalInputsAvailable`
89
+ // rejects `images` before any of this — but if it somehow did, reading the
90
+ // file is what would fail, so keep the pre-existing error path.
91
+ if (!onDisk)
92
+ return readImageAsInline(path);
93
+ const { stat } = await import('node:fs/promises');
94
+ const resolved = resolveImagePath(path);
95
+ let key;
96
+ try {
97
+ const { mtimeMs, size } = await stat(resolved);
98
+ // NUL separator, written as the \u0000 ESCAPE and never a literal NUL
99
+ // byte: a literal one makes git classify this whole file as binary and
100
+ // stop diffing it (same trap as fingerprintRequest in jobs.ts). It cannot
101
+ // occur in a path, so no crafted name can straddle a field boundary.
102
+ key = `path\u0000${resolved}\u0000${mtimeMs}\u0000${size}`;
103
+ }
104
+ catch {
105
+ // Unstattable but resolvable is odd; fall back to plain inline rather than
106
+ // failing a call that would otherwise have worked.
107
+ return readImageAsInline(path);
108
+ }
109
+ const cached = client.session.cachedUpload(key);
110
+ if (cached)
111
+ return { uri: cached.uri, mimeType: cached.mimeType };
112
+ const references = client.session.countReference(key);
113
+ const inline = await readImageAsInline(resolved);
114
+ if (references < 2)
115
+ return inline;
116
+ // Second sighting: pay one upload now so every later reference is free.
117
+ // Best-effort — an upload failure must not fail a call that inline handles
118
+ // perfectly well.
119
+ try {
120
+ const bytes = Uint8Array.from(atob(inline.base64), (c) => c.charCodeAt(0));
121
+ const displayName = resolved.split(/[\\/]/).pop() || 'image';
122
+ const uploaded = await client.uploadBytes(bytes, inline.mimeType, displayName);
123
+ client.session.uploadCache.set(key, {
124
+ name: uploaded.name,
125
+ uri: uploaded.uri,
126
+ mimeType: uploaded.mimeType,
127
+ expiresAtMs: expiresAtMs(uploaded.expirationTime, Date.now()),
128
+ });
129
+ (report.auto_uploaded ??= []).push({ path: resolved, file_uri: uploaded.name, expires: uploaded.expirationTime });
130
+ return { uri: uploaded.uri, mimeType: uploaded.mimeType };
131
+ }
132
+ catch {
133
+ return inline;
134
+ }
135
+ }
136
+ /** One https URL → inline bytes, or a Files API reference when it's large. */
137
+ async function resolveUrl(url, client, report) {
138
+ const fetched = await client.fetchRemoteImage(url);
139
+ const entry = {
140
+ url,
141
+ mime_type: fetched.mimeType,
142
+ bytes: fetched.size,
143
+ };
144
+ (report.fetched_urls ??= []).push(entry);
145
+ if (fetched.size <= IMAGE_INLINE_MAX_BYTES) {
146
+ return { base64: encodeBase64(fetched.bytes), mimeType: fetched.mimeType };
147
+ }
148
+ const displayName = fileNameFromUrl(url);
149
+ const uploaded = await client.uploadBytes(fetched.bytes, fetched.mimeType, displayName);
150
+ entry.file_uri = uploaded.name;
151
+ return { uri: uploaded.uri, mimeType: uploaded.mimeType };
152
+ }
153
+ /** Last path segment of a URL, for the Files API display name. */
154
+ function fileNameFromUrl(url) {
155
+ try {
156
+ const name = new URL(url).pathname.split('/').filter(Boolean).pop();
157
+ return name ? decodeURIComponent(name).slice(0, 120) : 'image';
158
+ }
159
+ catch {
160
+ return 'image';
161
+ }
162
+ }
163
+ /**
164
+ * bytes → base64 without `Buffer`. `btoa` is the primitive both runtimes share
165
+ * (see `storage/media.ts`, which decodes the same way); chunked because
166
+ * `String.fromCharCode(...huge)` blows the argument limit.
167
+ */
168
+ function encodeBase64(bytes) {
169
+ let binary = '';
170
+ const CHUNK = 0x8000;
171
+ for (let i = 0; i < bytes.length; i += CHUNK) {
172
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
173
+ }
174
+ return btoa(binary);
175
+ }
176
+ /**
177
+ * Guard for tools that REQUIRE at least one reference image
178
+ * (`gemini_image_edit`). Names every accepted param, in the order a caller
179
+ * should prefer them — the context-free ones first.
180
+ */
181
+ export function requireImageInput(args) {
182
+ if (hasImageInput(args))
183
+ return;
184
+ throw new McpToolError('Provide at least one input image via `images_url` (an https URL the server fetches), `images_file_uris` ' +
185
+ '(from gemini_upload_file), `images` (a local path), `images_base64`, or `from_clipboard`.');
186
+ }
package/dist/session.js CHANGED
@@ -44,6 +44,43 @@ export class SessionState {
44
44
  * model against the requested edit.
45
45
  */
46
46
  writtenOutputs = new Set();
47
+ /**
48
+ * Files API uploads this session can reuse, keyed by an opaque identity
49
+ * string (for a local path: absolute path + mtime + size, so an edited file
50
+ * is a different key rather than a stale hit).
51
+ *
52
+ * Backs the stdio "referenced twice → upload once" optimization: the second
53
+ * time a session sends the same picture, the bytes are replaced by a
54
+ * `files/…` reference that costs nothing to repeat. Session-scoped, like
55
+ * everything else here — a uri is minted by ONE user's API key and is only
56
+ * readable with that key, so sharing this map across the connector's tenants
57
+ * would hand out references another user cannot use and this user did not
58
+ * create.
59
+ */
60
+ uploadCache = new Map();
61
+ /**
62
+ * How many times each identity has been referenced this session. The
63
+ * threshold for auto-uploading is "more than once", so the count has to
64
+ * outlive the call that first saw it.
65
+ */
66
+ referenceCounts = new Map();
67
+ /** Record a reference and return the running total (1 on first sight). */
68
+ countReference(key) {
69
+ const next = (this.referenceCounts.get(key) ?? 0) + 1;
70
+ this.referenceCounts.set(key, next);
71
+ return next;
72
+ }
73
+ /** A cached upload, or `undefined` if absent or past its Files API TTL. */
74
+ cachedUpload(key, now = Date.now()) {
75
+ const hit = this.uploadCache.get(key);
76
+ if (!hit)
77
+ return undefined;
78
+ if (hit.expiresAtMs <= now) {
79
+ this.uploadCache.delete(key);
80
+ return undefined;
81
+ }
82
+ return hit;
83
+ }
47
84
  /** Forget everything. Tests use this; nothing in `src/` calls it. */
48
85
  reset() {
49
86
  this.jobs.reset();
@@ -51,5 +88,7 @@ export class SessionState {
51
88
  this.lastMusicInteractionId = undefined;
52
89
  this.lastVideoInteractionId = undefined;
53
90
  this.writtenOutputs.clear();
91
+ this.uploadCache.clear();
92
+ this.referenceCounts.clear();
54
93
  }
55
94
  }
@@ -0,0 +1,194 @@
1
+ import { z } from 'zod';
2
+ import { McpToolError, textResult } from '@chrischall/mcp-utils';
3
+ import { withProgressHeartbeat } from './shared.js';
4
+ import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
5
+ /**
6
+ * First-class Gemini Files API access.
7
+ *
8
+ * The problem these solve: on a hosted connector there is no filesystem, so the
9
+ * only way to hand over a picture used to be `images_base64` — and a base64
10
+ * JPEG is ~14k tokens of the model's context window per photo, corrupted
11
+ * outright whenever the file read that produced it was truncated. A Files API
12
+ * reference is a 20-character string that any number of later calls can reuse.
13
+ *
14
+ * Three ways in, none of which put bytes in the conversation:
15
+ *
16
+ * - `gemini_upload_file` with `url` — the SERVER downloads it.
17
+ * - `POST /upload` on the connector with the raw bytes as the request body —
18
+ * the intended path for an agent with a shell (see README).
19
+ * - `gemini_upload_file` with `path` — stdio only, reads the local file.
20
+ *
21
+ * `data_base64` exists as a deliberate last resort: it is the one form that
22
+ * *does* cost context, and it is documented as such.
23
+ *
24
+ * Uploads are retained ~48h. After that the uri stops resolving — which,
25
+ * unhelpfully, surfaces as the same generic 404 an unknown model id produces
26
+ * (see `ChainedRequest404Error` in client.ts), so the expiry is stated
27
+ * everywhere a uri is returned.
28
+ */
29
+ /**
30
+ * A `url` upload is buffered whole before being forwarded (fetched bytes, then
31
+ * a Blob copy of them), so the cap has to be one the runtime can actually hold
32
+ * — an advertised limit that fails as an OOM is worse than a smaller honest
33
+ * one. A Cloudflare isolate gets 128MB total, hence the much lower hosted
34
+ * figure; Node has no comparable ceiling.
35
+ *
36
+ * `POST /upload` is unaffected: it streams, and is bounded only by the Files
37
+ * API's own 2 GB cap.
38
+ */
39
+ const UPLOAD_URL_MAX_BYTES_DISK = 100 * 1024 * 1024;
40
+ const UPLOAD_URL_MAX_BYTES_HOSTED = 25 * 1024 * 1024;
41
+ const mb = (bytes) => Math.round(bytes / (1024 * 1024));
42
+ /** What a `url` upload will accept: the media the Files API is useful for. */
43
+ const UPLOAD_ACCEPT = ['image/', 'video/', 'audio/'];
44
+ const RETENTION_NOTE = 'Gemini retains uploaded files for ~48h. Until then this reference can be reused across any number of calls ' +
45
+ '(images_file_uris on gemini_image_generate / _edit / _set / gemini_interact, or video_url) without re-sending the bytes.';
46
+ /** Shape returned by every upload path. */
47
+ function uploadResult(file) {
48
+ return textResult({
49
+ // The `files/<id>` form is what every tool parameter documents, so it leads.
50
+ file_uri: file.name,
51
+ // The absolute uri, for callers that would rather pass it through verbatim.
52
+ uri: file.uri,
53
+ name: file.name,
54
+ mime_type: file.mimeType,
55
+ ...(file.sizeBytes ? { size_bytes: Number(file.sizeBytes) } : {}),
56
+ ...(file.displayName ? { display_name: file.displayName } : {}),
57
+ expires: file.expirationTime ?? '~48h from upload',
58
+ hint: `Pass "${file.name}" in images_file_uris (or video_url for video) on any image tool. ${RETENTION_NOTE}`,
59
+ });
60
+ }
61
+ export function registerFileTools(server, client) {
62
+ const onDisk = client.mediaSink?.persistsFiles ?? true;
63
+ const urlMaxBytes = onDisk ? UPLOAD_URL_MAX_BYTES_DISK : UPLOAD_URL_MAX_BYTES_HOSTED;
64
+ server.registerTool('gemini_upload_file', {
65
+ description: 'Upload an image (or video/audio) to the Gemini Files API ONCE and get back a reusable `files/<id>` reference. ' +
66
+ 'Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever ' +
67
+ 'enter the conversation, and it can be reused across many generations until it expires (~48h). ' +
68
+ 'Provide exactly one of `url` (the server downloads it), `data_base64`' +
69
+ (onDisk ? ', or `path` (a local file).' : '.') +
70
+ (onDisk
71
+ ? ''
72
+ : ' This connector also accepts raw bytes over HTTP: POST them to /upload with the same Authorization header ' +
73
+ 'and a Content-Type of image/*, which avoids base64 entirely.'),
74
+ annotations: { readOnlyHint: false, openWorldHint: true },
75
+ inputSchema: {
76
+ url: z
77
+ .string()
78
+ .url()
79
+ .optional()
80
+ .describe(`Public https URL the SERVER downloads and uploads (image/video/audio, up to ${mb(urlMaxBytes)}MB). ` +
81
+ 'No bytes pass through the conversation.' +
82
+ (onDisk ? '' : ' Larger files: POST the raw bytes to /upload, which streams instead of buffering.')),
83
+ data_base64: z
84
+ .string()
85
+ .min(1)
86
+ .optional()
87
+ .describe('Raw base64 or a data: URI. Last resort — this is the one form that costs model context (~14k tokens for a modest JPEG).'),
88
+ path: z
89
+ .string()
90
+ .min(1)
91
+ .optional()
92
+ .describe(onDisk
93
+ ? 'Path to a local file (absolute, or resolved against $GEMINI_INPUT_DIR). Confirm-gated like every other local-file input.'
94
+ : 'Unavailable on this hosted connector (no filesystem) — use `url`, `data_base64`, or POST /upload.'),
95
+ mime_type: z
96
+ .string()
97
+ .regex(/^[\w.+-]+\/[\w.+-]+$/, 'must be a MIME type like image/png')
98
+ .optional()
99
+ .describe('Override the detected MIME type (sniffed from the bytes / taken from the server response otherwise)'),
100
+ display_name: z.string().min(1).optional().describe('Human-readable name recorded against the upload'),
101
+ confirm: schemaConfirm,
102
+ },
103
+ }, async (args, extra) => {
104
+ const sources = [args.url && 'url', args.data_base64 && 'data_base64', args.path && 'path'].filter(Boolean);
105
+ if (sources.length !== 1) {
106
+ throw new McpToolError(sources.length === 0
107
+ ? 'Provide exactly one of `url`, `data_base64`' + (onDisk ? ' or `path`.' : '.')
108
+ : `Provide exactly one source, not ${sources.length} (${sources.join(', ')}).`);
109
+ }
110
+ if (args.path) {
111
+ if (!onDisk) {
112
+ throw new McpToolError('`path` is unavailable on the hosted connector: it runs on a Cloudflare Worker, which has no filesystem. ' +
113
+ '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.' });
114
+ }
115
+ // Same gate as every other local-file input: a prompt-injected `path`
116
+ // would otherwise ship an arbitrary local file to Google, and an
117
+ // uploaded file is readable by anyone holding the api key for ~48h.
118
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Upload a local file to the Gemini Files API', '/upload/v1beta/files', [args.path]);
119
+ if (gate)
120
+ return gate;
121
+ }
122
+ const uploaded = await withProgressHeartbeat(extra, 'Uploading to the Gemini Files API', async () => {
123
+ if (args.url) {
124
+ const fetched = await client.fetchRemoteImage(args.url, { maxBytes: urlMaxBytes, accept: UPLOAD_ACCEPT });
125
+ return client.uploadBytes(fetched.bytes, args.mime_type ?? fetched.mimeType, args.display_name ?? nameFromUrl(args.url));
126
+ }
127
+ if (args.data_base64) {
128
+ const { decodeImageInput } = await import('../images.js');
129
+ const decoded = decodeImageInput(args.data_base64);
130
+ const bytes = Uint8Array.from(atob(decoded.base64), (c) => c.charCodeAt(0));
131
+ return client.uploadBytes(bytes, args.mime_type ?? decoded.mimeType, args.display_name ?? 'upload');
132
+ }
133
+ const { readImageAsInline, resolveImagePath } = await import('../images.js');
134
+ const resolved = resolveImagePath(args.path);
135
+ const inline = await readImageAsInline(resolved);
136
+ const bytes = Uint8Array.from(atob(inline.base64), (c) => c.charCodeAt(0));
137
+ return client.uploadBytes(bytes, args.mime_type ?? inline.mimeType, args.display_name ?? (resolved.split(/[\\/]/).pop() || 'upload'));
138
+ });
139
+ return uploadResult(uploaded);
140
+ });
141
+ server.registerTool('gemini_list_files', {
142
+ description: 'List the files currently uploaded to the Gemini Files API under this API key, with their `files/<id>` references, ' +
143
+ 'MIME types and expiry times. Retention is ~48h, so an entry that has vanished has expired rather than failed.',
144
+ annotations: { readOnlyHint: true, openWorldHint: true },
145
+ inputSchema: {
146
+ page_size: z.number().int().positive().max(100).optional().describe('Maximum files to return (1-100, default 100)'),
147
+ },
148
+ }, async (args) => {
149
+ const files = await client.listFiles(args.page_size ?? 100);
150
+ return textResult({
151
+ count: files.length,
152
+ files: files.map((f) => ({
153
+ file_uri: f.name,
154
+ uri: f.uri,
155
+ mime_type: f.mimeType,
156
+ ...(f.displayName ? { display_name: f.displayName } : {}),
157
+ ...(f.sizeBytes ? { size_bytes: Number(f.sizeBytes) } : {}),
158
+ ...(f.state ? { state: f.state } : {}),
159
+ ...(f.createTime ? { created: f.createTime } : {}),
160
+ ...(f.expirationTime ? { expires: f.expirationTime } : {}),
161
+ })),
162
+ hint: files.length
163
+ ? `Pass any file_uri above in images_file_uris on an image tool. ${RETENTION_NOTE}`
164
+ : 'No files uploaded (or all have expired — retention is ~48h). Upload one with gemini_upload_file.',
165
+ });
166
+ });
167
+ server.registerTool('gemini_delete_file', {
168
+ description: 'Delete an uploaded file from the Gemini Files API before its ~48h expiry. Any tool call still referencing it ' +
169
+ 'will then fail with a generic 404, so delete only references you are finished with.',
170
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
171
+ inputSchema: {
172
+ file_uri: z.string().min(1).describe('The `files/<id>` reference (or full uri) to delete'),
173
+ confirm: schemaConfirm,
174
+ },
175
+ }, async (args) => {
176
+ // Deleting is a remote mutation with no undo — confirm-gated per the
177
+ // fleet convention (unlike writing a generated image, which is local).
178
+ const gate = previewUnlessConfirmed(args.confirm, 'Delete an uploaded Gemini file', 'DELETE', `/v1beta/${args.file_uri}`);
179
+ if (gate)
180
+ return gate;
181
+ await client.deleteFile(args.file_uri);
182
+ return textResult({ deleted: args.file_uri });
183
+ });
184
+ }
185
+ /** Last path segment of a URL, for the Files API display name. */
186
+ function nameFromUrl(url) {
187
+ try {
188
+ const name = new URL(url).pathname.split('/').filter(Boolean).pop();
189
+ return name ? decodeURIComponent(name).slice(0, 120) : 'upload';
190
+ }
191
+ catch {
192
+ return 'upload';
193
+ }
194
+ }
@@ -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
@@ -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 });