@chrischall/gemini-mcp 2.1.4 → 2.3.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 +28 -1
- package/dist/bundle.js +723 -122
- package/dist/client.js +32 -3
- package/dist/images.js +20 -4
- package/dist/tools/_confirm.js +52 -28
- package/dist/tools/files.js +34 -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 +3 -3
- package/server.json +9 -2
- package/skills/gemini-mcp/SKILL.md +3 -2
package/dist/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { basename } from 'node:path';
|
|
1
|
+
import { basename, delimiter } from 'node:path';
|
|
2
2
|
import { readEnvVar, McpToolError, ApiError, createApiClient, formatApiError, fileBlob } from '@chrischall/mcp-utils';
|
|
3
3
|
import { resolveModel, filterImageModels, DEFAULT_VIDEO_MODEL, DEFAULT_MUSIC_MODEL } from './models.js';
|
|
4
4
|
import { readUsage } from './usage.js';
|
|
@@ -181,6 +181,29 @@ export function normalizeFileName(ref) {
|
|
|
181
181
|
}
|
|
182
182
|
return match[1];
|
|
183
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* `GEMINI_UPLOAD_DIR` — the optional allow-list for local files streamed to
|
|
186
|
+
* the Files API (`gemini_upload_file` `path`, `video_path`). One or more
|
|
187
|
+
* directories separated by the platform path delimiter (`:` / `;`); `~` is
|
|
188
|
+
* expanded by mcp-utils. Unset → `undefined` → uploads stay unconfined.
|
|
189
|
+
* The paths are model-chosen, so a prompt-injected path could otherwise send
|
|
190
|
+
* any readable file to Google.
|
|
191
|
+
*/
|
|
192
|
+
function uploadRoots() {
|
|
193
|
+
const roots = readEnvVar('GEMINI_UPLOAD_DIR')?.split(delimiter).map((r) => r.trim()).filter(Boolean);
|
|
194
|
+
return roots && roots.length > 0 ? roots : undefined;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Turn mcp-utils' bare "outside the allowed directories" refusal into an
|
|
198
|
+
* actionable tool error. The remediation is in the MESSAGE — hosts show the
|
|
199
|
+
* message and drop the hint (see CLAUDE.md, Errors).
|
|
200
|
+
*/
|
|
201
|
+
function explainOutsideUploadDir(err) {
|
|
202
|
+
if (err instanceof Error && err.message.startsWith('Path is outside the allowed directories')) {
|
|
203
|
+
throw new McpToolError('Refusing to upload a file outside GEMINI_UPLOAD_DIR, which restricts which local files can be uploaded to Google. Move the file into GEMINI_UPLOAD_DIR, or add its directory to GEMINI_UPLOAD_DIR.');
|
|
204
|
+
}
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
184
207
|
export class GeminiClient {
|
|
185
208
|
/**
|
|
186
209
|
* A key handed in explicitly (the connector's per-session key). When absent
|
|
@@ -365,7 +388,10 @@ export class GeminiClient {
|
|
|
365
388
|
* (enforced locally before any bytes are sent).
|
|
366
389
|
*/
|
|
367
390
|
async uploadVideo(path, mimeType) {
|
|
368
|
-
const
|
|
391
|
+
const roots = uploadRoots();
|
|
392
|
+
const blob = await fileBlob(path, {
|
|
393
|
+
type: mimeType, maxBytes: FILE_MAX_BYTES, label: 'Video', ...(roots ? { allowedRoots: roots } : {}),
|
|
394
|
+
}).catch(explainOutsideUploadDir);
|
|
369
395
|
return this.uploadToFilesApi(blob, mimeType, basename(path), blob.size);
|
|
370
396
|
}
|
|
371
397
|
/**
|
|
@@ -376,7 +402,10 @@ export class GeminiClient {
|
|
|
376
402
|
* length on a few-hundred-MB video (chrischall/fleet-audit#117).
|
|
377
403
|
*/
|
|
378
404
|
async uploadFile(path, mimeType, displayName = basename(path)) {
|
|
379
|
-
const
|
|
405
|
+
const roots = uploadRoots();
|
|
406
|
+
const blob = await fileBlob(path, {
|
|
407
|
+
type: mimeType, maxBytes: FILE_MAX_BYTES, label: 'File', ...(roots ? { allowedRoots: roots } : {}),
|
|
408
|
+
}).catch(explainOutsideUploadDir);
|
|
380
409
|
return this.uploadToFilesApi(blob, mimeType, displayName, blob.size);
|
|
381
410
|
}
|
|
382
411
|
/**
|
package/dist/images.js
CHANGED
|
@@ -31,10 +31,26 @@ 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 (PNG fallback).
|
|
34
|
+
/** Sniff MIME type from the first bytes of an image buffer (PNG fallback).
|
|
35
|
+
* Only for bytes that arrive as base64 with no other type information; a LOCAL
|
|
36
|
+
* file goes through {@link localImageMime}, which never guesses. */
|
|
35
37
|
function sniffMimeBytes(buf) {
|
|
36
38
|
return sniffImageMime(buf) ?? 'image/png';
|
|
37
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The MIME of a LOCAL image input (PNG/JPEG/WebP, sniffed from its bytes), or a
|
|
42
|
+
* refusal. Never defaults: an unidentifiable file used to be previewed and sent
|
|
43
|
+
* as image/png, so a prompt-injected `images: ['~/.ssh/id_rsa']` showed up in
|
|
44
|
+
* the confirmation preview mislabelled as a PNG (chrischall/fleet-audit#929).
|
|
45
|
+
*/
|
|
46
|
+
function localImageMime(resolvedPath, head) {
|
|
47
|
+
const mime = sniffImageMime(head);
|
|
48
|
+
if (!mime) {
|
|
49
|
+
throw new McpToolError(`Cannot tell what kind of image ${resolvedPath} is — its bytes are not PNG, JPEG or WebP, ` +
|
|
50
|
+
'so it is not sent to Gemini as a reference image.', { hint: 'Pass a PNG, JPEG or WebP file (convert other formats first).' });
|
|
51
|
+
}
|
|
52
|
+
return mime;
|
|
53
|
+
}
|
|
38
54
|
/** PNG/JPEG/WebP from the leading bytes, or `undefined` when it is none of them. */
|
|
39
55
|
function sniffImageMime(buf) {
|
|
40
56
|
// PNG: 89 50 4E 47
|
|
@@ -167,14 +183,14 @@ export function videoMimeType(p) {
|
|
|
167
183
|
export async function previewImageInput(path) {
|
|
168
184
|
const resolved = resolveImagePath(path);
|
|
169
185
|
// Read only the header bytes needed to sniff the MIME (≤12), and take the size
|
|
170
|
-
// from stat() — a
|
|
186
|
+
// from stat() — a preview must not load a large reference image into memory
|
|
171
187
|
// just to report it (matches previewVideoInput).
|
|
172
188
|
const { size } = await stat(resolved);
|
|
173
189
|
const fh = await open(resolved, 'r');
|
|
174
190
|
try {
|
|
175
191
|
const head = Buffer.alloc(16);
|
|
176
192
|
const { bytesRead } = await fh.read(head, 0, 16, 0);
|
|
177
|
-
return { path: resolved, mimeType:
|
|
193
|
+
return { path: resolved, mimeType: localImageMime(resolved, head.subarray(0, bytesRead)), size };
|
|
178
194
|
}
|
|
179
195
|
finally {
|
|
180
196
|
await fh.close();
|
|
@@ -239,7 +255,7 @@ export async function detectUploadMime(resolvedPath) {
|
|
|
239
255
|
export async function readImageAsInline(path) {
|
|
240
256
|
const resolved = resolveImagePath(path);
|
|
241
257
|
const buf = await readFile(resolved);
|
|
242
|
-
const mimeType =
|
|
258
|
+
const mimeType = localImageMime(resolved, buf);
|
|
243
259
|
return { base64: buf.toString('base64'), mimeType };
|
|
244
260
|
}
|
|
245
261
|
/**
|
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
|
@@ -5,7 +5,7 @@ import { withProgressHeartbeat } from './shared.js';
|
|
|
5
5
|
import { base64ToBytes, bytesToBase64, formatMb, wholeMb } from '../bytes.js';
|
|
6
6
|
import { fileNameFromUrl } from '../fetch-image.js';
|
|
7
7
|
import { downloadFilename } from '../media-name.js';
|
|
8
|
-
import {
|
|
8
|
+
import { confirmNote, confirmTokenParam, confirmWrite } from './_confirm.js';
|
|
9
9
|
/**
|
|
10
10
|
* First-class Gemini Files API access.
|
|
11
11
|
*
|
|
@@ -86,7 +86,7 @@ export function registerFileTools(server, client) {
|
|
|
86
86
|
? ', or `path` (a local file).'
|
|
87
87
|
: ', or `r2_key` (re-upload media this connector generated, from media[].r2_key in an earlier result).') +
|
|
88
88
|
(onDisk
|
|
89
|
-
? ''
|
|
89
|
+
? ' ' + confirmNote('A local `path` is confirmed first')
|
|
90
90
|
: ' To upload a local file without base64: mint a signed PUT URL with gemini_get_upload_url, PUT the bytes to it, ' +
|
|
91
91
|
'then pass the returned r2_key here.'),
|
|
92
92
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
@@ -126,7 +126,7 @@ export function registerFileTools(server, client) {
|
|
|
126
126
|
.optional()
|
|
127
127
|
.describe('Override the detected MIME type (sniffed from the bytes / taken from the server response otherwise)'),
|
|
128
128
|
display_name: z.string().min(1).optional().describe('Human-readable name recorded against the upload'),
|
|
129
|
-
|
|
129
|
+
confirmToken: confirmTokenParam,
|
|
130
130
|
}),
|
|
131
131
|
}, async (args, extra) => {
|
|
132
132
|
const sources = [args.url && 'url', args.data_base64 && 'data_base64', args.r2_key && 'r2_key', args.path && 'path'].filter(Boolean);
|
|
@@ -156,12 +156,22 @@ export function registerFileTools(server, client) {
|
|
|
156
156
|
// Same gate as every other local-file input: a prompt-injected `path`
|
|
157
157
|
// would otherwise ship an arbitrary local file to Google, and an
|
|
158
158
|
// uploaded file is readable by anyone holding the api key for ~48h.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
+
});
|
|
173
|
+
if (gate)
|
|
174
|
+
return gate;
|
|
165
175
|
localUpload = { path: resolved, mimeType };
|
|
166
176
|
}
|
|
167
177
|
const uploaded = await withProgressHeartbeat(extra, 'Uploading to the Gemini Files API', async () => {
|
|
@@ -351,16 +361,27 @@ export function registerFileTools(server, client) {
|
|
|
351
361
|
}
|
|
352
362
|
server.registerTool('gemini_delete_file', {
|
|
353
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 ' +
|
|
354
|
-
'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(),
|
|
355
366
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
356
367
|
inputSchema: z.object({
|
|
357
368
|
file_uri: z.string().min(1).describe('The `files/<id>` reference (or full uri) to delete'),
|
|
358
|
-
|
|
369
|
+
confirmToken: confirmTokenParam,
|
|
359
370
|
}),
|
|
360
|
-
}, async (args) => {
|
|
371
|
+
}, async (args, extra) => {
|
|
361
372
|
// Deleting is a remote mutation with no undo — confirm-gated per the
|
|
362
373
|
// fleet convention (unlike writing a generated image, which is local).
|
|
363
|
-
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
|
+
});
|
|
364
385
|
if (gate)
|
|
365
386
|
return gate;
|
|
366
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'));
|
package/dist/tools/library.js
CHANGED
|
@@ -3,7 +3,7 @@ import { McpToolError, minifiedResult } from '@chrischall/mcp-utils';
|
|
|
3
3
|
import { LIBRARY_NAME_PATTERN } from '../library.js';
|
|
4
4
|
import { acceptableUploadType } from '../upload-url.js';
|
|
5
5
|
import { withProgressHeartbeat } from './shared.js';
|
|
6
|
-
import {
|
|
6
|
+
import { confirmNote, confirmTokenParam, confirmWrite } from './_confirm.js';
|
|
7
7
|
/**
|
|
8
8
|
* Tools over the per-account reference library (src/library.ts): named
|
|
9
9
|
* characters and styles that persist across sessions with NO expiry.
|
|
@@ -111,11 +111,21 @@ export function registerLibraryTools(server, client) {
|
|
|
111
111
|
});
|
|
112
112
|
});
|
|
113
113
|
server.registerTool('gemini_delete_character', {
|
|
114
|
-
description: 'Delete a saved character from this account\'s reference library. Generation calls naming it will then fail until it is re-saved.',
|
|
114
|
+
description: 'Delete a saved character from this account\'s reference library. Generation calls naming it will then fail until it is re-saved. ' + confirmNote(),
|
|
115
115
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
116
|
-
inputSchema: z.object({ name: NAME_FIELD,
|
|
117
|
-
}, async (args) => {
|
|
118
|
-
const gate =
|
|
116
|
+
inputSchema: z.object({ name: NAME_FIELD, confirmToken: confirmTokenParam }),
|
|
117
|
+
}, async (args, extra) => {
|
|
118
|
+
const gate = await confirmWrite(extra, {
|
|
119
|
+
tool: 'gemini_delete_character',
|
|
120
|
+
action: 'character.delete',
|
|
121
|
+
message: 'Review and confirm this deletion:',
|
|
122
|
+
description: 'Delete a saved character (its reference image and description)',
|
|
123
|
+
method: 'DELETE',
|
|
124
|
+
path: `characters/${args.name}`,
|
|
125
|
+
target: args.name,
|
|
126
|
+
payload: { name: args.name },
|
|
127
|
+
confirmToken: args.confirmToken,
|
|
128
|
+
});
|
|
119
129
|
if (gate)
|
|
120
130
|
return gate;
|
|
121
131
|
const existed = await library.deleteCharacter(args.name);
|
|
@@ -170,11 +180,21 @@ export function registerLibraryTools(server, client) {
|
|
|
170
180
|
});
|
|
171
181
|
});
|
|
172
182
|
server.registerTool('gemini_delete_style', {
|
|
173
|
-
description: 'Delete a saved style preset from this account\'s reference library.',
|
|
183
|
+
description: 'Delete a saved style preset from this account\'s reference library. ' + confirmNote(),
|
|
174
184
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
175
|
-
inputSchema: z.object({ name: NAME_FIELD,
|
|
176
|
-
}, async (args) => {
|
|
177
|
-
const gate =
|
|
185
|
+
inputSchema: z.object({ name: NAME_FIELD, confirmToken: confirmTokenParam }),
|
|
186
|
+
}, async (args, extra) => {
|
|
187
|
+
const gate = await confirmWrite(extra, {
|
|
188
|
+
tool: 'gemini_delete_style',
|
|
189
|
+
action: 'style.delete',
|
|
190
|
+
message: 'Review and confirm this deletion:',
|
|
191
|
+
description: 'Delete a saved style preset',
|
|
192
|
+
method: 'DELETE',
|
|
193
|
+
path: `styles/${args.name}`,
|
|
194
|
+
target: args.name,
|
|
195
|
+
payload: { name: args.name },
|
|
196
|
+
confirmToken: args.confirmToken,
|
|
197
|
+
});
|
|
178
198
|
if (gate)
|
|
179
199
|
return gate;
|
|
180
200
|
const existed = await library.deleteStyle(args.name);
|
package/dist/tools/music.js
CHANGED
|
@@ -5,7 +5,7 @@ import { DEFAULT_MUSIC_MODEL } from '../models.js';
|
|
|
5
5
|
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesBase64Schema } from './shared.js';
|
|
6
6
|
import { fingerprintRequest } from '../jobs.js';
|
|
7
7
|
import { attachCost } from '../pricing.js';
|
|
8
|
-
import {
|
|
8
|
+
import { confirmLocalInputs, confirmNote, confirmTokenParam } from './_confirm.js';
|
|
9
9
|
const MUSIC_MODELS = ['lyria-3-clip-preview', 'lyria-3.5', 'lyria-3-pro-preview'];
|
|
10
10
|
/**
|
|
11
11
|
* There is no output-format parameter, and there is no chaining.
|
|
@@ -28,7 +28,8 @@ export function registerMusicTools(server, client) {
|
|
|
28
28
|
'lyria-3-clip-preview (30s instrumental clip, default, cheapest), lyria-3.5 (full-length song with vocals) or ' +
|
|
29
29
|
'lyria-3-pro-preview (longer-form). Output is MP3, written to disk (or returned inline). Single-turn: a track ' +
|
|
30
30
|
'cannot be refined by a follow-up call, so put the whole brief in the prompt. Runs long — give it a `max_wait_ms` ' +
|
|
31
|
-
'budget (or `async: true` + gemini_get_result on a local install), or raise `timeout_ms`. Needs a funded account.'
|
|
31
|
+
'budget (or `async: true` + gemini_get_result on a local install), or raise `timeout_ms`. Needs a funded account. ' +
|
|
32
|
+
confirmNote('Local file inputs are confirmed first'),
|
|
32
33
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
33
34
|
inputSchema: z.object({
|
|
34
35
|
prompt: z.string().min(1).describe('Description of the music: mood, genre, instruments, tempo, structure, or lyrics'),
|
|
@@ -46,12 +47,16 @@ export function registerMusicTools(server, client) {
|
|
|
46
47
|
idempotency_key: idempotencyKeySchema,
|
|
47
48
|
async: asyncSchema,
|
|
48
49
|
max_wait_ms: maxWaitMsSchema,
|
|
49
|
-
|
|
50
|
+
confirmToken: confirmTokenParam,
|
|
50
51
|
}),
|
|
51
52
|
}, async (args, extra) => {
|
|
52
53
|
assertLocalInputsAvailable(client.mediaSink, args);
|
|
53
54
|
const model = args.model ?? DEFAULT_MUSIC_MODEL;
|
|
54
|
-
const
|
|
55
|
+
const { confirmToken, ...request } = args;
|
|
56
|
+
const gate = await confirmLocalInputs(extra, {
|
|
57
|
+
tool: 'gemini_music_generate', action: 'music.generate', description: 'Send local image input(s) to the Gemini Lyria API', endpoint: '/v1beta/interactions',
|
|
58
|
+
imagePaths: args.images, request, confirmToken,
|
|
59
|
+
});
|
|
55
60
|
if (gate)
|
|
56
61
|
return gate;
|
|
57
62
|
const fingerprint = fingerprintRequest('gemini_music_generate', {
|
package/dist/tools/set.js
CHANGED
|
@@ -7,11 +7,12 @@ 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
|
export function registerSetTools(server, client) {
|
|
12
12
|
server.registerTool('gemini_image_set', {
|
|
13
13
|
description: 'Generate a consistent SET of images: a master image from master_prompt, then one image per scene that references the master so the subject/style stays consistent. Provide `scenes` (explicit per-image prompts) OR `count` (variations of the master). ' +
|
|
14
|
-
'Scene generations run in parallel (reference_mode "master", the default). On the hosted connector: saved `characters` and a saved `style` can seed the whole set by name, multi-image results include a `bundle_url` zip of every image (one curl instead of N), and `max_wait_ms` returns a pollable job handle if the batch runs long.'
|
|
14
|
+
'Scene generations run in parallel (reference_mode "master", the default). On the hosted connector: saved `characters` and a saved `style` can seed the whole set by name, multi-image results include a `bundle_url` zip of every image (one curl instead of N), and `max_wait_ms` returns a pollable job handle if the batch runs long. ' +
|
|
15
|
+
confirmNote('Local file inputs are confirmed first'),
|
|
15
16
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
16
17
|
inputSchema: z.object({
|
|
17
18
|
master_prompt: z.string().min(1).describe('Prompt for the master/reference image'),
|
|
@@ -26,7 +27,7 @@ export function registerSetTools(server, client) {
|
|
|
26
27
|
characters: charactersSchema,
|
|
27
28
|
style: styleSchema,
|
|
28
29
|
master_images_base64: imagesBase64Schema('Reference images for the master generation', 'master_images'),
|
|
29
|
-
|
|
30
|
+
confirmToken: confirmTokenParam,
|
|
30
31
|
...sharedImageSchema,
|
|
31
32
|
}),
|
|
32
33
|
}, async (args, extra) => {
|
|
@@ -36,8 +37,12 @@ export function registerSetTools(server, client) {
|
|
|
36
37
|
throw new McpToolError('Provide exactly one of `scenes` or `count`.');
|
|
37
38
|
}
|
|
38
39
|
// Confirm-gate local file inputs: only `master_images` (paths) can read a
|
|
39
|
-
// local file; base64 references pass through ungated.
|
|
40
|
-
const
|
|
40
|
+
// local file; base64 references pass through ungated. The preview makes NO API call.
|
|
41
|
+
const { confirmToken, ...request } = args;
|
|
42
|
+
const gate = await confirmLocalInputs(extra, {
|
|
43
|
+
tool: 'gemini_image_set', action: 'image.set', description: 'Send local master image input(s) to the Gemini API', endpoint: '/v1beta/models/{model}:generateContent',
|
|
44
|
+
imagePaths: args.master_images, request, confirmToken,
|
|
45
|
+
});
|
|
41
46
|
if (gate)
|
|
42
47
|
return gate;
|
|
43
48
|
const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
package/dist/tools/video.js
CHANGED
|
@@ -6,7 +6,7 @@ import { DEFAULT_VIDEO_MODEL } from '../models.js';
|
|
|
6
6
|
import { emitMedia, reportShape, resolveAspectRatio, orientationSchema, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesBase64Schema } from './shared.js';
|
|
7
7
|
import { fingerprintRequest } from '../jobs.js';
|
|
8
8
|
import { attachCost } from '../pricing.js';
|
|
9
|
-
import {
|
|
9
|
+
import { confirmLocalInputs, confirmNote, confirmTokenParam } from './_confirm.js';
|
|
10
10
|
/** omni's own aspect-ratio enum — NOT the image tools' ASPECT_RATIOS. */
|
|
11
11
|
const VIDEO_ASPECT_RATIOS = ['16:9', '9:16'];
|
|
12
12
|
const VIDEO_TASKS = ['text_to_video', 'image_to_video', 'reference_to_video', 'edit', 'extend'];
|
|
@@ -36,7 +36,8 @@ export function registerVideoTools(server, client) {
|
|
|
36
36
|
'or continue a prior video (task: "edit" or "extend" + previous_interaction_id / continue_last; extensions ' +
|
|
37
37
|
'add ~3-10s each, to ~40s total). Cost scales with `resolution` — draft at 360p, keep at 1080p/4k. ' +
|
|
38
38
|
'Output is written to disk as MP4 (video has no inline MCP block). Video runs long — give it a `max_wait_ms` budget ' +
|
|
39
|
-
'(or `async: true` + gemini_get_result on a local install), or raise `timeout_ms`. Needs a funded account.'
|
|
39
|
+
'(or `async: true` + gemini_get_result on a local install), or raise `timeout_ms`. Needs a funded account. ' +
|
|
40
|
+
confirmNote('Local file inputs are confirmed first'),
|
|
40
41
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
41
42
|
inputSchema: z.object({
|
|
42
43
|
prompt: z.string().min(1).describe('Description of the video to generate (or the edit instruction when task=edit)'),
|
|
@@ -64,7 +65,7 @@ export function registerVideoTools(server, client) {
|
|
|
64
65
|
idempotency_key: idempotencyKeySchema,
|
|
65
66
|
async: asyncSchema,
|
|
66
67
|
max_wait_ms: maxWaitMsSchema,
|
|
67
|
-
|
|
68
|
+
confirmToken: confirmTokenParam,
|
|
68
69
|
}),
|
|
69
70
|
}, async (args, extra) => {
|
|
70
71
|
assertLocalInputsAvailable(client.mediaSink, args);
|
|
@@ -79,7 +80,11 @@ export function registerVideoTools(server, client) {
|
|
|
79
80
|
}
|
|
80
81
|
// Confirm-gate local file inputs (a prompt-injected `images` path could
|
|
81
82
|
// exfiltrate a local file); base64 / clipboard pass through ungated.
|
|
82
|
-
const
|
|
83
|
+
const { confirmToken, ...request } = args;
|
|
84
|
+
const gate = await confirmLocalInputs(extra, {
|
|
85
|
+
tool: 'gemini_video_generate', action: 'video.generate', description: 'Send local image input(s) to the Gemini omni API', endpoint: '/v1beta/interactions',
|
|
86
|
+
imagePaths: args.images, request: { ...request, previous_interaction_id: previousInteractionId }, confirmToken,
|
|
87
|
+
});
|
|
83
88
|
if (gate)
|
|
84
89
|
return gate;
|
|
85
90
|
const model = args.model?.trim() || DEFAULT_VIDEO_MODEL;
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Single source of the server version. release-please bumps the literal below. */
|
|
2
|
-
export const VERSION = '2.
|
|
2
|
+
export const VERSION = '2.3.0'; // x-release-please-version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrischall/gemini-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/gemini-mcp",
|
|
5
5
|
"description": "Gemini image-generation MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
"typecheck": "tsc -p tsconfig.test.json"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@chrischall/mcp-utils": "^2.
|
|
49
|
+
"@chrischall/mcp-utils": "^2.6.0",
|
|
50
50
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
51
|
-
"dotenv": "^
|
|
51
|
+
"dotenv": "^18.0.2",
|
|
52
52
|
"zod": "^4.6.5"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|