@chrischall/gemini-mcp 2.1.3 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -6,6 +6,7 @@ import { createDiskSink, createR2Sink, tenantIdFor } from './storage/media.js';
6
6
  import { createR2Library } from './library.js';
7
7
  import { createUploadUrlMinter } from './upload-url.js';
8
8
  import { blobStoreFromEnv } from './blob-store.js';
9
+ import { pinnedTenant } from './tenant.js';
9
10
  import { createBlobJobStore } from './job-store.js';
10
11
  import { SessionState } from './session.js';
11
12
  import { fetchRemoteImage, readCapped, MAX_REDIRECTS } from './fetch-image.js';
@@ -367,6 +368,17 @@ export class GeminiClient {
367
368
  const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: 'Video' });
368
369
  return this.uploadToFilesApi(blob, mimeType, basename(path), blob.size);
369
370
  }
371
+ /**
372
+ * Upload ANY local file (image, video, audio) by streaming it from disk —
373
+ * the {@link uploadVideo} path without the video label. `gemini_upload_file`
374
+ * `path` uses this: reading the file into a Buffer, base64-encoding it and
375
+ * decoding it back held ~3x the file in memory and hit V8's max string
376
+ * length on a few-hundred-MB video (chrischall/fleet-audit#117).
377
+ */
378
+ async uploadFile(path, mimeType, displayName = basename(path)) {
379
+ const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: 'File' });
380
+ return this.uploadToFilesApi(blob, mimeType, displayName, blob.size);
381
+ }
370
382
  /**
371
383
  * Upload already-in-memory bytes (a fetched `images_url`, a `data_base64`
372
384
  * payload, an inline `gemini_upload_file` body) to the Files API. Same three
@@ -1155,15 +1167,18 @@ function isDeliveryUnsupported(err) {
1155
1167
  *
1156
1168
  * Side-effect-free at module scope: reading two env vars and closing over them.
1157
1169
  */
