@chrischall/gemini-mcp 0.7.2 → 0.9.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 +21 -0
- package/dist/bundle.js +594 -139
- package/dist/client.js +55 -29
- package/dist/images.js +42 -1
- package/dist/index.js +2 -1
- package/dist/jobs.js +0 -0
- package/dist/tools/_confirm.js +39 -0
- package/dist/tools/generate.js +108 -64
- package/dist/tools/interact.js +92 -41
- package/dist/tools/jobs.js +17 -0
- package/dist/tools/set.js +63 -37
- package/dist/tools/shared.js +132 -8
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/client.js
CHANGED
|
@@ -8,8 +8,24 @@ const BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'; // v1 lacks
|
|
|
8
8
|
const SERVICE = 'Gemini';
|
|
9
9
|
const INTERACTIONS_SERVICE = 'Gemini Interactions';
|
|
10
10
|
// Per-request abort budget. Image generation routinely runs 30s+ on the Pro
|
|
11
|
-
// model, so this is deliberately 60s (NOT the fleet's usual 15–30s)
|
|
11
|
+
// model, so this is deliberately 60s (NOT the fleet's usual 15–30s) — and 4K
|
|
12
|
+
// output routinely runs past 60s, hence the doubled 4K default. Both are
|
|
13
|
+
// overridable: per-call `timeout_ms` beats $GEMINI_TIMEOUT_MS beats these.
|
|
12
14
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
15
|
+
const REQUEST_TIMEOUT_4K_MS = 120_000;
|
|
16
|
+
/**
|
|
17
|
+
* Effective upstream timeout for one request:
|
|
18
|
+
* per-call `timeout_ms` → `$GEMINI_TIMEOUT_MS` → 120s for 4K output → 60s.
|
|
19
|
+
* Resolved at request time so an env change doesn't require a new process.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveTimeoutMs(perCallMs, imageSize) {
|
|
22
|
+
if (perCallMs !== undefined && perCallMs > 0)
|
|
23
|
+
return perCallMs;
|
|
24
|
+
const env = Number(readEnvVar('GEMINI_TIMEOUT_MS'));
|
|
25
|
+
if (Number.isFinite(env) && env > 0)
|
|
26
|
+
return env;
|
|
27
|
+
return imageSize === '4K' ? REQUEST_TIMEOUT_4K_MS : REQUEST_TIMEOUT_MS;
|
|
28
|
+
}
|
|
13
29
|
// Files API (local-video upload — docs/GEMINI-API.md "Files API — local video
|
|
14
30
|
// upload", verified 2026-06-12). The resumable upload endpoint lives under
|
|
15
31
|
// `/upload/v1beta`, NOT the normal `/v1beta` base.
|
|
@@ -31,8 +47,7 @@ const CHAIN_404_RETRY_MS = 2_000;
|
|
|
31
47
|
export class GeminiClient {
|
|
32
48
|
apiKey;
|
|
33
49
|
configError;
|
|
34
|
-
|
|
35
|
-
interactionsApi;
|
|
50
|
+
apis = new Map();
|
|
36
51
|
fetchImpl;
|
|
37
52
|
sleep;
|
|
38
53
|
constructor(opts = {}) {
|
|
@@ -47,30 +62,39 @@ export class GeminiClient {
|
|
|
47
62
|
this.apiKey = key;
|
|
48
63
|
this.configError = null;
|
|
49
64
|
}
|
|
50
|
-
// Both paths share: the key in the `x-goog-api-key` header (Gemini does not
|
|
51
|
-
// use `Authorization: Bearer`), a 60s abort budget, and the default 429
|
|
52
|
-
// retry (once after 2s — the free tier rate-limits aggressively).
|
|
53
|
-
// `getToken` calls requireKey() so key resolution stays at REQUEST time:
|
|
54
|
-
// the server boots without a key and the actionable config error (with its
|
|
55
|
-
// aistudio hint) surfaces on the first tool call, not at startup.
|
|
56
65
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
57
66
|
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The `createApiClient` pair for one timeout budget, built lazily and
|
|
70
|
+
* memoized per distinct timeout (a handful of values in practice). Both
|
|
71
|
+
* share: the key in the `x-goog-api-key` header (Gemini does not use
|
|
72
|
+
* `Authorization: Bearer`) and the default 429 retry (once after 2s — the
|
|
73
|
+
* free tier rate-limits aggressively). `getToken` calls requireKey() so key
|
|
74
|
+
* resolution stays at REQUEST time: the server boots without a key and the
|
|
75
|
+
* actionable config error (with its aistudio hint) surfaces on the first
|
|
76
|
+
* tool call, not at startup.
|
|
77
|
+
*/
|
|
78
|
+
apisFor(timeoutMs) {
|
|
79
|
+
let entry = this.apis.get(timeoutMs);
|
|
80
|
+
if (!entry) {
|
|
81
|
+
const shared = {
|
|
82
|
+
tokenHeader: 'x-goog-api-key',
|
|
83
|
+
getToken: () => this.requireKey(),
|
|
84
|
+
timeout: timeoutMs,
|
|
85
|
+
fetchImpl: this.fetchImpl,
|
|
86
|
+
};
|
|
87
|
+
entry = {
|
|
88
|
+
api: createApiClient({ baseUrl: BASE_URL, serviceName: SERVICE, ...shared }),
|
|
89
|
+
// Separate client for the Interactions API so errors name the right API.
|
|
90
|
+
// The beta-era Api-Revision header is gone: the API went GA and requests
|
|
91
|
+
// succeed without it (verified live 2026-07-06; see docs/GEMINI-API.md).
|
|
92
|
+
// Don't re-add a pinned revision — it would freeze us on old semantics.
|
|
93
|
+
interactionsApi: createApiClient({ baseUrl: BASE_URL, serviceName: INTERACTIONS_SERVICE, ...shared }),
|
|
94
|
+
};
|
|
95
|
+
this.apis.set(timeoutMs, entry);
|
|
96
|
+
}
|
|
97
|
+
return entry;
|
|
74
98
|
}
|
|
75
99
|
requireKey() {
|
|
76
100
|
if (this.configError)
|
|
@@ -81,8 +105,9 @@ export class GeminiClient {
|
|
|
81
105
|
defaultModel() {
|
|
82
106
|
return resolveModel(undefined, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
83
107
|
}
|
|
84
|
-
async call(method, path, body) {
|
|
85
|
-
|
|
108
|
+
async call(method, path, body, timeoutMs) {
|
|
109
|
+
const { api } = this.apisFor(timeoutMs ?? resolveTimeoutMs());
|
|
110
|
+
return api.fetchJson(method, path, body !== undefined ? { body } : {});
|
|
86
111
|
}
|
|
87
112
|
/**
|
|
88
113
|
* Upload a local video to the Gemini Files API (resumable protocol) and wait
|
|
@@ -200,7 +225,7 @@ export class GeminiClient {
|
|
|
200
225
|
const requestBody = { contents: [{ parts }], generationConfig };
|
|
201
226
|
if (opts.googleSearch)
|
|
202
227
|
requestBody.tools = [{ google_search: {} }];
|
|
203
|
-
const data = await this.call('POST', `/models/${model}:generateContent`, requestBody);
|
|
228
|
+
const data = await this.call('POST', `/models/${model}:generateContent`, requestBody, resolveTimeoutMs(opts.timeoutMs, opts.imageSize));
|
|
204
229
|
const images = [];
|
|
205
230
|
const textParts = [];
|
|
206
231
|
let groundingMeta;
|
|
@@ -263,10 +288,11 @@ export class GeminiClient {
|
|
|
263
288
|
searchTool.search_types = opts.searchTypes;
|
|
264
289
|
body.tools = [searchTool];
|
|
265
290
|
}
|
|
291
|
+
const { interactionsApi } = this.apisFor(resolveTimeoutMs(opts.timeoutMs, opts.imageSize));
|
|
266
292
|
let data;
|
|
267
293
|
for (let attempt = 0;; attempt++) {
|
|
268
294
|
try {
|
|
269
|
-
data = await
|
|
295
|
+
data = await interactionsApi.fetchJson('POST', '/interactions', {
|
|
270
296
|
body,
|
|
271
297
|
});
|
|
272
298
|
break;
|
package/dist/images.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile, access } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, readFile, writeFile, access, stat, open } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { join, resolve, isAbsolute } from 'node:path';
|
|
4
4
|
import { readEnvVar, McpToolError } from '@chrischall/mcp-utils';
|
|
@@ -57,6 +57,16 @@ export async function writeImage(dir, base, base64, mimeType) {
|
|
|
57
57
|
await writeFile(path, Buffer.from(base64, 'base64'));
|
|
58
58
|
return resolve(path);
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Write `<imagePath>.json` recording generation metadata (notably the
|
|
62
|
+
* interaction id) next to a written image. The MCP host's tools/call timeout
|
|
63
|
+
* can expire while the server-side generation finishes anyway — the response
|
|
64
|
+
* (and its interaction id) is then lost, but this sidecar survives, so the
|
|
65
|
+
* multi-turn chain is recoverable from disk.
|
|
66
|
+
*/
|
|
67
|
+
export async function writeSidecar(imagePath, data) {
|
|
68
|
+
await writeFile(`${imagePath}.json`, JSON.stringify(data, null, 2));
|
|
69
|
+
}
|
|
60
70
|
/**
|
|
61
71
|
* Resolve an input file path, checking (in order):
|
|
62
72
|
* 1. Absolute path that exists → returned as-is.
|
|
@@ -116,6 +126,37 @@ export function videoMimeType(p) {
|
|
|
116
126
|
}
|
|
117
127
|
return mime;
|
|
118
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Preview a local IMAGE input WITHOUT sending it anywhere: resolve the path to
|
|
131
|
+
* an absolute one, sniff its mime from the leading bytes, and measure its size.
|
|
132
|
+
* Reads the file locally (to sniff/measure) but makes NO network/API call — so a
|
|
133
|
+
* prompt-injected `image_path` (e.g. a local secret) is visible before upload.
|
|
134
|
+
*/
|
|
135
|
+
export async function previewImageInput(path) {
|
|
136
|
+
const resolved = resolveImagePath(path);
|
|
137
|
+
// Read only the header bytes needed to sniff the MIME (≤12), and take the size
|
|
138
|
+
// from stat() — a dry-run must not load a large reference image into memory
|
|
139
|
+
// just to report it (matches previewVideoInput).
|
|
140
|
+
const { size } = await stat(resolved);
|
|
141
|
+
const fh = await open(resolved, 'r');
|
|
142
|
+
try {
|
|
143
|
+
const head = Buffer.alloc(16);
|
|
144
|
+
const { bytesRead } = await fh.read(head, 0, 16, 0);
|
|
145
|
+
return { path: resolved, mimeType: sniffMimeBytes(head.subarray(0, bytesRead)), size };
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
await fh.close();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Preview a local VIDEO input WITHOUT uploading it: resolve the path, derive its
|
|
153
|
+
* mime from the extension, and measure its size. Makes NO network/API call.
|
|
154
|
+
*/
|
|
155
|
+
export async function previewVideoInput(path) {
|
|
156
|
+
const resolved = resolveVideoPath(path);
|
|
157
|
+
const { size } = await stat(resolved);
|
|
158
|
+
return { path: resolved, mimeType: videoMimeType(resolved), size };
|
|
159
|
+
}
|
|
119
160
|
/** Read an image file into `{ base64, mimeType }` for an inline_data part. */
|
|
120
161
|
export async function readImageAsInline(path) {
|
|
121
162
|
const resolved = resolveImagePath(path);
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { registerModelTools } from './tools/models.js';
|
|
|
5
5
|
import { registerGenerateTools } from './tools/generate.js';
|
|
6
6
|
import { registerSetTools } from './tools/set.js';
|
|
7
7
|
import { registerInteractTools } from './tools/interact.js';
|
|
8
|
+
import { registerJobTools } from './tools/jobs.js';
|
|
8
9
|
// The GeminiClient is a module-level singleton (imported by each tool module)
|
|
9
10
|
// that defers its config error to the first request — so the server boots and
|
|
10
11
|
// answers the host's install-time tools/list probe even without GEMINI_API_KEY.
|
|
@@ -12,5 +13,5 @@ await runMcp({
|
|
|
12
13
|
name: 'gemini-mcp',
|
|
13
14
|
version: VERSION,
|
|
14
15
|
banner: '[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.',
|
|
15
|
-
tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools],
|
|
16
|
+
tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerJobTools],
|
|
16
17
|
});
|
package/dist/jobs.js
ADDED
|
Binary file
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { schemaConfirm, textResult } from '@chrischall/mcp-utils';
|
|
2
|
+
import { previewImageInput, previewVideoInput } from '../images.js';
|
|
3
|
+
export { schemaConfirm };
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
export function previewUnlessConfirmed(confirm, action, method, path, body) {
|
|
10
|
+
if (confirm === true)
|
|
11
|
+
return null;
|
|
12
|
+
return textResult({
|
|
13
|
+
dryRun: true,
|
|
14
|
+
action,
|
|
15
|
+
method,
|
|
16
|
+
path,
|
|
17
|
+
...(body !== undefined ? { willSend: body } : {}),
|
|
18
|
+
note: 'Re-run with confirm: true to execute.',
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 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.
|
|
27
|
+
* `~/.ssh/id_rsa`) is visible and interceptable before any byte leaves the box.
|
|
28
|
+
*/
|
|
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;
|
|
33
|
+
const inputs = [];
|
|
34
|
+
for (const p of imagePaths ?? [])
|
|
35
|
+
inputs.push(await previewImageInput(p));
|
|
36
|
+
if (videoPath)
|
|
37
|
+
inputs.push(await previewVideoInput(videoPath));
|
|
38
|
+
return previewUnlessConfirmed(confirm, action, 'POST', endpoint, { inputs });
|
|
39
|
+
}
|
package/dist/tools/generate.js
CHANGED
|
@@ -3,7 +3,11 @@ import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
|
|
|
3
3
|
import { resolveModel } from '../models.js';
|
|
4
4
|
import { client } from '../client.js';
|
|
5
5
|
import { slugify, baseName, gatherImageInputs } from '../images.js';
|
|
6
|
-
import { emit, sharedImageSchema, pickSeed, buildMeta, resolveVideoInput, videoPathSchema } from './shared.js';
|
|
6
|
+
import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat } from './shared.js';
|
|
7
|
+
import { dispatch, fingerprintRequest } from '../jobs.js';
|
|
8
|
+
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
9
|
+
const GENERATE_ENDPOINT = '/v1beta/models/{model}:generateContent';
|
|
10
|
+
const SEND_LOCAL_ACTION = 'Send local file input(s) to the Gemini API';
|
|
7
11
|
// generateContent output can't seed an Interactions-API conversation, so
|
|
8
12
|
// results point follow-up edits at gemini_interact instead of an id.
|
|
9
13
|
const REFINE_HINT = 'To iteratively refine this image, use gemini_interact (pass the output file via `images` on the first call, ' +
|
|
@@ -21,53 +25,76 @@ export function registerGenerateTools(server) {
|
|
|
21
25
|
images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs'),
|
|
22
26
|
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)'),
|
|
23
27
|
video_path: videoPathSchema,
|
|
28
|
+
confirm: schemaConfirm,
|
|
24
29
|
...sharedImageSchema,
|
|
25
30
|
},
|
|
26
|
-
}, async (args) => {
|
|
31
|
+
}, async (args, extra) => {
|
|
32
|
+
// Confirm-gate local file inputs: a prompt-injected `images`/`video_path`
|
|
33
|
+
// could exfiltrate a local file. Pure text-to-image calls (no local input)
|
|
34
|
+
// are unaffected. Dry-run makes NO API call.
|
|
35
|
+
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images, args.video_path);
|
|
36
|
+
if (gate)
|
|
37
|
+
return gate;
|
|
27
38
|
const count = args.count ?? 1;
|
|
28
|
-
const seed = pickSeed(args.seed);
|
|
29
39
|
const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
40
|
+
// Fingerprint the request identity (excl. server-picked seed / output path)
|
|
41
|
+
// so a blind re-issue after a host timeout dedups instead of re-billing.
|
|
42
|
+
const fingerprint = fingerprintRequest('gemini_generate_image', {
|
|
43
|
+
model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
44
|
+
thinking_level: args.thinking_level, google_search: args.google_search, count,
|
|
45
|
+
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
46
|
+
video_url: args.video_url, video_path: args.video_path,
|
|
47
|
+
});
|
|
48
|
+
return dispatch({ toolName: 'gemini_generate_image', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
49
|
+
const seed = pickSeed(args.seed);
|
|
50
|
+
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
51
|
+
const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
52
|
+
// Upload ONCE (before the count loop) — every generate call references
|
|
53
|
+
// the same files/… uri.
|
|
54
|
+
const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args));
|
|
55
|
+
const named = [];
|
|
56
|
+
let capturedText;
|
|
57
|
+
// For count>1, surface the FIRST call's grounding (each call grounds
|
|
58
|
+
// independently; one representative set of sources beats concatenating N).
|
|
59
|
+
let capturedGrounding;
|
|
60
|
+
await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : 'image'} (${model})`, async () => {
|
|
61
|
+
for (let i = 0; i < count; i++) {
|
|
62
|
+
// Distinct seed per image (seed+0 for the single-image case == echoed seed)
|
|
63
|
+
// so count>1 yields N *different* images, not N duplicates.
|
|
64
|
+
const result = await client.generate({
|
|
65
|
+
prompt: args.prompt,
|
|
66
|
+
images: refInputs.length > 0 ? refInputs : undefined,
|
|
67
|
+
model: args.model,
|
|
68
|
+
aspectRatio: args.aspect_ratio,
|
|
69
|
+
imageSize: args.image_size,
|
|
70
|
+
seed: seed + i,
|
|
71
|
+
thinkingLevel: args.thinking_level,
|
|
72
|
+
googleSearch: args.google_search,
|
|
73
|
+
videoUrl: video.videoUrl,
|
|
74
|
+
videoMimeType: video.videoMimeType,
|
|
75
|
+
timeoutMs: args.timeout_ms,
|
|
76
|
+
});
|
|
77
|
+
const { images: [img], text } = result;
|
|
78
|
+
if (text && !capturedText)
|
|
79
|
+
capturedText = text;
|
|
80
|
+
if (result.grounding && !capturedGrounding)
|
|
81
|
+
capturedGrounding = result.grounding;
|
|
82
|
+
named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, '0')}` });
|
|
83
|
+
}
|
|
54
84
|
});
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
meta.video_file = video.videoFileMeta;
|
|
69
|
-
meta.hint = REFINE_HINT;
|
|
70
|
-
return emit(named, args, meta);
|
|
85
|
+
const meta = buildMeta(model, seed, args);
|
|
86
|
+
if (capturedText)
|
|
87
|
+
meta.text = capturedText;
|
|
88
|
+
if (capturedGrounding)
|
|
89
|
+
meta.grounding = capturedGrounding;
|
|
90
|
+
if (video.videoFileMeta)
|
|
91
|
+
meta.video_file = video.videoFileMeta;
|
|
92
|
+
meta.hint = REFINE_HINT;
|
|
93
|
+
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count });
|
|
94
|
+
if (risk)
|
|
95
|
+
meta.timeout_risk = risk;
|
|
96
|
+
return emit(named, args, meta);
|
|
97
|
+
});
|
|
71
98
|
});
|
|
72
99
|
server.registerTool('gemini_edit_image', {
|
|
73
100
|
description: 'Edit or compose images: provide one or more input images (paths or base64), plus a text instruction. ' +
|
|
@@ -80,35 +107,52 @@ export function registerGenerateTools(server) {
|
|
|
80
107
|
images: z.array(z.string().min(1)).optional().describe('Paths to input image file(s) (1 = edit, 2+ = compose)'),
|
|
81
108
|
images_base64: z.array(z.string().min(1)).optional().describe('Input images as base64 strings or data URIs'),
|
|
82
109
|
filename: z.string().optional().describe('Base filename for the output image (extension stripped; default: slugified prompt)'),
|
|
110
|
+
confirm: schemaConfirm,
|
|
83
111
|
...sharedImageSchema,
|
|
84
112
|
},
|
|
85
|
-
}, async (args) => {
|
|
113
|
+
}, async (args, extra) => {
|
|
86
114
|
const hasPaths = (args.images?.length ?? 0) > 0;
|
|
87
115
|
const hasBase64 = (args.images_base64?.length ?? 0) > 0;
|
|
88
116
|
if (!hasPaths && !hasBase64 && !args.from_clipboard) {
|
|
89
117
|
throw new McpToolError('Provide at least one input image via `images`, `images_base64`, or `from_clipboard`.');
|
|
90
118
|
}
|
|
91
|
-
|
|
119
|
+
// Confirm-gate local file inputs (see gemini_generate_image). base64 /
|
|
120
|
+
// clipboard inputs are not local-path reads and pass through ungated.
|
|
121
|
+
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
|
|
122
|
+
if (gate)
|
|
123
|
+
return gate;
|
|
92
124
|
const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
125
|
+
const fingerprint = fingerprintRequest('gemini_edit_image', {
|
|
126
|
+
model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
127
|
+
thinking_level: args.thinking_level, google_search: args.google_search,
|
|
128
|
+
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
129
|
+
});
|
|
130
|
+
return dispatch({ toolName: 'gemini_edit_image', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
131
|
+
const seed = pickSeed(args.seed);
|
|
132
|
+
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
133
|
+
const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
134
|
+
const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
|
|
135
|
+
prompt: args.prompt,
|
|
136
|
+
images: inputs,
|
|
137
|
+
model: args.model,
|
|
138
|
+
aspectRatio: args.aspect_ratio,
|
|
139
|
+
imageSize: args.image_size,
|
|
140
|
+
seed,
|
|
141
|
+
thinkingLevel: args.thinking_level,
|
|
142
|
+
googleSearch: args.google_search,
|
|
143
|
+
timeoutMs: args.timeout_ms,
|
|
144
|
+
}));
|
|
145
|
+
const { images: [img], text } = result;
|
|
146
|
+
const meta = buildMeta(model, seed, args);
|
|
147
|
+
if (text)
|
|
148
|
+
meta.text = text;
|
|
149
|
+
if (result.grounding)
|
|
150
|
+
meta.grounding = result.grounding;
|
|
151
|
+
meta.hint = REFINE_HINT;
|
|
152
|
+
const risk = timeoutRiskHint({ model, imageSize: args.image_size });
|
|
153
|
+
if (risk)
|
|
154
|
+
meta.timeout_risk = risk;
|
|
155
|
+
return emit([{ image: img, base: slug }], args, meta);
|
|
104
156
|
});
|
|
105
|
-
const { images: [img], text } = result;
|
|
106
|
-
const meta = buildMeta(model, seed, args);
|
|
107
|
-
if (text)
|
|
108
|
-
meta.text = text;
|
|
109
|
-
if (result.grounding)
|
|
110
|
-
meta.grounding = result.grounding;
|
|
111
|
-
meta.hint = REFINE_HINT;
|
|
112
|
-
return emit([{ image: img, base: slug }], args, meta);
|
|
113
157
|
});
|
|
114
158
|
}
|
package/dist/tools/interact.js
CHANGED
|
@@ -2,8 +2,10 @@ import { z } from 'zod';
|
|
|
2
2
|
import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
|
|
3
3
|
import { resolveModel } from '../models.js';
|
|
4
4
|
import { client } from '../client.js';
|
|
5
|
-
import { slugify, baseName, gatherImageInputs, resolveImagePath } from '../images.js';
|
|
6
|
-
import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema } from './shared.js';
|
|
5
|
+
import { slugify, baseName, gatherImageInputs, resolveImagePath, writeSidecar } from '../images.js';
|
|
6
|
+
import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, withProgressHeartbeat } from './shared.js';
|
|
7
|
+
import { dispatch, fingerprintRequest } from '../jobs.js';
|
|
8
|
+
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
7
9
|
// The most recent interaction id this server process created — what
|
|
8
10
|
// `continue_last: true` resumes. In-memory only: an MCP server lives for the
|
|
9
11
|
// host session, so "last" means "last in this session".
|
|
@@ -14,6 +16,16 @@ let lastInteractionId;
|
|
|
14
16
|
// the model against the requested edit (observed in the wild — callers pass
|
|
15
17
|
// the last result path back via `images` despite the description saying not to).
|
|
16
18
|
const writtenOutputs = new Set();
|
|
19
|
+
/**
|
|
20
|
+
* Test-only: clear the module-level session memory (`lastInteractionId` and the
|
|
21
|
+
* written-outputs set) so tests that assert the "no prior interaction" path are
|
|
22
|
+
* order-independent — they can't rely on module-load state once other tests have
|
|
23
|
+
* run first (e.g. under `--sequence.shuffle`).
|
|
24
|
+
*/
|
|
25
|
+
export function __resetInteractSessionForTest() {
|
|
26
|
+
lastInteractionId = undefined;
|
|
27
|
+
writtenOutputs.clear();
|
|
28
|
+
}
|
|
17
29
|
/** Shared warning for the reference-image params. */
|
|
18
30
|
const NEW_REFERENCES_ONLY = 'NEW reference images only (e.g. a style or target photo). When chaining with previous_interaction_id, ' +
|
|
19
31
|
"do NOT re-attach the prior turn's output — the interaction already contains it, and re-sending it " +
|
|
@@ -43,6 +55,9 @@ export function registerInteractTools(server) {
|
|
|
43
55
|
'To refine, capture the returned interaction `id` and pass it as `previous_interaction_id` ' +
|
|
44
56
|
'on the next call — do NOT start a new interaction or re-upload the image for each tweak. ' +
|
|
45
57
|
'`continue_last: true` chains from this session\'s most recent interaction without threading the id. ' +
|
|
58
|
+
'If a call times out on the client side, the generation usually still completes: the image plus a ' +
|
|
59
|
+
'`<image>.json` sidecar recording its interaction id land in the output dir, and `continue_last: true` ' +
|
|
60
|
+
'still resumes that interaction — check the output dir before re-issuing (a re-issue is a second billable generation). ' +
|
|
46
61
|
'Output is JPEG.',
|
|
47
62
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
48
63
|
inputSchema: {
|
|
@@ -103,12 +118,16 @@ export function registerInteractTools(server) {
|
|
|
103
118
|
.optional()
|
|
104
119
|
.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)'),
|
|
105
120
|
video_path: videoPathSchema,
|
|
121
|
+
timeout_ms: timeoutMsSchema,
|
|
122
|
+
idempotency_key: idempotencyKeySchema,
|
|
123
|
+
async: asyncSchema,
|
|
106
124
|
from_clipboard: z
|
|
107
125
|
.boolean()
|
|
108
126
|
.optional()
|
|
109
127
|
.describe('Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)'),
|
|
128
|
+
confirm: schemaConfirm,
|
|
110
129
|
},
|
|
111
|
-
}, async (args) => {
|
|
130
|
+
}, async (args, extra) => {
|
|
112
131
|
let previousInteractionId = args.previous_interaction_id;
|
|
113
132
|
if (!previousInteractionId && args.continue_last) {
|
|
114
133
|
if (!lastInteractionId) {
|
|
@@ -123,44 +142,76 @@ export function registerInteractTools(server) {
|
|
|
123
142
|
if (previousInteractionId && images?.length) {
|
|
124
143
|
({ kept: images, dropped } = splitReattachedOutputs(images));
|
|
125
144
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
imageSize: args.image_size,
|
|
134
|
-
thinkingLevel: args.thinking_level,
|
|
135
|
-
previousInteractionId,
|
|
136
|
-
googleSearch: args.google_search,
|
|
137
|
-
searchTypes: args.search_types,
|
|
138
|
-
videoUrl: video.videoUrl,
|
|
139
|
-
videoMimeType: video.videoMimeType,
|
|
140
|
-
});
|
|
141
|
-
lastInteractionId = r.id;
|
|
145
|
+
// Confirm-gate local file inputs AFTER the re-attach split, so the preview
|
|
146
|
+
// reflects the paths actually sent (dropped prior-output paths aren't). A
|
|
147
|
+
// prompt-injected `images`/`video_path` could exfiltrate a local file;
|
|
148
|
+
// base64/clipboard inputs pass through ungated. Dry-run makes NO API call.
|
|
149
|
+
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Send local file input(s) to the Gemini Interactions API', '/v1beta/interactions', images, args.video_path);
|
|
150
|
+
if (gate)
|
|
151
|
+
return gate;
|
|
142
152
|
const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
153
|
+
const fingerprint = fingerprintRequest('gemini_interact', {
|
|
154
|
+
model, input: args.input, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
155
|
+
thinking_level: args.thinking_level, previous_interaction_id: previousInteractionId,
|
|
156
|
+
google_search: args.google_search, search_types: args.search_types,
|
|
157
|
+
images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
158
|
+
video_url: args.video_url, video_path: args.video_path,
|
|
159
|
+
});
|
|
160
|
+
return dispatch({ toolName: 'gemini_interact', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
161
|
+
const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
162
|
+
const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args));
|
|
163
|
+
const r = await withProgressHeartbeat(extra, 'Generating image (Gemini Interactions API)', () => client.interact({
|
|
164
|
+
input: args.input,
|
|
165
|
+
images: inputs.length ? inputs : undefined,
|
|
166
|
+
model: args.model,
|
|
167
|
+
aspectRatio: args.aspect_ratio,
|
|
168
|
+
imageSize: args.image_size,
|
|
169
|
+
thinkingLevel: args.thinking_level,
|
|
170
|
+
previousInteractionId,
|
|
171
|
+
googleSearch: args.google_search,
|
|
172
|
+
searchTypes: args.search_types,
|
|
173
|
+
videoUrl: video.videoUrl,
|
|
174
|
+
videoMimeType: video.videoMimeType,
|
|
175
|
+
timeoutMs: args.timeout_ms,
|
|
176
|
+
}));
|
|
177
|
+
lastInteractionId = r.id;
|
|
178
|
+
const meta = { model, interaction_id: r.id };
|
|
179
|
+
if (previousInteractionId)
|
|
180
|
+
meta.previous_interaction_id = previousInteractionId;
|
|
181
|
+
if (dropped.length)
|
|
182
|
+
meta.dropped_previous_output = dropped;
|
|
183
|
+
meta.hint =
|
|
184
|
+
`To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true) — ` +
|
|
185
|
+
'do NOT re-attach the returned image as an input; the interaction already contains it.';
|
|
186
|
+
if (r.text)
|
|
187
|
+
meta.text = r.text;
|
|
188
|
+
if (r.grounding)
|
|
189
|
+
meta.grounding = r.grounding;
|
|
190
|
+
if (video.videoFileMeta)
|
|
191
|
+
meta.video_file = video.videoFileMeta;
|
|
192
|
+
const risk = timeoutRiskHint({ model, imageSize: args.image_size });
|
|
193
|
+
if (risk)
|
|
194
|
+
meta.timeout_risk = risk;
|
|
195
|
+
const slug = args.filename ? baseName(args.filename) : slugify(args.input);
|
|
196
|
+
const named = r.images.map((image, i) => ({
|
|
197
|
+
image,
|
|
198
|
+
base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
|
|
199
|
+
}));
|
|
200
|
+
// writeImage returns already-resolved absolute paths.
|
|
201
|
+
return emit(named, args, meta, async (paths) => {
|
|
202
|
+
for (const p of paths)
|
|
203
|
+
writtenOutputs.add(p);
|
|
204
|
+
// Sidecar per image so the interaction id survives a lost MCP response
|
|
205
|
+
// (host timeout). Best-effort: the image is already on disk and the id
|
|
206
|
+
// is in the result — a sidecar failure shouldn't fail the call.
|
|
207
|
+
try {
|
|
208
|
+
for (const p of paths)
|
|
209
|
+
await writeSidecar(p, { ...meta, images: paths });
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
meta.sidecar_error = err instanceof Error ? err.message : String(err);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
});
|
|
165
216
|
});
|
|
166
217
|
}
|