@chrischall/gemini-mcp 1.13.0 → 1.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,8 @@
2
2
  * Where generated media goes.
3
3
  *
4
4
  * The stdio server writes images/video/audio to the local filesystem. The
5
- * hosted connector (a Cloudflare Worker) has **no filesystem at all** — no
5
+ * hosted connector runs on a remote machine with **no access to the caller's
6
+ * filesystem** — no
6
7
  * `writeFile`, no output dir, and therefore no `<image>.json` sidecar — so it
7
8
  * puts objects into an R2 bucket and hands back URLs instead of paths.
8
9
  *
@@ -49,8 +50,13 @@ const KEY_PREFIX_DEFAULT = 'gen';
49
50
  * How many listing pages one `listRecent` call will walk. A listing walk is
50
51
  * unbounded work and this runs on a tool call, so it is capped — and the cap
51
52
  * is REPORTED (see `listRecentPage`) rather than silently truncating.
53
+ *
54
+ * Doubled when sidecar records joined the same prefix: each generation now
55
+ * occupies two objects, so the old budget reached half as many of them.
56
+ * Reporting `truncated` sooner is not free — it is what sends someone to re-pay
57
+ * for an image that is sitting right there.
52
58
  */
53
- const MAX_LIST_PAGES = 10;
59
+ const MAX_LIST_PAGES = 20;
54
60
  /**
55
61
  * Split a stored key back into the day it was written and the name it was asked
56
62
  * for — the inverse of the key format `persist` builds.
@@ -78,6 +84,48 @@ export function safeKeySegment(base) {
78
84
  .replace(/^[-.]+|[-.]+$/g, '');
79
85
  return cleaned || 'media';
80
86
  }
87
+ /**
88
+ * Suffixes that turn a media key into its sidecar's key. Beside the object, not
89
+ * under a prefix of its own, so one retention sweep covers both.
90
+ *
91
+ * TWO of them, because only some records can answer "what do I chain from".
92
+ * `gemini_interact`, video and music produce an interaction id; a plain
93
+ * `gemini_image_generate` produces a prompt and nothing to resume. Splitting
94
+ * them by KEY is what lets a lookup skip the ones that can never match without
95
+ * opening them — forty ordinary generations used to bury the chain and make
96
+ * continue_last come back empty. Both end in `.json`, so one check still keeps
97
+ * records out of a media listing.
98
+ */
99
+ const SIDECAR_SUFFIX = '.json';
100
+ const CHAIN_SIDECAR_SUFFIX = '.chain.json';
101
+ /**
102
+ * How many sidecar records a lookup will READ before giving up.
103
+ *
104
+ * Deliberately small, and the reason both lookups walk lazily rather than
105
+ * reusing the listing: a record is a separate object, so "scan the last 200"
106
+ * means 200 store round trips on a tool call. Newest-first, stopping at the
107
+ * first match, the common case is one read — the chain being re-anchored is
108
+ * almost always the previous turn. Only a miss pays the full budget, and a miss
109
+ * is the error path.
110
+ */
111
+ const MAX_SIDECAR_READS = 40;
112
+ /** Longest prompt a sidecar keeps. Enough to recognise, not to reproduce. */
113
+ const SIDECAR_PROMPT_MAX = 500;
114
+ /** How many record reads a single listing will have open at once. */
115
+ const DECORATE_CONCURRENCY = 8;
116
+ /** `Promise.all` with a ceiling on how many run at once, preserving order. */
117
+ async function mapLimit(items, limit, fn) {
118
+ const out = new Array(items.length);
119
+ let next = 0;
120
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
121
+ while (next < items.length) {
122
+ const i = next++;
123
+ out[i] = await fn(items[i]);
124
+ }
125
+ });
126
+ await Promise.all(workers);
127
+ return out;
128
+ }
81
129
  /**
82
130
  * The hosted-connector sink: one R2 object per generated item.
83
131
  *
@@ -133,45 +181,54 @@ export function createR2Sink(bucket, opts) {
133
181
  async listRecentPage({ limit = 20, sinceDay } = {}) {
134
182
  if (!bucket.list)
135
183
  return { media: [], truncated: false, scannedPages: 0 };
136
- // Scoped at the STORE, not filtered afterwards: asking only for our own
137
- // prefix means another account's keys are never in hand to leak by a
138
- // filtering mistake.
139
- const prefix = await ownPrefix();
140
- const found = [];
141
- let cursor;
142
- let truncated = false;
143
- let scannedPages = 0;
144
- // Bounded: a listing walk is unbounded work, and this runs on a tool call.
145
- for (let page = 0; page < MAX_LIST_PAGES; page++) {
146
- scannedPages = page + 1;
147
- const listed = await bucket.list({ prefix, cursor, limit: 1000 });
148
- for (const obj of listed.objects) {
149
- const parsed = parseMediaKey(obj.key, prefix);
150
- if (!parsed)
151
- continue; // not a dated object key — skip, never throw
152
- if (sinceDay && parsed.day < sinceDay)
153
- continue;
154
- found.push({ ...parsed, key: obj.key, ...(obj.size !== undefined ? { sizeBytes: obj.size } : {}) });
155
- }
156
- if (!listed.truncated || !listed.cursor)
157
- break;
158
- cursor = listed.cursor;
159
- // Ran out of budget with more to read: the answer is a subset, and
160
- // must say so rather than passing for the whole picture.
161
- if (page === MAX_LIST_PAGES - 1)
162
- truncated = true;
184
+ const { entries, sidecarKeys, truncated, scannedPages } = await walk({ sinceDay });
185
+ const page = entries.slice(0, limit);
186
+ // Records are read for the RETURNED page only, never the whole walk: one
187
+ // per entry is one store round trip, and a listing of twenty must not
188
+ // become a listing of every object stored this month.
189
+ const media = await decorate(page, sidecarKeys);
190
+ return { media, truncated: truncated || entries.length > limit, scannedPages };
191
+ },
192
+ /**
193
+ * `<mediaKey>.json` (or `.chain.json`), deliberately beside the object
194
+ * rather than under a prefix of its own: the retention sweep that removes
195
+ * the image removes its record with it, so a sidecar can never outlive the
196
+ * thing it describes. `walk()` is what keeps records out of a media
197
+ * listing — it skips any key ending in the suffix BEFORE parsing, because
198
+ * `parseMediaKey` would happily parse one into a valid-looking entry.
199
+ */
200
+ async writeSidecar(key, record) {
201
+ try {
202
+ if (!(await owned(key)))
203
+ return;
204
+ const suffix = record.interaction_id ? CHAIN_SIDECAR_SUFFIX : SIDECAR_SUFFIX;
205
+ const body = new TextEncoder().encode(JSON.stringify({
206
+ ...record,
207
+ // Capped: a record exists to identify a generation later, not to
208
+ // store the brief. An 8k prompt would put more bytes in metadata than
209
+ // some images have, on every single object.
210
+ ...(record.prompt ? { prompt: record.prompt.slice(0, SIDECAR_PROMPT_MAX) } : {}),
211
+ created: now().toISOString(),
212
+ }));
213
+ await bucket.put(`${key}${suffix}`, body, { httpMetadata: { contentType: 'application/json' } });
163
214
  }
164
- // Keys sort lexicographically by day, and the id after it is random, so
165
- // ordering is by day — which is the honest grain: the store records no
166
- // per-object timestamp we could do better with.
167
- found.sort((a, b) => (a.day < b.day ? 1 : a.day > b.day ? -1 : a.key < b.key ? 1 : -1));
168
- const page = found.slice(0, limit);
169
- const expiresAtMs = now().getTime() + ttlMs;
170
- const media = await Promise.all(page.map(async (entry) => {
171
- const link = await describe(entry.key, expiresAtMs);
172
- return { ...entry, ...(link.unavailable ? {} : { url: link.ref, expiresAt: link.expiresAt }) };
173
- }));
174
- return { media, truncated: truncated || found.length > limit, scannedPages };
215
+ catch {
216
+ // Best-effort by design: the generation is the job, and a record that
217
+ // fails to write must not fail the image it describes.
218
+ }
219
+ },
220
+ async latestInteractionId(kind) {
221
+ return newestInteractionId(kind);
222
+ },
223
+ async findByInteraction(interactionId) {
224
+ const wanted = interactionId.trim();
225
+ if (!wanted)
226
+ return undefined;
227
+ const hit = await scanSidecars((record) => record.interaction_id === wanted);
228
+ // The record the scan already read is passed straight through: re-reading
229
+ // it to decorate the entry would spend a second round trip on an object
230
+ // we are holding.
231
+ return hit ? (await decorate([hit.entry], undefined, hit.record))[0] : undefined;
175
232
  },
