@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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +18 -1
- package/dist/bundle.js +810 -135
- package/dist/client.js +22 -7
- package/dist/images.js +53 -3
- package/dist/library.js +3 -2
- package/dist/storage/media.js +3 -2
- package/dist/tenant.js +93 -0
- package/dist/tools/_confirm.js +52 -28
- package/dist/tools/files.js +49 -13
- package/dist/tools/generate.js +18 -8
- package/dist/tools/interact.js +11 -5
- package/dist/tools/library.js +29 -9
- package/dist/tools/music.js +9 -4
- package/dist/tools/set.js +10 -5
- package/dist/tools/video.js +9 -4
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/skills/gemini-mcp/SKILL.md +3 -2
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
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
46
|
-
const tenantId = (
|
|
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").`);
|
package/dist/storage/media.js
CHANGED
|
@@ -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
|
-
|
|
145
|
-
const tenantId = (
|
|
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
|
+
}
|
package/dist/tools/_confirm.js
CHANGED
|
@@ -1,39 +1,63 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { confirmationFromEnv, confirmTokenParam, requireConfirmationWithFallback } from '@chrischall/mcp-utils';
|
|
2
2
|
import { previewImageInput, previewVideoInput } from '../images.js';
|
|
3
|
-
export {
|
|
3
|
+
export { confirmTokenParam };
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
30
|
-
const hasLocal = (imagePaths?.length ?? 0) > 0 || Boolean(videoPath);
|
|
31
|
-
if (
|
|
32
|
-
return
|
|
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
|
|
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
|
}
|
package/dist/tools/files.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
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
|
-
|
|
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 =
|
|
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);
|
package/dist/tools/generate.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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.
|
|
46
|
-
const
|
|
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
|
-
|
|
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
|
|
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'));
|
package/dist/tools/interact.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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.
|
|
209
|
-
const
|
|
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'));
|