@chrischall/gemini-mcp 1.12.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +12 -3
- package/dist/bundle.js +7402 -2377
- package/dist/client.js +38 -35
- package/dist/inputs.js +80 -3
- package/dist/jobs.js +49 -15
- package/dist/models.js +14 -4
- package/dist/pricing.js +9 -1
- package/dist/session.js +7 -6
- package/dist/storage/media.js +265 -42
- package/dist/tools/_confirm.js +2 -2
- package/dist/tools/files.js +73 -17
- package/dist/tools/generate.js +5 -5
- package/dist/tools/interact.js +65 -32
- package/dist/tools/jobs.js +2 -2
- package/dist/tools/library.js +7 -7
- package/dist/tools/models.js +2 -2
- package/dist/tools/music.js +31 -44
- package/dist/tools/set.js +3 -3
- package/dist/tools/shared.js +74 -43
- package/dist/tools/uploads.js +8 -8
- package/dist/tools/usage.js +2 -2
- package/dist/tools/video.js +27 -10
- package/dist/upload-url.js +3 -3
- package/dist/version.js +1 -1
- package/mint.yaml +6 -0
- package/package.json +4 -4
- package/server.json +2 -2
- package/skills/gemini-mcp/SKILL.md +16 -8
package/dist/storage/media.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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
|
|
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.
|
package/dist/tools/_confirm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { minifiedResult, schemaConfirm } from '@chrischall/mcp-utils';
|
|
2
2
|
import { previewImageInput, previewVideoInput } from '../images.js';
|
|
3
3
|
export { schemaConfirm };
|
|
4
4
|
/**
|
|
@@ -9,7 +9,7 @@ export { schemaConfirm };
|
|
|
9
9
|
export function previewUnlessConfirmed(confirm, action, method, path, body) {
|
|
10
10
|
if (confirm === true)
|
|
11
11
|
return null;
|
|
12
|
-
return
|
|
12
|
+
return minifiedResult({
|
|
13
13
|
dryRun: true,
|
|
14
14
|
action,
|
|
15
15
|
method,
|
package/dist/tools/files.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { McpToolError,
|
|
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
|
-
* - `
|
|
21
|
-
* the intended path for
|
|
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
|
-
*
|
|
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;
|
|
@@ -47,7 +47,7 @@ const RETENTION_NOTE = 'Gemini retains uploaded files for ~48h. Until then this
|
|
|
47
47
|
'(images_file_uris on gemini_image_generate / _edit / _set / gemini_interact, or video_url) without re-sending the bytes.';
|
|
48
48
|
/** Shape returned by every upload path. */
|
|
49
49
|
function uploadResult(file) {
|
|
50
|
-
return
|
|
50
|
+
return minifiedResult({
|
|
51
51
|
// The `files/<id>` form is what every tool parameter documents, so it leads.
|
|
52
52
|
file_uri: file.name,
|
|
53
53
|
// The absolute uri, for callers that would rather pass it through verbatim.
|
|
@@ -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
|
-
: '
|
|
80
|
-
'
|
|
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:
|
|
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
|
|
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
|
|
130
|
-
'Use `url` (the server downloads it), `
|
|
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
|
|
@@ -178,7 +189,7 @@ export function registerFileTools(server, client) {
|
|
|
178
189
|
},
|
|
179
190
|
}, async (args) => {
|
|
180
191
|
const files = await client.listFiles(args.page_size ?? 100);
|
|
181
|
-
return
|
|
192
|
+
return minifiedResult({
|
|
182
193
|
count: files.length,
|
|
183
194
|
files: files.map((f) => ({
|
|
184
195
|
file_uri: f.name,
|
|
@@ -220,7 +231,7 @@ export function registerFileTools(server, client) {
|
|
|
220
231
|
// Remediation goes in the MESSAGE: MCP serialization drops hints.
|
|
221
232
|
throw new McpToolError(`No stored media for r2_key "${args.r2_key}" — it has probably passed its retention window. Re-run the generation to get a new one.`, { hint: 'Objects are swept on a retention schedule (MEDIA_TTL_DAYS, default 7 days).' });
|
|
222
233
|
}
|
|
223
|
-
return
|
|
234
|
+
return minifiedResult({
|
|
224
235
|
url: fresh.ref,
|
|
225
236
|
r2_key: fresh.key,
|
|
226
237
|
...(fresh.expiresAt ? { expires_at: fresh.expiresAt } : {}),
|
|
@@ -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
|
|
@@ -258,7 +307,7 @@ export function registerFileTools(server, client) {
|
|
|
258
307
|
? await sink.listRecentPage({ limit, sinceDay: args.since_day })
|
|
259
308
|
: { media: await sink.listRecent({ limit, sinceDay: args.since_day }), truncated: false };
|
|
260
309
|
const found = page.media;
|
|
261
|
-
return
|
|
310
|
+
return minifiedResult({
|
|
262
311
|
count: found.length,
|
|
263
312
|
// Never let a bounded walk pass for the whole picture: concluding an
|
|
264
313
|
// image is gone is what makes someone pay to generate it twice.
|
|
@@ -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 } : {}),
|
|
@@ -293,6 +349,6 @@ export function registerFileTools(server, client) {
|
|
|
293
349
|
if (gate)
|
|
294
350
|
return gate;
|
|
295
351
|
await client.deleteFile(args.file_uri);
|
|
296
|
-
return
|
|
352
|
+
return minifiedResult({ deleted: args.file_uri });
|
|
297
353
|
});
|
|
298
354
|
}
|
package/dist/tools/generate.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
}
|