176
233
  async resign(key) {
177
234
  if (!(await owned(key)))
@@ -202,14 +259,180 @@ export function createR2Sink(bucket, opts) {
202
259
  },
203
260
  note: () => publicBase
204
261
  ? 'Generated media was uploaded to R2 and the values above are public URLs (not local file paths) — open or download them directly. ' +
205
- 'This hosted connector has no filesystem, so `output_dir` is ignored and no <image>.json sidecar is written — capture interaction_id from this result to chain further turns.'
262
+ 'This connector has no filesystem, so `output_dir` is ignored — but the interaction id is recorded beside each image, so continue_last and chain recovery still work.'
206
263
  : signedBase
207
264
  ? 'Generated media is served by this connector at the signed URLs above — open them in a browser or fetch them with curl; no auth header is needed, the signature is in the link. ' +
208
265
  'They expire (see media[].expires_at), and the objects behind them are cleaned up on a retention schedule. ' +
209
- 'This hosted connector has no filesystem, so `output_dir` is ignored and no <image>.json sidecar is written — capture interaction_id from this result to chain further turns.'
266
+ 'This connector has no filesystem, so `output_dir` is ignored — but the interaction id is recorded beside each image, so continue_last and chain recovery still work.'
210
267
  : 'Generated media was stored, but this connector has no public media URL configured and no signing route available, so the values above are bare object keys rather than fetchable links. ' +
211
268
  'Set MEDIA_PUBLIC_BASE_URL on the Worker, or pass inline: true to receive the bytes directly.',
212
269
  };