1158
- function hostedStorage() {
1159
- const blob = blobStoreFromEnv();
1170
+ export function hostedStorage(blob = blobStoreFromEnv()) {
1160
1171
  if (!blob)
1161
1172
  return {};
1162
- // Namespaced by API key like the connector was. One registration is one
1163
- // user here, so this is belt-and-braces rather than the tenancy boundary —
1164
- // that is the blob store's per-registration key. Lazy because the key is
1165
- // read at request time, not at construction (deferred-config-error).
1166
- const tenant = () => tenantIdFor(readEnvVar('GEMINI_API_KEY') ?? 'local');
1173
+ // One registration is one user here, so the namespace is belt-and-braces
1174
+ // rather than the tenancy boundary — that is the blob store's
1175
+ // per-registration key. It must be STABLE, though: it used to be a hash of
1176
+ // GEMINI_API_KEY, so rotating the key orphaned the whole library, every job
1177
+ // record and every chain sidecar (chrischall/fleet-audit#116). It is now
1178
+ // pinned in the store on first use — derived from the current key so the
1179
+ // data already there stays reachable — and never re-derived after that.
1180
+ // Lazy because the key is read at request time (deferred-config-error).
1181
+ const tenant = pinnedTenant(blob.bucket, () => tenantIdFor(readEnvVar('GEMINI_API_KEY') ?? 'local'));
1167
1182
  return {
1168
1183
  mediaSink: createR2Sink(blob.bucket, {
1169
1184
  // The SAME `links` object the minter gets below. Media GETs and upload
package/dist/images.js CHANGED
@@ -31,8 +31,12 @@ export async function uniquePath(dir, base, ext) {
31
31
  }
32
32
  return candidate;
33
33
  }
34
- /** Sniff MIME type from the first bytes of an image buffer. */
34
+ /** Sniff MIME type from the first bytes of an image buffer (PNG fallback). */
35
35
  function sniffMimeBytes(buf) {
36
+ return sniffImageMime(buf) ?? 'image/png';
37
+ }
38
+ /** PNG/JPEG/WebP from the leading bytes, or `undefined` when it is none of them. */
39
+ function sniffImageMime(buf) {
36
40
  // PNG: 89 50 4E 47
37
41
  if (buf.length >= 4 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
38
42
  return 'image/png';
@@ -47,7 +51,7 @@ function sniffMimeBytes(buf) {
47
51
  buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) {
48
52
  return 'image/webp';
49
53
  }
50
- return 'image/png';
54
+ return undefined;
51
55
  }
52
56
  /** File extension for a media MIME (image/video/audio). Falls back to the MIME
53
57
  * subtype, so an unforeseen preview type still writes with a sane extension. */
@@ -163,7 +167,7 @@ export function videoMimeType(p) {
163
167
  export async function previewImageInput(path) {
164
168
  const resolved = resolveImagePath(path);
165
169
  // Read only the header bytes needed to sniff the MIME (≤12), and take the size
166
- // from stat() — a dry-run must not load a large reference image into memory
170
+ // from stat() — a preview must not load a large reference image into memory
167
171
  // just to report it (matches previewVideoInput).
168
172
  const { size } = await stat(resolved);
169
173
  const fh = await open(resolved, 'r');
@@ -185,6 +189,52 @@ export async function previewVideoInput(path) {
185
189
  const { size } = await stat(resolved);
186
190
  return { path: resolved, mimeType: videoMimeType(resolved), size };
187
191
  }
192
+ /**
193
+ * Image and audio types the Files API accepts, by extension. Video comes from
194
+ * {@link VIDEO_MIME_BY_EXT} so the two lists cannot disagree.
195
+ */
196
+ const UPLOAD_MIME_BY_EXT = {
197
+ png: 'image/png',
198
+ jpg: 'image/jpeg',
199
+ jpeg: 'image/jpeg',
200
+ webp: 'image/webp',
201
+ gif: 'image/gif',
202
+ heic: 'image/heic',
203
+ heif: 'image/heif',
204
+ wav: 'audio/wav',
205
+ mp3: 'audio/mp3',
206
+ aiff: 'audio/aiff',
207
+ aif: 'audio/aiff',
208
+ aac: 'audio/aac',
209
+ ogg: 'audio/ogg',
210
+ flac: 'audio/flac',
211
+ ...VIDEO_MIME_BY_EXT,
212
+ };
213
+ async function readHead(path, n = 16) {
214
+ const fh = await open(path, 'r');
215
+ try {
216
+ const head = Buffer.alloc(n);
217
+ const { bytesRead } = await fh.read(head, 0, n, 0);
218
+ return head.subarray(0, bytesRead);
219
+ }
220
+ finally {
221
+ await fh.close();
222
+ }
223
+ }
224
+ /**
225
+ * The MIME a local file should be uploaded to the Files API as, or `undefined`
226
+ * when it cannot be identified. Bytes win for the image formats we can sniff
227
+ * (a PNG saved as `.jpg` is still a PNG); otherwise the extension decides,
228
+ * over image, video AND audio types. Never guesses: `gemini_upload_file` used
229
+ * to label every unrecognised file image/png (chrischall/fleet-audit#117).
230
+ */
231
+ export async function detectUploadMime(resolvedPath) {
232
+ const sniffed = sniffImageMime(await readHead(resolvedPath));
233
+ if (sniffed)
234
+ return sniffed;
235
+ const ext = resolvedPath.includes('.') ? resolvedPath.slice(resolvedPath.lastIndexOf('.') + 1).toLowerCase() : '';
236
+ return UPLOAD_MIME_BY_EXT[ext];
237
+ }
188
238
  /** Read an image file into `{ base64, mimeType }` for an inline_data part. */
189
239
  export async function readImageAsInline(path) {
190
240
  const resolved = resolveImagePath(path);
package/dist/library.js CHANGED
@@ -29,6 +29,7 @@
29
29
  *
30
30
  * Worker-safe: no node imports, no module-scope state, structural bucket type.
31
31
  */
32
+ import { tenantResolver } from './tenant.js';
32
33
  /** Key prefix for the library — the one prefix the retention sweep must skip. */
33
34
  export const LIBRARY_KEY_PREFIX = 'lib';
34
35
  /**
@@ -42,8 +43,8 @@ export function isValidLibraryName(name) {
42
43
  /** Build the R2-backed library for one session. */
43
44
  export function createR2Library(bucket, opts) {
44
45
  const now = opts.now ?? (() => new Date());
45
- let tenantCache;
46
- const tenantId = () => (tenantCache ??= Promise.resolve(typeof opts.tenant === 'function' ? opts.tenant() : opts.tenant));
46
+ // Cached per library; a rejected resolution is retried, not cached.
47
+ const tenantId = tenantResolver(opts.tenant);
47
48
  const assertName = (name) => {
48
49
  if (!isValidLibraryName(name)) {
49
50
  throw new Error(`Invalid library name "${name}" — use 1-40 lowercase letters, digits, "-" or "_" (e.g. "finn", "bold-cartoon-sports").`);
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { base64ToBytes } from '../bytes.js';
16
16
  import { signedLinks } from '../signed-url.js';
17
+ import { tenantResolver } from '../tenant.js';
17
18
  /**
18
19
  * The stdio sink: `resolveOutputDir(output_dir)` → `writeMedia` per item, in
19
20
  * order (so `uniquePath`'s `name`/`name-2`/`name-3` sequencing is preserved).
@@ -141,8 +142,8 @@ export function createR2Sink(bucket, opts) {
141
142
  const signedBase = opts.links?.base ?? opts.signedBaseUrl?.replace(/\/+$/, '');
142
143
  const ttlMs = opts.urlTtlMs ?? 0;
143
144
  const readPrefixes = [prefix, ...(opts.readPrefixes ?? [])];
144
- let tenantCache;
145
- const tenantId = () => (tenantCache ??= Promise.resolve(typeof opts.tenant === 'function' ? opts.tenant() : opts.tenant));
145
+ // Cached per sink; a rejected resolution is retried, not cached.
146
+ const tenantId = tenantResolver(opts.tenant);
146
147
  /** `<prefix>/` or `<prefix>/<tenant>/` — where every WRITE goes. */
147
148
  const ownPrefix = async () => {
148
149
  const tenant = await tenantId();
package/dist/tenant.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The hosted namespace (`lib/<tenant>/`, `jobs/<tenant>/`, `gen/<tenant>/`,
3
+ * `up/<tenant>/`), pinned once per registration.
4
+ *
5
+ * It used to be `tenantIdFor(GEMINI_API_KEY)`, recomputed every process. That
6
+ * made the API key — the one credential the healthcheck tells the user to
7
+ * rotate on `credential_rejected` — the address of everything persistent: a
8
+ * rotation silently moved the whole namespace, and the character/style library
9
+ * (advertised as having NO expiry), durable job records and chain sidecars all
10
+ * came back empty (chrischall/fleet-audit#116).
11
+ *
12
+ * The real tenancy boundary is the blob store's per-registration key, so the
13
+ * tenant only needs to be stable, not secret-derived. It is now read from a
14
+ * small pin object in the registration's own store; the first process to find
15
+ * none derives it the old way and writes it. Deriving from the CURRENT key on
16
+ * first use is deliberate: that is exactly where every existing deployment's
17
+ * data already lives, so the change needs no migration.
18
+ */
19
+ /** Under `lib/` so the host's retention prune never reclaims it (blob-store.ts). */
20
+ export const TENANT_PIN_KEY = 'lib/tenant.json';
21
+ /** Same shape `tenantIdFor` produces; anything else in the pin is ignored. */
22
+ const TENANT_SHAPE = /^[0-9a-f]{12}$/;
23
+ async function readPin(bucket) {
24
+ const object = await bucket.get(TENANT_PIN_KEY);
25
+ if (!object)
26
+ return undefined;
27
+ try {
28
+ const parsed = JSON.parse(new TextDecoder().decode(await object.arrayBuffer()));
29
+ return typeof parsed.tenant === 'string' && TENANT_SHAPE.test(parsed.tenant) ? parsed.tenant : undefined;
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ }
35
+ /**
36
+ * A memoised tenant resolver for the hosted stores.
37
+ *
38
+ * Only a fully successful resolution is cached: a failed read rejects and is
39
+ * retried on the next call, and a failed pin WRITE still returns the derived
40
+ * tenant (so a transient blob hiccup does not fail the tool call) but leaves
41
+ * the pin to be written next time.
42
+ */
43
+ export function pinnedTenant(bucket, derive) {
44
+ let cached;
45
+ async function resolve() {
46
+ const pinned = await readPin(bucket);
47
+ if (pinned)
48
+ return { tenant: pinned, pinned: true };
49
+ const tenant = await derive();
50
+ try {
51
+ await bucket.put(TENANT_PIN_KEY, JSON.stringify({ tenant }), {
52
+ httpMetadata: { contentType: 'application/json' },
53
+ });
54
+ return { tenant, pinned: true };
55
+ }
56
+ catch {
57
+ return { tenant, pinned: false };
58
+ }
59
+ }
60
+ return () => {
61
+ if (cached)
62
+ return cached;
63
+ const attempt = resolve().then(({ tenant, pinned }) => {
64
+ if (!pinned && cached === attempt)
65
+ cached = undefined;
66
+ return tenant;
67
+ });
68
+ cached = attempt;
69
+ attempt.catch(() => {
70
+ if (cached === attempt)
71
+ cached = undefined;
72
+ });
73
+ return attempt;
74
+ };
75
+ }
76
+ /**
77
+ * Memoise a tenant source (a static value, or a possibly-async resolver) for
78
+ * one sink/library. Concurrent callers share one in-flight resolution and a
79
+ * success is cached for the process, but a REJECTED resolution is dropped, not
80
+ * cached: the hosted tenant is read from the store (`pinnedTenant`), and one
81
+ * transient failure must not wedge its consumer for the rest of the process.
82
+ */
83
+ export function tenantResolver(source) {
84
+ let cache;
85
+ return () => {
86
+ if (cache)
87
+ return cache;
88
+ const attempt = Promise.resolve(typeof source === 'function' ? source() : source);
89
+ attempt.catch(() => { if (cache === attempt)
90
+ cache = undefined; });
91
+ return (cache = attempt);
92
+ };
93
+ }
@@ -1,39 +1,63 @@
1
- import { minifiedResult, schemaConfirm } from '@chrischall/mcp-utils';
1
+ import { confirmationFromEnv, confirmTokenParam, requireConfirmationWithFallback } from '@chrischall/mcp-utils';
2
2
  import { previewImageInput, previewVideoInput } from '../images.js';
3
- export { schemaConfirm };
3
+ export { confirmTokenParam };
4
4
  /**
5
- * Confirm-gate for a mutating tool (the fleet convention). When `confirm` is not
6
- * `true`, returns a no-network dry-run preview of exactly what would be sent;
7
- * when it is `true`, returns `null` so the caller proceeds with the write.
5
+ * The sentence every gated tool's description ends with. `lead` names what is
6
+ * confirmed when it is not the whole call (local file inputs only).
8
7
  */
9
- export function previewUnlessConfirmed(confirm, action, method, path, body) {
10
- if (confirm === true)
11
- return null;
12
- return minifiedResult({
13
- dryRun: true,
14
- action,
15
- method,
16
- path,
17
- ...(body !== undefined ? { willSend: body } : {}),
18
- note: 'Re-run with confirm: true to execute.',
19
- });
8
+ export function confirmNote(lead = 'Asks the user to confirm first') {
9
+ return `${lead}: a confirmation prompt where the client supports one; otherwise the first call returns a preview ` +
10
+ 'and a confirmToken, and only a repeat call with that token proceeds (see MCP_CONFIRM_MODE).';
11
+ }
12
+ /**
13
+ * Confirm a mutating call before it runs: an elicitation prompt where the
14
+ * client supports one, otherwise the preview-plus-token fallback governed by
15
+ * MCP_CONFIRM_MODE. The preview is the same method/path/willSend shape the old
16
+ * dry-run returned.
17
+ */
18
+ export async function confirmWrite(ctx, o) {
19
+ const preview = {
20
+ action: o.description,
21
+ method: o.method,
22
+ path: o.path,
23
+ ...(o.body !== undefined ? { willSend: o.body } : {}),
24
+ };
25
+ return requireConfirmationWithFallback(ctx, confirmationFromEnv({
26
+ action: o.action,
27
+ message: o.message,
28
+ details: preview,
29
+ tool: o.tool,
30
+ confirmToken: o.confirmToken,
31
+ subject: () => ({ target: o.target, payload: o.payload, preview }),
32
+ }));
20
33
  }
21
34
  /**
22
35
  * Confirm-gate for a Gemini tool that ships LOCAL input files (image/video
23
- * paths) to Google. Returns `null` (proceed) when `confirm === true` OR the call
24
- * has no local file inputs — so pure text-to-image / base64 / clipboard calls
25
- * are unaffected. Otherwise returns a NO-API dry-run echoing each RESOLVED
26
- * ABSOLUTE input path plus its mime and size, so a prompt-injected path (e.g.
36
+ * paths) to Google. Proceeds straight away when the call has no local file
37
+ * inputs — so pure text-to-image / base64 / clipboard calls are unaffected.
38
+ * Otherwise the preview echoes each RESOLVED ABSOLUTE input path plus its mime
39
+ * and size (a local read, no network), so a prompt-injected path (e.g.
27
40
  * `~/.ssh/id_rsa`) is visible and interceptable before any byte leaves the box.
28
41
  */
29
- export async function previewLocalInputsUnlessConfirmed(confirm, action, endpoint, imagePaths, videoPath) {
30
- const hasLocal = (imagePaths?.length ?? 0) > 0 || Boolean(videoPath);
31
- if (confirm === true || !hasLocal)
32
- return null;
42
+ export async function confirmLocalInputs(ctx, o) {
43
+ const hasLocal = (o.imagePaths?.length ?? 0) > 0 || Boolean(o.videoPath);
44
+ if (!hasLocal)
45
+ return undefined;
33
46
  const inputs = [];
34
- for (const p of imagePaths ?? [])
47
+ for (const p of o.imagePaths ?? [])
35
48
  inputs.push(await previewImageInput(p));
36
- if (videoPath)
37
- inputs.push(await previewVideoInput(videoPath));
38
- return previewUnlessConfirmed(confirm, action, 'POST', endpoint, { inputs });
49
+ if (o.videoPath)
50
+ inputs.push(await previewVideoInput(o.videoPath));
51
+ return confirmWrite(ctx, {
52
+ tool: o.tool,
53
+ action: o.action,
54
+ message: 'Review and confirm sending these local files to Google:',
55
+ description: o.description,
56
+ method: 'POST',
57
+ path: o.endpoint,
58
+ body: { inputs },
59
+ target: '',
60
+ payload: { request: o.request, inputs },
61
+ confirmToken: o.confirmToken,
62
+ });
39
63
  }
@@ -1,10 +1,11 @@
1
1
  import { z } from 'zod';
2
+ import { stat } from 'node:fs/promises';
2
3
  import { McpToolError, minifiedResult } from '@chrischall/mcp-utils';
3
4
  import { withProgressHeartbeat } from './shared.js';
4
5
  import { base64ToBytes, bytesToBase64, formatMb, wholeMb } from '../bytes.js';
5
6
  import { fileNameFromUrl } from '../fetch-image.js';
6
7
  import { downloadFilename } from '../media-name.js';
7
- import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
8
+ import { confirmNote, confirmTokenParam, confirmWrite } from './_confirm.js';
8
9
  /**
9
10
  * First-class Gemini Files API access.
10
11
  *
@@ -85,7 +86,7 @@ export function registerFileTools(server, client) {
85
86
  ? ', or `path` (a local file).'
86
87
  : ', or `r2_key` (re-upload media this connector generated, from media[].r2_key in an earlier result).') +
87
88
  (onDisk
88
- ? ''
89
+ ? ' ' + confirmNote('A local `path` is confirmed first')
89
90
  : ' To upload a local file without base64: mint a signed PUT URL with gemini_get_upload_url, PUT the bytes to it, ' +
90
91
  'then pass the returned r2_key here.'),
91
92
  annotations: { readOnlyHint: false, openWorldHint: true },
@@ -125,7 +126,7 @@ export function registerFileTools(server, client) {
125
126
  .optional()
126
127
  .describe('Override the detected MIME type (sniffed from the bytes / taken from the server response otherwise)'),
127
128
  display_name: z.string().min(1).optional().describe('Human-readable name recorded against the upload'),
128
- confirm: schemaConfirm,
129
+ confirmToken: confirmTokenParam,
129
130
  }),
130
131
  }, async (args, extra) => {
131
132
  const sources = [args.url && 'url', args.data_base64 && 'data_base64', args.r2_key && 'r2_key', args.path && 'path'].filter(Boolean);
@@ -134,18 +135,44 @@ export function registerFileTools(server, client) {
134
135
  ? 'Provide exactly one of `url`, `data_base64`' + (onDisk ? ' or `path`.' : ' or `r2_key`.')
135
136
  : `Provide exactly one source, not ${sources.length} (${sources.join(', ')}).`);
136
137
  }
138
+ let localUpload;
137
139
  if (args.path) {
138
140
  if (!onDisk) {
139
141
  throw new McpToolError('`path` is unavailable on the hosted connector: it runs on a remote machine with no access to your filesystem. ' +
140
142
  'Use `url` (the server downloads it), `r2_key` after PUTting the file to a URL from gemini_get_upload_url, or ' +
141
143
  '`data_base64` if you cannot make HTTP requests at all.', { hint: 'Use `url`, `r2_key` (gemini_get_upload_url), or `data_base64`.' });
142
144
  }
145
+ // Resolve and identify BEFORE the gate, so the preview shows the MIME
146
+ // that will actually be sent. An unidentifiable file is refused rather
147
+ // than labelled image/png — the old image-only sniffer's default, which
148
+ // shipped every video, audio clip, GIF and HEIC mislabelled (#117).
149
+ const { resolveImagePath, detectUploadMime } = await import('../images.js');
150
+ const resolved = resolveImagePath(args.path);
151
+ const mimeType = args.mime_type ?? (await detectUploadMime(resolved));
152
+ if (!mimeType) {
153
+ throw new McpToolError(`Cannot tell what kind of file ${resolved} is from its bytes or extension — pass \`mime_type\` ` +
154
+ '(e.g. video/mp4, audio/mp3, image/gif) so it is not uploaded under the wrong type.', { hint: 'Pass mime_type explicitly.' });
155
+ }
143
156
  // Same gate as every other local-file input: a prompt-injected `path`
144
157
  // would otherwise ship an arbitrary local file to Google, and an
145
158
  // uploaded file is readable by anyone holding the api key for ~48h.
146
- const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Upload a local file to the Gemini Files API', '/upload/v1beta/files', [args.path]);
159
+ const { size } = await stat(resolved);
160
+ const inputs = [{ path: resolved, mimeType, size }];
161
+ const gate = await confirmWrite(extra, {
162
+ tool: 'gemini_upload_file',
163
+ action: 'file.upload',
164
+ message: 'Review and confirm uploading this local file to Google:',
165
+ description: 'Upload a local file to the Gemini Files API',
166
+ method: 'POST',
167
+ path: '/upload/v1beta/files',
168
+ body: { inputs },
169
+ target: resolved,
170
+ payload: { inputs, display_name: args.display_name },
171
+ confirmToken: args.confirmToken,
172
+ });
147
173
  if (gate)
148
174
  return gate;
175
+ localUpload = { path: resolved, mimeType };
149
176
  }
150
177
  const uploaded = await withProgressHeartbeat(extra, 'Uploading to the Gemini Files API', async () => {
151
178
  if (args.url) {
@@ -172,11 +199,9 @@ export function registerFileTools(server, client) {
172
199
  const bytes = base64ToBytes(decoded.base64);
173
200
  return client.uploadBytes(bytes, args.mime_type ?? decoded.mimeType, args.display_name ?? 'upload');
174
201
  }
175
- const { readImageAsInline, resolveImagePath } = await import('../images.js');
176
- const resolved = resolveImagePath(args.path);
177
- const inline = await readImageAsInline(resolved);
178
- const bytes = base64ToBytes(inline.base64);
179
- return client.uploadBytes(bytes, args.mime_type ?? inline.mimeType, args.display_name ?? (resolved.split(/[\\/]/).pop() || 'upload'));
202
+ // Streamed from disk (fileBlob), never buffered and re-encoded.
203
+ const local = localUpload;
204
+ return client.uploadFile(local.path, local.mimeType, args.display_name ?? (local.path.split(/[\\/]/).pop() || 'upload'));
180
205
  });
181
206
  return uploadResult(uploaded);
182
207
  });
@@ -336,16 +361,27 @@ export function registerFileTools(server, client) {
336
361
  }
337
362
  server.registerTool('gemini_delete_file', {
338
363
  description: 'Delete an uploaded file, image or photo (by file_uri) from the Gemini Files API before its ~48h expiry. Any tool call still referencing it ' +
339
- 'will then fail with a generic 404, so delete only references you are finished with.',
364
+ 'will then fail with a generic 404, so delete only references you are finished with. ' +
365
+ confirmNote(),
340
366
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
341
367
  inputSchema: z.object({
342
368
  file_uri: z.string().min(1).describe('The `files/<id>` reference (or full uri) to delete'),
343
- confirm: schemaConfirm,
369
+ confirmToken: confirmTokenParam,
344
370
  }),
345
- }, async (args) => {
371
+ }, async (args, extra) => {
346
372
  // Deleting is a remote mutation with no undo — confirm-gated per the
347
373
  // fleet convention (unlike writing a generated image, which is local).
348
- const gate = previewUnlessConfirmed(args.confirm, 'Delete an uploaded Gemini file', 'DELETE', `/v1beta/${args.file_uri}`);
374
+ const gate = await confirmWrite(extra, {
375
+ tool: 'gemini_delete_file',
376
+ action: 'file.delete',
377
+ message: 'Review and confirm this deletion:',
378
+ description: 'Delete an uploaded Gemini file',
379
+ method: 'DELETE',
380
+ path: `/v1beta/${args.file_uri}`,
381
+ target: args.file_uri,
382
+ payload: { file_uri: args.file_uri },
383
+ confirmToken: args.confirmToken,
384
+ });
349
385
  if (gate)
350
386
  return gate;
351
387
  await client.deleteFile(args.file_uri);
@@ -7,7 +7,7 @@ import { emit, resolveAspectRatio, ASPECT_RATIOS, sharedImageSchema, pickSeed, b
7
7
  import { fingerprintRequest } from '../jobs.js';
8
8
  import { sumUsage } from '../usage.js';
9
9
  import { attachCost } from '../pricing.js';
10
- import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
10
+ import { confirmLocalInputs, confirmNote, confirmTokenParam } from './_confirm.js';
11
11
  const GENERATE_ENDPOINT = '/v1beta/models/{model}:generateContent';
12
12
  const SEND_LOCAL_ACTION = 'Send local file input(s) to the Gemini API';
13
13
  // generateContent output can't seed an Interactions-API conversation, so
@@ -17,7 +17,8 @@ const REFINE_HINT = 'To iteratively refine this image, use gemini_interact (pass
17
17
  export function registerGenerateTools(server, client) {
18
18
  server.registerTool('gemini_image_generate', {
19
19
  description: 'Generate image(s) from a text prompt with a Gemini image model (Nano Banana / Nano Banana Pro). ' +
20
- 'If the result will likely be refined iteratively, prefer gemini_interact (multi-turn) as the entry point.',
20
+ 'If the result will likely be refined iteratively, prefer gemini_interact (multi-turn) as the entry point. ' +
21
+ confirmNote('Local file inputs are confirmed first'),
21
22
  annotations: { readOnlyHint: false, openWorldHint: true },
22
23
  inputSchema: z.object({
23
24
  prompt: z.string().min(1).describe('Text prompt describing the image'),
@@ -32,7 +33,7 @@ export function registerGenerateTools(server, client) {
32
33
  style: styleSchema,
33
34
  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)'),
34
35
  video_path: videoPathSchema,
35
- confirm: schemaConfirm,
36
+ confirmToken: confirmTokenParam,
36
37
  ...sharedImageSchema,
37
38
  }),
38
39
  }, async (args, extra) => {
@@ -42,8 +43,12 @@ export function registerGenerateTools(server, client) {
42
43
  assertLocalInputsAvailable(client.mediaSink, args);
43
44
  // Confirm-gate local file inputs: a prompt-injected `images`/`video_path`
44
45
  // could exfiltrate a local file. Pure text-to-image calls (no local input)
45
- // are unaffected. Dry-run makes NO API call.
46
- const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images, args.video_path);
46
+ // are unaffected. The preview makes NO API call.
47
+ const { confirmToken, ...request } = args;
48
+ const gate = await confirmLocalInputs(extra, {
49
+ tool: 'gemini_image_generate', action: 'image.generate', description: SEND_LOCAL_ACTION, endpoint: GENERATE_ENDPOINT,
50
+ imagePaths: args.images, videoPath: args.video_path, request, confirmToken,
51
+ });
47
52
  if (gate)
48
53
  return gate;
49
54
  const count = args.count ?? 1;
@@ -159,7 +164,8 @@ export function registerGenerateTools(server, client) {
159
164
  description: 'Edit or compose images: provide one or more input images (paths or base64), plus a text instruction. ' +
160
165
  'For a SERIES of successive edits to the same image, prefer gemini_interact (multi-turn) — it keeps edit context ' +
161
166
  'and avoids re-processing the full image each round; use gemini_image_edit for one-off edits or composing multiple distinct inputs. ' +
162
- 'Gemini over-preserves the input; there is no edit-strength control — for large structural changes, reroll with a different `seed` or more forceful wording.',
167
+ 'Gemini over-preserves the input; there is no edit-strength control — for large structural changes, reroll with a different `seed` or more forceful wording. ' +
168
+ confirmNote('Local file inputs are confirmed first'),
163
169
  annotations: { readOnlyHint: false, openWorldHint: true },
164
170
  inputSchema: z.object({
165
171
  prompt: z.string().min(1).describe('Instruction describing the edit or composition'),
@@ -171,7 +177,7 @@ export function registerGenerateTools(server, client) {
171
177
  characters: charactersSchema,
172
178
  style: styleSchema,
173
179
  filename: z.string().optional().describe('Base filename for the output image (extension stripped; default: slugified prompt)'),
174
- confirm: schemaConfirm,
180
+ confirmToken: confirmTokenParam,
175
181
  ...sharedImageSchema,
176
182
  }),
177
183
  }, async (args, extra) => {
@@ -182,7 +188,11 @@ export function registerGenerateTools(server, client) {
182
188
  requireImageInput(args);
183
189
  // Confirm-gate local file inputs (see gemini_image_generate). base64 /
184
190
  // clipboard inputs are not local-path reads and pass through ungated.
185
- const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
191
+ const { confirmToken, ...request } = args;
192
+ const gate = await confirmLocalInputs(extra, {
193
+ tool: 'gemini_image_edit', action: 'image.edit', description: SEND_LOCAL_ACTION, endpoint: GENERATE_ENDPOINT,
194
+ imagePaths: args.images, request, confirmToken,
195
+ });
186
196
  if (gate)
187
197
  return gate;
188
198
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
@@ -9,7 +9,7 @@ import { emit, reportShape, resolveAspectRatio, orientationSchema, ASPECT_RATIOS
9
9
  import { bytesToBase64 } from '../bytes.js';
10
10
  import { fingerprintRequest } from '../jobs.js';
11
11
  import { attachCost } from '../pricing.js';
12
- import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
12
+ import { confirmLocalInputs, confirmNote, confirmTokenParam } from './_confirm.js';
13
13
  // The last-interaction id and the written-outputs set used to live here, at
14
14
  // module scope. On the hosted connector that leaks across tenants — one
15
15
  // Cloudflare isolate serves many authenticated sessions, so `continue_last`
@@ -89,7 +89,8 @@ export function registerInteractTools(server, client) {
89
89
  'do NOT start a new interaction or re-send the image for each tweak. `continue_last: true` chains from this ' +
90
90
  "session's most recent interaction without threading the id. " +
91
91
  recoveryDescription +
92
- 'Output is JPEG.',
92
+ 'Output is JPEG. ' +
93
+ confirmNote('Local file inputs are confirmed first'),
93
94
  annotations: { readOnlyHint: false, openWorldHint: true },
94
95
  inputSchema: z.object({
95
96
  input: z.string().min(1).describe('Text prompt or editing instruction'),
@@ -160,7 +161,7 @@ export function registerInteractTools(server, client) {
160
161
  .boolean()
161
162
  .optional()
162
163
  .describe('Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)'),
163
- confirm: schemaConfirm,
164
+ confirmToken: confirmTokenParam,
164
165
  }),
165
166
  }, async (args, extra) => {
166
167
  assertLocalInputsAvailable(client.mediaSink, args);
@@ -205,8 +206,13 @@ export function registerInteractTools(server, client) {
205
206
  // Confirm-gate local file inputs AFTER the re-attach split, so the preview
206
207
  // reflects the paths actually sent (dropped prior-output paths aren't). A
207
208
  // prompt-injected `images`/`video_path` could exfiltrate a local file;
208
- // base64/clipboard inputs pass through ungated. Dry-run makes NO API call.
209
- const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Send local file input(s) to the Gemini Interactions API', '/v1beta/interactions', images, args.video_path);
209
+ // base64/clipboard inputs pass through ungated. The preview makes NO API call.
210
+ const { confirmToken, ...request } = args;
211
+ const gate = await confirmLocalInputs(extra, {
212
+ tool: 'gemini_interact', action: 'interaction.create', description: 'Send local file input(s) to the Gemini Interactions API', endpoint: '/v1beta/interactions',
213
+ imagePaths: images, videoPath: args.video_path,
214
+ request: { ...request, images, previous_interaction_id: previousInteractionId }, confirmToken,
215
+ });
210
216
  if (gate)
211
217
  return gate;
212
218
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));