270
+ /**
271
+ * List this account's objects, newest-first, splitting media from the sidecar
272
+ * records that sit beside them. No record is READ here — that is per-object
273
+ * work, and both callers want only a slice of it.
274
+ */
275
+ async function walk({ sinceDay }) {
276
+ // A put-only bucket is a supported shape. Answering empty lets the CALLER
277
+ // raise its own actionable error; asserting `bucket.list!` here threw a
278
+ // TypeError out of the sink instead.
279
+ if (!bucket.list)
280
+ return { entries: [], sidecarKeys: new Set(), truncated: false, scannedPages: 0 };
281
+ // Scoped at the STORE, not filtered afterwards: asking only for our own
282
+ // prefix means another account's keys are never in hand to leak by a
283
+ // filtering mistake.
284
+ const prefix = await ownPrefix();
285
+ const found = [];
286
+ const sidecarKeys = new Set();
287
+ let cursor;
288
+ let truncated = false;
289
+ let scannedPages = 0;
290
+ // Bounded: a listing walk is unbounded work, and this runs on a tool call.
291
+ for (let page = 0; page < MAX_LIST_PAGES; page++) {
292
+ scannedPages = page + 1;
293
+ const listed = await bucket.list({ prefix, cursor, limit: 1000 });
294
+ for (const obj of listed.objects) {
295
+ // A sidecar shares its media's key plus a suffix, so it lands in this
296
+ // same listing. It is metadata, not a generation: offering one as
297
+ // "recent media" would hand back a JSON blob to look at.
298
+ if (obj.key.endsWith(SIDECAR_SUFFIX)) {
299
+ sidecarKeys.add(obj.key);
300
+ continue;
301
+ }
302
+ const parsed = parseMediaKey(obj.key, prefix);
303
+ if (!parsed)
304
+ continue; // not a dated object key — skip, never throw
305
+ if (sinceDay && parsed.day < sinceDay)
306
+ continue;
307
+ found.push({ ...parsed, key: obj.key, ...(obj.size !== undefined ? { sizeBytes: obj.size } : {}) });
308
+ }
309
+ if (!listed.truncated || !listed.cursor)
310
+ break;
311
+ cursor = listed.cursor;
312
+ // Ran out of budget with more to read: the answer is a subset, and must
313
+ // say so rather than passing for the whole picture.
314
+ if (page === MAX_LIST_PAGES - 1)
315
+ truncated = true;
316
+ }
317
+ // Keys sort lexicographically by day, and the id after it is random, so
318
+ // ordering is by day — which is the honest grain: the store records no
319
+ // per-object timestamp we could do better with.
320
+ found.sort((a, b) => (a.day < b.day ? 1 : a.day > b.day ? -1 : a.key < b.key ? 1 : -1));
321
+ return { entries: found, sidecarKeys, truncated, scannedPages };
322
+ }
323
+ /**
324
+ * Attach a fresh link, and the sidecar record where one exists.
325
+ *
326
+ * `known` short-circuits the read for a caller that is already holding the
327
+ * record (see findByInteraction).
328
+ */
329
+ async function decorate(entries, sidecarKeys, known) {
330
+ const expiresAtMs = now().getTime() + ttlMs;
331
+ // Batched, not one Promise.all over the page: a listing of a hundred fired
332
+ // a hundred concurrent store reads while the id lookups were carefully
333
+ // budgeted. One tool call should not open a hundred connections to
334
+ // decorate a page.
335
+ return mapLimit(entries, DECORATE_CONCURRENCY, async (entry) => {
336
+ const link = await describe(entry.key, expiresAtMs);
337
+ // Only where the walk actually saw a record — media with none (anything
338
+ // generated before sidecars existed, and anything whose write failed)
339
+ // keeps its place and simply carries no metadata.
340
+ const has = sidecarKeys
341
+ ? sidecarKeys.has(`${entry.key}${CHAIN_SIDECAR_SUFFIX}`) || sidecarKeys.has(`${entry.key}${SIDECAR_SUFFIX}`)
342
+ : true;
343
+ const record = known ?? (has ? await readSidecar(entry.key, sidecarKeys) : undefined);
344
+ return {
345
+ ...entry,
346
+ ...(link.unavailable ? {} : { url: link.ref, expiresAt: link.expiresAt }),
347
+ ...(record?.interaction_id ? { interactionId: record.interaction_id } : {}),
348
+ ...(record?.prompt ? { prompt: record.prompt } : {}),
349
+ ...(record?.model ? { model: record.model } : {}),
350
+ ...(record?.created ? { createdAt: record.created } : {}),
351
+ };
352
+ });
353
+ }
354
+ /**
355
+ * Walk stored objects newest-first, reading at most {@link MAX_SIDECAR_READS}
356
+ * records, and stop at the first one the predicate accepts.
357
+ *
358
+ * Lazy on purpose. Reusing the listing would read every record on the page to
359
+ * answer a question that is nearly always settled by the first — a chain
360
+ * being re-anchored is the previous turn.
361
+ */
362
+ async function scanSidecars(accept) {
363
+ const { entries, sidecarKeys } = await walk({});
364
+ let reads = 0;
365
+ for (const entry of entries) {
366
+ // Chain records only: a prompt-only record cannot answer this, and
367
+ // reading it to find that out is what the budget was being spent on.
368
+ if (!sidecarKeys.has(`${entry.key}${CHAIN_SIDECAR_SUFFIX}`))
369
+ continue;
370
+ if (++reads > MAX_SIDECAR_READS)
371
+ return undefined;
372
+ const record = await readSidecar(entry.key);
373
+ if (record?.interaction_id && accept(record))
374
+ return { entry, record };
375
+ }
376
+ return undefined;
377
+ }
378
+ /**
379
+ * The most recent interaction id of a given kind, by the record's OWN
380
+ * timestamp.
381
+ *
382
+ * Key order cannot answer this. Keys sort by day and then by a random id, so
383
+ * within a day the order is arbitrary — reading "the newest" off it hands
384
+ * `continue_last` a turn from earlier in the session and silently resumes the
385
+ * wrong chain. So the newest DAY's records are read (bounded like every other
386
+ * scan) and compared on `created`.
387
+ *
388
+ * A day whose records are all of another kind falls through to the day
389
+ * before, rather than answering "none": an image chain does not end because
390
+ * the last thing generated was a video.
391
+ */
392
+ async function newestInteractionId(kind) {
393
+ const { entries, sidecarKeys } = await walk({});
394
+ let reads = 0;
395
+ let best;
396
+ let bestDay;
397
+ for (const entry of entries) {
398
+ // Entries are day-descending, so once a day has produced a match every
399
+ // later entry is older and cannot beat it.
400
+ if (bestDay && entry.day < bestDay)
401
+ break;
402
+ if (!sidecarKeys.has(`${entry.key}${CHAIN_SIDECAR_SUFFIX}`))
403
+ continue;
404
+ if (++reads > MAX_SIDECAR_READS)
405
+ break;
406
+ const record = await readSidecar(entry.key);
407
+ if (!record?.interaction_id || (record.kind ?? 'image') !== kind)
408
+ continue;
409
+ // A record written before `created` existed sorts oldest rather than
410
+ // winning on an empty string comparison.
411
+ const created = record.created ?? '';
412
+ if (!best || created > best.created)
413
+ best = { created, id: record.interaction_id };
414
+ bestDay = entry.day;
415
+ }
416
+ return best?.id;
417
+ }
418
+ /**
419
+ * A stored object's sidecar record, or undefined — never a throw. Prefers the
420
+ * chain record: an object has one or the other, never both.
421
+ */
422
+ async function readSidecar(key, sidecarKeys) {
423
+ const suffix = !sidecarKeys || sidecarKeys.has(`${key}${CHAIN_SIDECAR_SUFFIX}`) ? CHAIN_SIDECAR_SUFFIX : SIDECAR_SUFFIX;
424
+ try {
425
+ const obj = await bucket.get?.(`${key}${suffix}`);
426
+ if (!obj)
427
+ return undefined;
428
+ return JSON.parse(new TextDecoder().decode(new Uint8Array(await obj.arrayBuffer())));
429
+ }
430
+ catch {
431
+ // A malformed or unreadable record is the same as none: this decorates a
432
+ // listing, and a listing that throws is worse than one without prompts.
433
+ return undefined;
434
+ }
435
+ }
213
436
  /** One stored object → the ref the caller sees, plus its key and expiry. */
214
437
  async function describe(key, expiresAtMs) {
215
438
  // A bucket served directly (r2.dev or a custom domain) needs no signature.
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { McpToolError, minifiedResult } from '@chrischall/mcp-utils';
3
3
  import { withProgressHeartbeat } from './shared.js';
4
- import { base64ToBytes, wholeMb } from '../bytes.js';
4
+ import { base64ToBytes, bytesToBase64, formatMb, wholeMb } from '../bytes.js';
5
5
  import { fileNameFromUrl } from '../fetch-image.js';
6
6
  import { downloadFilename } from '../media-name.js';
7
7
  import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
@@ -17,8 +17,8 @@ import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfir
17
17
  * Three ways in, none of which put bytes in the conversation:
18
18
  *
19
19
  * - `gemini_upload_file` with `url` — the SERVER downloads it.
20
- * - `POST /upload` on the connector with the raw bytes as the request body —
21
- * the intended path for an agent with a shell (see README).
20
+ * - `gemini_get_upload_url` → a signed PUT → `gemini_upload_file` with the
21
+ * returned `r2_key` — the intended path for a caller with a shell.
22
22
  * - `gemini_upload_file` with `path` — stdio only, reads the local file.
23
23
  *
24
24
  * `data_base64` exists as a deliberate last resort: it is the one form that
@@ -36,8 +36,8 @@ import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfir
36
36
  * one. A Cloudflare isolate gets 128MB total, hence the much lower hosted
37
37
  * figure; Node has no comparable ceiling.
38
38
  *
39
- * `POST /upload` is unaffected: it streams, and is bounded only by the Files
40
- * API's own 2 GB cap.
39
+ * The signed-PUT route is unaffected: those bytes go straight to the object
40
+ * store and are bounded only by the Files API's own 2 GB cap when forwarded.
41
41
  */
42
42
  const UPLOAD_URL_MAX_BYTES_DISK = 100 * 1024 * 1024;
43
43
  const UPLOAD_URL_MAX_BYTES_HOSTED = 25 * 1024 * 1024;
@@ -60,6 +60,16 @@ function uploadResult(file) {
60
60
  hint: `Pass "${file.name}" in images_file_uris (or video_url for video) on any image tool. ${RETENTION_NOTE}`,
61
61
  });
62
62
  }
63
+ /**
64
+ * Ceiling on an inline view.
65
+ *
66
+ * Not a model limit — an image block is tokenized as an image, and a 1K render
67
+ * costs about a thousand tokens however many bytes it arrived as. This is the
68
+ * TRANSPORT: a host that caps tool-result size rejects or truncates an
69
+ * oversized payload, and a truncated image block reads as a broken tool rather
70
+ * than a big picture. 8MB clears any render this server produces at 1K or 2K.
71
+ */
72
+ const VIEW_MAX_BYTES = 8 * 1024 * 1024;
63
73
  export function registerFileTools(server, client) {
64
74
  const onDisk = client.mediaSink?.persistsFiles ?? true;
65
75
  const urlMaxBytes = onDisk ? UPLOAD_URL_MAX_BYTES_DISK : UPLOAD_URL_MAX_BYTES_HOSTED;
@@ -76,8 +86,8 @@ export function registerFileTools(server, client) {
76
86
  : ', or `r2_key` (re-upload media this connector generated, from media[].r2_key in an earlier result).') +
77
87
  (onDisk
78
88
  ? ''
79
- : ' This connector also accepts raw bytes over HTTP: POST them to /upload with the same Authorization header ' +
80
- 'and a Content-Type of image/*, which avoids base64 entirely.'),
89
+ : ' To upload a local file without base64: mint a signed PUT URL with gemini_get_upload_url, PUT the bytes to it, ' +
90
+ 'then pass the returned r2_key here.'),
81
91
  annotations: { readOnlyHint: false, openWorldHint: true },
82
92
  inputSchema: {
83
93
  url: z
@@ -86,7 +96,7 @@ export function registerFileTools(server, client) {
86
96
  .optional()
87
97
  .describe(`Public https URL the SERVER downloads and uploads (image/video/audio, up to ${wholeMb(urlMaxBytes)}MB). ` +
88
98
  'No bytes pass through the conversation.' +
89
- (onDisk ? '' : ' Larger files: POST the raw bytes to /upload, which streams instead of buffering.')),
99
+ (onDisk ? '' : ' Larger files: PUT them to a gemini_get_upload_url link and pass the r2_key instead — that path does not buffer.')),
90
100
  data_base64: z
91
101
  .string()
92
102
  .min(1)
@@ -108,7 +118,7 @@ export function registerFileTools(server, client) {
108
118
  .optional()
109
119
  .describe(onDisk
110
120
  ? 'Path to a local file (absolute, or resolved against $GEMINI_INPUT_DIR). Confirm-gated like every other local-file input.'
111
- : 'Unavailable on this hosted connector (no filesystem) — use `url`, `data_base64`, or POST /upload.'),
121
+ : 'Unavailable here: this deployment runs on a remote machine and cannot read your filesystem. Use `url`, `r2_key` (via gemini_get_upload_url), or `data_base64`.'),
112
122
  mime_type: z
113
123
  .string()
114
124
  .regex(/^[\w.+-]+\/[\w.+-]+$/, 'must be a MIME type like image/png')
@@ -126,8 +136,9 @@ export function registerFileTools(server, client) {
126
136
  }
127
137
  if (args.path) {
128
138
  if (!onDisk) {
129
- throw new McpToolError('`path` is unavailable on the hosted connector: it runs on a Cloudflare Worker, which has no filesystem. ' +
130
- '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.' });
139
+ throw new McpToolError('`path` is unavailable on the hosted connector: it runs on a remote machine with no access to your filesystem. ' +
140
+ 'Use `url` (the server downloads it), `r2_key` after PUTting the file to a URL from gemini_get_upload_url, or ' +
141
+ '`data_base64` if you cannot make HTTP requests at all.', { hint: 'Use `url`, `r2_key` (gemini_get_upload_url), or `data_base64`.' });
131
142
  }
132
143
  // Same gate as every other local-file input: a prompt-injected `path`
133
144
  // would otherwise ship an arbitrary local file to Google, and an
@@ -228,6 +239,44 @@ export function registerFileTools(server, client) {
228
239
  hint: 'In a Claude chat client, download this URL into your sandbox and present it as an output file — inline image blocks and markdown embeds do not render for the end user.',
229
240
  });
230
241
  });
242
+ // The tool that lets a model SEE what it made.
243
+ //
244
+ // On disk the result is a path an agent's own image tool can open. Here it
245
+ // is a signed URL, which is not the same thing at all: fetching it takes
246
+ // network I/O the caller may not be allowed to perform, and a URL is not
247
+ // something a vision model can look at. Without this, a generate → refine
248
+ // loop on the hosted connector runs blind.
249
+ //
250
+ // Deliberately its own tool rather than pixels on every result. Bytes on
251
+ // every turn is a cost nobody opted into, and the durable half of a result
252
+ // is the `r2_key` manifest — small enough to persist, and re-signable long
253
+ // after any URL in it has expired. This way the caller pays for eyes on the
254
+ // turns it actually looks, and an image from a LOST turn (found through
255
+ // gemini_list_recent_media) can be examined exactly the same way.
256
+ server.registerTool('gemini_view_media', {
257
+ description: 'Return a generated image as an inline image block, so you can actually SEE it — from its `r2_key`. ' +
258
+ 'The generation tools hand back links, which a model cannot look at; call this to check composition, colour ' +
259
+ 'or garbled text before refining, and to inspect an image recovered with gemini_list_recent_media. ' +
260
+ 'Costs image tokens on the turns you use it, which is why it is a separate call and not part of every result. ' +
261
+ 'Keywords: see the image, view, look at, verify output, check the result, preview.',
262
+ annotations: { readOnlyHint: true, openWorldHint: false },
263
+ inputSchema: {
264
+ r2_key: z.string().min(1).describe('The `media[].r2_key` from a generation result or gemini_list_recent_media'),
265
+ },
266
+ }, async (args) => {
267
+ const key = args.r2_key.trim();
268
+ const { bytes, mimeType } = await client.readStoredMedia(key);
269
+ if (bytes.byteLength > VIEW_MAX_BYTES) {
270
+ // A cap the TRANSPORT imposes, not the model: an oversized block is
271
+ // rejected or truncated on the way out, which reads as a broken tool
272
+ // rather than a large image. Say which it is, and name both ways
273
+ // through. Remediation in the MESSAGE — hints are dropped.
274
+ throw new McpToolError(`That image is too large to return inline (${formatMb(bytes.byteLength)}, limit ${formatMb(VIEW_MAX_BYTES)}). ` +
275
+ 'Re-render it at a smaller image_size (1K is plenty to check composition), or take the link from ' +
276
+ 'gemini_sign_media and open it outside the conversation.');
277
+ }
278
+ return { content: [{ type: 'image', data: bytesToBase64(bytes), mimeType }] };
279
+ });
231
280
  // The recovery path for media whose RESULT was lost rather than never
232
281
  // produced — a background job killed with its machine still wrote its
233
282
  // images, but every reference to them went with the response. Without this
@@ -267,6 +316,13 @@ export function registerFileTools(server, client) {
267
316
  r2_key: m.key,
268
317
  day: m.day,
269
318
  name: m.name,
319
+ // From the object's sidecar record, where it has one. This is what
320
+ // makes a listing identifiable rather than a wall of keys: which
321
+ // turn produced it, what was asked for, and the id to chain from.
322
+ ...(m.interactionId ? { interaction_id: m.interactionId } : {}),
323
+ ...(m.prompt ? { prompt: m.prompt } : {}),
324
+ ...(m.model ? { model: m.model } : {}),
325
+ ...(m.createdAt ? { created_at: m.createdAt } : {}),
270
326
  ...(m.sizeBytes !== undefined ? { size_bytes: m.sizeBytes } : {}),
271
327
  ...(m.url ? { url: m.url, curl_hint: `curl -sS -o ${downloadFilename(m.key)} "${m.url}"` } : {}),
272
328
  ...(m.expiresAt ? { expires_at: m.expiresAt } : {}),
@@ -3,7 +3,7 @@ import { readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
4
  import { slugify, baseName } from '../images.js';
5
5
  import { resolveImageInputs, requireImageInput } from '../inputs.js';
6
- import { emit, resolveAspectRatio, ASPECT_RATIOS, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt } from './shared.js';
6
+ import { emit, resolveAspectRatio, ASPECT_RATIOS, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesBase64Schema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt } from './shared.js';
7
7
  import { fingerprintRequest } from '../jobs.js';
8
8
  import { sumUsage } from '../usage.js';
9
9
  import { attachCost } from '../pricing.js';
@@ -27,7 +27,7 @@ export function registerGenerateTools(server, client) {
27
27
  images_url: imagesUrlSchema(),
28
28
  images_file_uris: imagesFileUrisSchema(),
29
29
  images_r2_keys: imagesR2KeysSchema(),
30
- 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
+ images_base64: imagesBase64Schema(),
31
31
  characters: charactersSchema,
32
32
  style: styleSchema,
33
33
  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)'),
@@ -152,7 +152,7 @@ export function registerGenerateTools(server, client) {
152
152
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, count, persistsFiles: client.mediaSink?.persistsFiles });
153
153
  if (risk)
154
154
  meta.timeout_risk = risk;
155
- return emit(named, { ...args, sink: client.mediaSink }, meta);
155
+ return emit(named, { ...args, sink: client.mediaSink, sidecar: { prompt: args.prompt } }, meta);
156
156
  });
157
157
  });
158
158
  server.registerTool('gemini_image_edit', {
@@ -167,7 +167,7 @@ export function registerGenerateTools(server, client) {
167
167
  images_url: imagesUrlSchema('Input images'),
168
168
  images_file_uris: imagesFileUrisSchema('Input images'),
169
169
  images_r2_keys: imagesR2KeysSchema('Input images'),
170
- 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'),
170
+ images_base64: imagesBase64Schema('Input images'),
171
171
  characters: charactersSchema,
172
172
  style: styleSchema,
173
173
  filename: z.string().optional().describe('Base filename for the output image (extension stripped; default: slugified prompt)'),
@@ -233,7 +233,7 @@ export function registerGenerateTools(server, client) {
233
233
  const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: client.mediaSink?.persistsFiles });
234
234
  if (risk)
235
235
  meta.timeout_risk = risk;
236
- return emit([{ image: img, base: slug }], { ...args, sink: client.mediaSink }, meta);
236
+ return emit([{ image: img, base: slug }], { ...args, sink: client.mediaSink, sidecar: { prompt: args.prompt } }, meta);
237
237
  });
238
238
  });
239
239
  }