@chrischall/gemini-mcp 0.8.0 → 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 +11 -2
- package/dist/bundle.js +354 -126
- package/dist/index.js +2 -1
- package/dist/jobs.js +0 -0
- package/dist/tools/generate.js +91 -67
- package/dist/tools/interact.js +65 -50
- package/dist/tools/jobs.js +17 -0
- package/dist/tools/set.js +54 -40
- package/dist/tools/shared.js +73 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "MCP server for Google Gemini image generation and editing — generate and edit images via the Gemini API",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.9.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "Gemini Images",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "Generate and edit images with Google Gemini image models (Nano Banana / Nano Banana Pro) via natural language prompts",
|
|
18
|
-
"version": "0.
|
|
18
|
+
"version": "0.9.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/README.md
CHANGED
|
@@ -26,8 +26,16 @@ The server sends `notifications/progress` heartbeats so hosts that reset their t
|
|
|
26
26
|
progress wait it out. If the host still gives up, the server-side generation usually completes
|
|
27
27
|
anyway: the image is written to the output dir, `gemini_interact` also writes an
|
|
28
28
|
`<image>.json` sidecar recording the `interaction_id`, and `continue_last: true` resumes the
|
|
29
|
-
interaction the lost response belonged to
|
|
30
|
-
|
|
29
|
+
interaction the lost response belonged to.
|
|
30
|
+
|
|
31
|
+
For hosts whose timeout can't be tamed (e.g. Claude Desktop, a fixed ~30s cap that ignores
|
|
32
|
+
progress), two guards make re-issuing safe and unnecessary:
|
|
33
|
+
|
|
34
|
+
- **`async: true`** returns a `job_id` immediately instead of the image, so the call can't
|
|
35
|
+
time out at all; poll `gemini_get_result` with the `job_id` until it's `done`.
|
|
36
|
+
- **`idempotency_key`** makes a repeat call idempotent — a retry with the same key returns the
|
|
37
|
+
recorded result (`reused: true`) instead of billing a second generation. (Even without a key,
|
|
38
|
+
two identical in-flight calls are deduplicated automatically.)
|
|
31
39
|
|
|
32
40
|
## Tools
|
|
33
41
|
|
|
@@ -38,6 +46,7 @@ interaction the lost response belonged to — check the output dir before re-iss
|
|
|
38
46
|
| `gemini_edit_image` | One-off edits or multi-image composition with a text instruction (for a series of edits, use `gemini_interact`) |
|
|
39
47
|
| `gemini_generate_set` | Generate a master image plus N consistent images referencing it |
|
|
40
48
|
| `gemini_interact` | Preferred tool for iterative refinement: multi-turn generation/editing via the Interactions API — chain the returned `interaction_id` via `previous_interaction_id` (or `continue_last: true`) |
|
|
49
|
+
| `gemini_get_result` | Fetch an async generation started with `async: true` by its `job_id` (status `running` → `done` result). Lets a long generation outlive a host's `tools/call` timeout |
|
|
41
50
|
|
|
42
51
|
## Quick Start
|
|
43
52
|
|
package/dist/bundle.js
CHANGED
|
@@ -32606,7 +32606,7 @@ var init_clipboard = __esm({
|
|
|
32606
32606
|
init_dist();
|
|
32607
32607
|
|
|
32608
32608
|
// src/version.ts
|
|
32609
|
-
var VERSION = "0.
|
|
32609
|
+
var VERSION = "0.9.0";
|
|
32610
32610
|
|
|
32611
32611
|
// src/tools/models.ts
|
|
32612
32612
|
init_dist();
|
|
@@ -33121,6 +33121,12 @@ var MODEL_CHOICE_GUIDE = "gemini-3.1-flash-image (Nano Banana 2) is the versatil
|
|
|
33121
33121
|
var timeoutMsSchema = external_exports.number().int().positive().optional().describe(
|
|
33122
33122
|
"Upstream request timeout in ms for this call (default: $GEMINI_TIMEOUT_MS, else 60000 \u2014 or 120000 when image_size is 4K, which routinely runs past 60s)"
|
|
33123
33123
|
);
|
|
33124
|
+
var idempotencyKeySchema = external_exports.string().min(1).optional().describe(
|
|
33125
|
+
"Opaque idempotency key: a repeat call with the same key returns the recorded result (reused: true) instead of billing a new generation. Set it when retrying after a host timeout (-32001) to avoid a duplicate charge."
|
|
33126
|
+
);
|
|
33127
|
+
var asyncSchema = external_exports.boolean().optional().describe(
|
|
33128
|
+
"Run in the background and return a job_id immediately instead of the image, so a long (Pro/4K) generation cannot hit the host tools/call timeout (-32001). Poll gemini_get_result with the job_id to fetch the result (jobs are per-process and expire ~10 min after completion)."
|
|
33129
|
+
);
|
|
33124
33130
|
var sharedImageSchema = {
|
|
33125
33131
|
// Bare id only: the model is interpolated into the URL path
|
|
33126
33132
|
// (`/models/${model}:generateContent`), so slashes/colons/queries must be
|
|
@@ -33134,7 +33140,9 @@ var sharedImageSchema = {
|
|
|
33134
33140
|
thinking_level: external_exports.enum(["minimal", "high"]).optional().describe("Reasoning depth (Gemini 3 models); higher can help complex/structural edits"),
|
|
33135
33141
|
google_search: external_exports.boolean().optional().describe("Ground the image in live Google Search results (current events, weather, data)"),
|
|
33136
33142
|
from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)"),
|
|
33137
|
-
timeout_ms: timeoutMsSchema
|
|
33143
|
+
timeout_ms: timeoutMsSchema,
|
|
33144
|
+
idempotency_key: idempotencyKeySchema,
|
|
33145
|
+
async: asyncSchema
|
|
33138
33146
|
};
|
|
33139
33147
|
async function resolveVideoInput(args) {
|
|
33140
33148
|
if (args.video_url && args.video_path) {
|
|
@@ -33159,16 +33167,28 @@ async function withProgressHeartbeat(extra, message, fn) {
|
|
|
33159
33167
|
const send = extra?.sendNotification;
|
|
33160
33168
|
const env = Number(readEnvVar("GEMINI_HEARTBEAT_MS"));
|
|
33161
33169
|
const intervalMs = Number.isFinite(env) ? env : HEARTBEAT_DEFAULT_MS;
|
|
33170
|
+
const debug = !!readEnvVar("GEMINI_DEBUG");
|
|
33171
|
+
const active = progressToken !== void 0 && !!send && intervalMs > 0;
|
|
33172
|
+
if (debug) {
|
|
33173
|
+
if (active) {
|
|
33174
|
+
console.error(`[gemini-mcp] heartbeat active: progressToken present, interval=${intervalMs}ms \u2014 ${message}`);
|
|
33175
|
+
} else {
|
|
33176
|
+
const reason = progressToken === void 0 ? "no progressToken from host (host cannot extend its tools/call timeout; long calls will hit it)" : !send ? "host provided no sendNotification channel" : "heartbeat disabled (GEMINI_HEARTBEAT_MS=0)";
|
|
33177
|
+
console.error(`[gemini-mcp] heartbeat inactive: ${reason} \u2014 ${message}`);
|
|
33178
|
+
}
|
|
33179
|
+
}
|
|
33162
33180
|
if (progressToken === void 0 || !send || intervalMs <= 0) return fn();
|
|
33163
33181
|
let ticks = 0;
|
|
33164
33182
|
const timer = setInterval(() => {
|
|
33165
33183
|
ticks++;
|
|
33184
|
+
const elapsed = Math.round(ticks * intervalMs / 1e3);
|
|
33185
|
+
if (debug) console.error(`[gemini-mcp] heartbeat: ${message} \u2014 still working (${elapsed}s)`);
|
|
33166
33186
|
void send({
|
|
33167
33187
|
method: "notifications/progress",
|
|
33168
33188
|
params: {
|
|
33169
33189
|
progressToken,
|
|
33170
33190
|
progress: ticks,
|
|
33171
|
-
message: `${message} \u2014 still working (${
|
|
33191
|
+
message: `${message} \u2014 still working (${elapsed}s)`
|
|
33172
33192
|
}
|
|
33173
33193
|
}).catch(() => {
|
|
33174
33194
|
});
|
|
@@ -33180,6 +33200,14 @@ async function withProgressHeartbeat(extra, message, fn) {
|
|
|
33180
33200
|
clearInterval(timer);
|
|
33181
33201
|
}
|
|
33182
33202
|
}
|
|
33203
|
+
function timeoutRiskHint(opts) {
|
|
33204
|
+
const proModel = /(?:^|[-_./])pro(?:[-_./]|$)/i.test(opts.model);
|
|
33205
|
+
const fourK = opts.imageSize === "4K";
|
|
33206
|
+
const multi = (opts.count ?? 1) > 1;
|
|
33207
|
+
if (!proModel && !fourK && !multi) return void 0;
|
|
33208
|
+
const why = [proModel && "Pro model", fourK && "4K output", multi && `count=${opts.count}`].filter(Boolean).join(" + ");
|
|
33209
|
+
return `Slow config (${why}) can exceed a host's tools/call timeout (e.g. Claude Desktop ~30s, error -32001). If the host gives up mid-generation the image is still written to disk (and gemini_interact leaves an <image>.json sidecar with the interaction id) \u2014 no rerun needed, just look in the output dir. For a faster in-band result use a Flash model, drop image_size to 1K/2K, or \u2014 in Claude Code \u2014 set MCP_TOOL_TIMEOUT.`;
|
|
33210
|
+
}
|
|
33183
33211
|
function buildMeta(model, seed, opts) {
|
|
33184
33212
|
const meta3 = { model, seed };
|
|
33185
33213
|
if (opts.aspect_ratio) meta3.aspect_ratio = opts.aspect_ratio;
|
|
@@ -33203,6 +33231,118 @@ async function emit(named, opts, meta3, onWritten) {
|
|
|
33203
33231
|
return textResult({ images, ...meta3 });
|
|
33204
33232
|
}
|
|
33205
33233
|
|
|
33234
|
+
// src/jobs.ts
|
|
33235
|
+
init_dist();
|
|
33236
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
33237
|
+
var JOB_TTL_MS = 6e5;
|
|
33238
|
+
var JOB_MAX = 64;
|
|
33239
|
+
var jobs = /* @__PURE__ */ new Map();
|
|
33240
|
+
var byKey = /* @__PURE__ */ new Map();
|
|
33241
|
+
var runningByFingerprint = /* @__PURE__ */ new Map();
|
|
33242
|
+
function isExpired(entry, now) {
|
|
33243
|
+
return entry.settledAt !== void 0 && now - entry.settledAt > JOB_TTL_MS;
|
|
33244
|
+
}
|
|
33245
|
+
function evict(now) {
|
|
33246
|
+
for (const [id, entry] of jobs) {
|
|
33247
|
+
if (isExpired(entry, now)) remove(id, entry);
|
|
33248
|
+
}
|
|
33249
|
+
if (jobs.size < JOB_MAX) return;
|
|
33250
|
+
const settled = [...jobs.values()].filter((e) => e.settledAt !== void 0).sort((a, b) => (a.settledAt ?? 0) - (b.settledAt ?? 0));
|
|
33251
|
+
for (const e of settled) {
|
|
33252
|
+
if (jobs.size < JOB_MAX) break;
|
|
33253
|
+
remove(e.jobId, e);
|
|
33254
|
+
}
|
|
33255
|
+
}
|
|
33256
|
+
function remove(id, entry) {
|
|
33257
|
+
jobs.delete(id);
|
|
33258
|
+
if (entry.idempotencyKey !== void 0 && byKey.get(entry.idempotencyKey) === id) byKey.delete(entry.idempotencyKey);
|
|
33259
|
+
if (runningByFingerprint.get(entry.fingerprint) === id) runningByFingerprint.delete(entry.fingerprint);
|
|
33260
|
+
}
|
|
33261
|
+
function fingerprintRequest(toolName, parts) {
|
|
33262
|
+
return createHash("sha256").update(`${toolName}\0${JSON.stringify(parts)}`).digest("hex");
|
|
33263
|
+
}
|
|
33264
|
+
function annotateReused(result, jobId) {
|
|
33265
|
+
const content = result.content ?? [];
|
|
33266
|
+
const first = content[0];
|
|
33267
|
+
if (first && first.type === "text") {
|
|
33268
|
+
let obj;
|
|
33269
|
+
try {
|
|
33270
|
+
obj = JSON.parse(first.text);
|
|
33271
|
+
} catch {
|
|
33272
|
+
obj = {};
|
|
33273
|
+
}
|
|
33274
|
+
obj.reused = true;
|
|
33275
|
+
obj.reused_job_id = jobId;
|
|
33276
|
+
return { ...result, content: [{ ...first, text: JSON.stringify(obj, null, 2) }, ...content.slice(1)] };
|
|
33277
|
+
}
|
|
33278
|
+
return {
|
|
33279
|
+
...result,
|
|
33280
|
+
content: [{ type: "text", text: JSON.stringify({ reused: true, reused_job_id: jobId }, null, 2) }, ...content]
|
|
33281
|
+
};
|
|
33282
|
+
}
|
|
33283
|
+
function jobHandle(jobId, status) {
|
|
33284
|
+
return textResult({
|
|
33285
|
+
job_id: jobId,
|
|
33286
|
+
status,
|
|
33287
|
+
hint: `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`
|
|
33288
|
+
});
|
|
33289
|
+
}
|
|
33290
|
+
async function dispatch(opts, work) {
|
|
33291
|
+
const { toolName, fingerprint, idempotencyKey, async } = opts;
|
|
33292
|
+
const now = Date.now();
|
|
33293
|
+
let hit;
|
|
33294
|
+
if (idempotencyKey !== void 0) {
|
|
33295
|
+
const id = byKey.get(idempotencyKey);
|
|
33296
|
+
const entry2 = id ? jobs.get(id) : void 0;
|
|
33297
|
+
if (entry2 && entry2.status !== "failed" && !isExpired(entry2, now)) hit = entry2;
|
|
33298
|
+
} else {
|
|
33299
|
+
const id = runningByFingerprint.get(fingerprint);
|
|
33300
|
+
const entry2 = id ? jobs.get(id) : void 0;
|
|
33301
|
+
if (entry2 && entry2.status === "running") hit = entry2;
|
|
33302
|
+
}
|
|
33303
|
+
if (hit) {
|
|
33304
|
+
if (async) return jobHandle(hit.jobId, hit.status);
|
|
33305
|
+
return annotateReused(await hit.promise, hit.jobId);
|
|
33306
|
+
}
|
|
33307
|
+
const jobId = randomUUID();
|
|
33308
|
+
evict(now);
|
|
33309
|
+
const promise2 = work();
|
|
33310
|
+
const entry = { jobId, toolName, fingerprint, idempotencyKey, status: "running", promise: promise2, createdAt: now };
|
|
33311
|
+
jobs.set(jobId, entry);
|
|
33312
|
+
if (idempotencyKey !== void 0) byKey.set(idempotencyKey, jobId);
|
|
33313
|
+
runningByFingerprint.set(fingerprint, jobId);
|
|
33314
|
+
promise2.then(
|
|
33315
|
+
(res) => {
|
|
33316
|
+
entry.status = "done";
|
|
33317
|
+
entry.result = res;
|
|
33318
|
+
entry.settledAt = Date.now();
|
|
33319
|
+
},
|
|
33320
|
+
(err) => {
|
|
33321
|
+
entry.status = "failed";
|
|
33322
|
+
entry.error = { message: err instanceof Error ? err.message : String(err), hint: err?.hint };
|
|
33323
|
+
entry.settledAt = Date.now();
|
|
33324
|
+
}
|
|
33325
|
+
).finally(() => {
|
|
33326
|
+
if (runningByFingerprint.get(fingerprint) === jobId) runningByFingerprint.delete(fingerprint);
|
|
33327
|
+
});
|
|
33328
|
+
if (async) return jobHandle(jobId, "running");
|
|
33329
|
+
return promise2;
|
|
33330
|
+
}
|
|
33331
|
+
function getJobResult(jobId) {
|
|
33332
|
+
const entry = jobs.get(jobId);
|
|
33333
|
+
if (!entry) {
|
|
33334
|
+
throw new McpToolError(`No job "${jobId}" \u2014 unknown or expired.`, {
|
|
33335
|
+
hint: "Jobs are per-process and evicted ~10 min after completion (or on server restart). If the generation likely finished, check the output dir / <image>.json sidecar instead of polling."
|
|
33336
|
+
});
|
|
33337
|
+
}
|
|
33338
|
+
if (entry.status === "running") return jobHandle(jobId, "running");
|
|
33339
|
+
if (entry.status === "failed") {
|
|
33340
|
+
throw new McpToolError(entry.error?.message ?? "Job failed.", entry.error?.hint ? { hint: entry.error.hint } : void 0);
|
|
33341
|
+
}
|
|
33342
|
+
if (!entry.result) throw new McpToolError(`Job "${jobId}" completed without a recorded result.`);
|
|
33343
|
+
return entry.result;
|
|
33344
|
+
}
|
|
33345
|
+
|
|
33206
33346
|
// src/tools/_confirm.ts
|
|
33207
33347
|
init_dist();
|
|
33208
33348
|
function previewUnlessConfirmed(confirm, action, method, path, body) {
|
|
@@ -33251,41 +33391,59 @@ function registerGenerateTools(server) {
|
|
|
33251
33391
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images, args.video_path);
|
|
33252
33392
|
if (gate) return gate;
|
|
33253
33393
|
const count = args.count ?? 1;
|
|
33254
|
-
const seed = pickSeed(args.seed);
|
|
33255
33394
|
const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
33256
|
-
const
|
|
33257
|
-
|
|
33258
|
-
|
|
33259
|
-
|
|
33260
|
-
|
|
33261
|
-
|
|
33262
|
-
|
|
33263
|
-
|
|
33264
|
-
|
|
33265
|
-
|
|
33266
|
-
|
|
33267
|
-
|
|
33268
|
-
|
|
33269
|
-
|
|
33270
|
-
|
|
33271
|
-
|
|
33272
|
-
|
|
33273
|
-
|
|
33274
|
-
|
|
33275
|
-
|
|
33276
|
-
|
|
33277
|
-
|
|
33278
|
-
|
|
33279
|
-
|
|
33280
|
-
|
|
33281
|
-
|
|
33395
|
+
const fingerprint = fingerprintRequest("gemini_generate_image", {
|
|
33396
|
+
model,
|
|
33397
|
+
prompt: args.prompt,
|
|
33398
|
+
aspect_ratio: args.aspect_ratio,
|
|
33399
|
+
image_size: args.image_size,
|
|
33400
|
+
thinking_level: args.thinking_level,
|
|
33401
|
+
google_search: args.google_search,
|
|
33402
|
+
count,
|
|
33403
|
+
images: args.images,
|
|
33404
|
+
images_base64: args.images_base64,
|
|
33405
|
+
from_clipboard: args.from_clipboard,
|
|
33406
|
+
video_url: args.video_url,
|
|
33407
|
+
video_path: args.video_path
|
|
33408
|
+
});
|
|
33409
|
+
return dispatch({ toolName: "gemini_generate_image", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
33410
|
+
const seed = pickSeed(args.seed);
|
|
33411
|
+
const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
|
|
33412
|
+
const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
33413
|
+
const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args));
|
|
33414
|
+
const named = [];
|
|
33415
|
+
let capturedText;
|
|
33416
|
+
let capturedGrounding;
|
|
33417
|
+
await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : "image"} (${model})`, async () => {
|
|
33418
|
+
for (let i = 0; i < count; i++) {
|
|
33419
|
+
const result = await client.generate({
|
|
33420
|
+
prompt: args.prompt,
|
|
33421
|
+
images: refInputs.length > 0 ? refInputs : void 0,
|
|
33422
|
+
model: args.model,
|
|
33423
|
+
aspectRatio: args.aspect_ratio,
|
|
33424
|
+
imageSize: args.image_size,
|
|
33425
|
+
seed: seed + i,
|
|
33426
|
+
thinkingLevel: args.thinking_level,
|
|
33427
|
+
googleSearch: args.google_search,
|
|
33428
|
+
videoUrl: video.videoUrl,
|
|
33429
|
+
videoMimeType: video.videoMimeType,
|
|
33430
|
+
timeoutMs: args.timeout_ms
|
|
33431
|
+
});
|
|
33432
|
+
const { images: [img], text } = result;
|
|
33433
|
+
if (text && !capturedText) capturedText = text;
|
|
33434
|
+
if (result.grounding && !capturedGrounding) capturedGrounding = result.grounding;
|
|
33435
|
+
named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, "0")}` });
|
|
33436
|
+
}
|
|
33437
|
+
});
|
|
33438
|
+
const meta3 = buildMeta(model, seed, args);
|
|
33439
|
+
if (capturedText) meta3.text = capturedText;
|
|
33440
|
+
if (capturedGrounding) meta3.grounding = capturedGrounding;
|
|
33441
|
+
if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
|
|
33442
|
+
meta3.hint = REFINE_HINT;
|
|
33443
|
+
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count });
|
|
33444
|
+
if (risk) meta3.timeout_risk = risk;
|
|
33445
|
+
return emit(named, args, meta3);
|
|
33282
33446
|
});
|
|
33283
|
-
const meta3 = buildMeta(model, seed, args);
|
|
33284
|
-
if (capturedText) meta3.text = capturedText;
|
|
33285
|
-
if (capturedGrounding) meta3.grounding = capturedGrounding;
|
|
33286
|
-
if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
|
|
33287
|
-
meta3.hint = REFINE_HINT;
|
|
33288
|
-
return emit(named, args, meta3);
|
|
33289
33447
|
}
|
|
33290
33448
|
);
|
|
33291
33449
|
server.registerTool(
|
|
@@ -33310,27 +33468,42 @@ function registerGenerateTools(server) {
|
|
|
33310
33468
|
}
|
|
33311
33469
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
|
|
33312
33470
|
if (gate) return gate;
|
|
33313
|
-
const seed = pickSeed(args.seed);
|
|
33314
33471
|
const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
33315
|
-
const
|
|
33316
|
-
|
|
33317
|
-
const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
|
|
33472
|
+
const fingerprint = fingerprintRequest("gemini_edit_image", {
|
|
33473
|
+
model,
|
|
33318
33474
|
prompt: args.prompt,
|
|
33319
|
-
|
|
33320
|
-
|
|
33321
|
-
|
|
33322
|
-
|
|
33323
|
-
|
|
33324
|
-
|
|
33325
|
-
|
|
33326
|
-
|
|
33327
|
-
})
|
|
33328
|
-
|
|
33329
|
-
|
|
33330
|
-
|
|
33331
|
-
|
|
33332
|
-
|
|
33333
|
-
|
|
33475
|
+
aspect_ratio: args.aspect_ratio,
|
|
33476
|
+
image_size: args.image_size,
|
|
33477
|
+
thinking_level: args.thinking_level,
|
|
33478
|
+
google_search: args.google_search,
|
|
33479
|
+
images: args.images,
|
|
33480
|
+
images_base64: args.images_base64,
|
|
33481
|
+
from_clipboard: args.from_clipboard
|
|
33482
|
+
});
|
|
33483
|
+
return dispatch({ toolName: "gemini_edit_image", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
33484
|
+
const seed = pickSeed(args.seed);
|
|
33485
|
+
const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
|
|
33486
|
+
const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
33487
|
+
const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
|
|
33488
|
+
prompt: args.prompt,
|
|
33489
|
+
images: inputs,
|
|
33490
|
+
model: args.model,
|
|
33491
|
+
aspectRatio: args.aspect_ratio,
|
|
33492
|
+
imageSize: args.image_size,
|
|
33493
|
+
seed,
|
|
33494
|
+
thinkingLevel: args.thinking_level,
|
|
33495
|
+
googleSearch: args.google_search,
|
|
33496
|
+
timeoutMs: args.timeout_ms
|
|
33497
|
+
}));
|
|
33498
|
+
const { images: [img], text } = result;
|
|
33499
|
+
const meta3 = buildMeta(model, seed, args);
|
|
33500
|
+
if (text) meta3.text = text;
|
|
33501
|
+
if (result.grounding) meta3.grounding = result.grounding;
|
|
33502
|
+
meta3.hint = REFINE_HINT;
|
|
33503
|
+
const risk = timeoutRiskHint({ model, imageSize: args.image_size });
|
|
33504
|
+
if (risk) meta3.timeout_risk = risk;
|
|
33505
|
+
return emit([{ image: img, base: slug }], args, meta3);
|
|
33506
|
+
});
|
|
33334
33507
|
}
|
|
33335
33508
|
);
|
|
33336
33509
|
}
|
|
@@ -33362,43 +33535,61 @@ function registerSetTools(server) {
|
|
|
33362
33535
|
}
|
|
33363
33536
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local master image input(s) to the Gemini API", "/v1beta/models/{model}:generateContent", args.master_images);
|
|
33364
33537
|
if (gate) return gate;
|
|
33365
|
-
const seed = pickSeed(args.seed);
|
|
33366
33538
|
const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
33367
|
-
const
|
|
33368
|
-
|
|
33369
|
-
|
|
33370
|
-
|
|
33371
|
-
|
|
33372
|
-
|
|
33373
|
-
|
|
33374
|
-
|
|
33375
|
-
|
|
33376
|
-
|
|
33377
|
-
|
|
33378
|
-
|
|
33379
|
-
|
|
33380
|
-
|
|
33381
|
-
|
|
33382
|
-
|
|
33383
|
-
|
|
33384
|
-
|
|
33385
|
-
|
|
33386
|
-
|
|
33387
|
-
|
|
33539
|
+
const fingerprint = fingerprintRequest("gemini_generate_set", {
|
|
33540
|
+
model,
|
|
33541
|
+
master_prompt: args.master_prompt,
|
|
33542
|
+
scenes: args.scenes,
|
|
33543
|
+
count: args.count,
|
|
33544
|
+
reference_mode: args.reference_mode,
|
|
33545
|
+
aspect_ratio: args.aspect_ratio,
|
|
33546
|
+
image_size: args.image_size,
|
|
33547
|
+
thinking_level: args.thinking_level,
|
|
33548
|
+
google_search: args.google_search,
|
|
33549
|
+
master_images: args.master_images,
|
|
33550
|
+
master_images_base64: args.master_images_base64,
|
|
33551
|
+
from_clipboard: args.from_clipboard
|
|
33552
|
+
});
|
|
33553
|
+
return dispatch({ toolName: "gemini_generate_set", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
33554
|
+
const seed = pickSeed(args.seed);
|
|
33555
|
+
const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
|
|
33556
|
+
const slug = args.basename ? baseName(args.basename) : slugify2(args.master_prompt);
|
|
33557
|
+
const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
|
|
33558
|
+
const named = [];
|
|
33559
|
+
const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
|
|
33560
|
+
const result = await client.generate({
|
|
33561
|
+
prompt: args.master_prompt,
|
|
33562
|
+
images: masterRefInputs.length > 0 ? masterRefInputs : void 0,
|
|
33563
|
+
seed,
|
|
33564
|
+
...cfg
|
|
33565
|
+
});
|
|
33566
|
+
const master = result.images[0];
|
|
33567
|
+
named.push({ image: master, base: `${slug}-master` });
|
|
33568
|
+
const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
|
|
33569
|
+
const mode = args.reference_mode ?? "master";
|
|
33570
|
+
if (mode === "chain") {
|
|
33571
|
+
let ref = master;
|
|
33572
|
+
for (let i = 0; i < scenePrompts.length; i++) {
|
|
33573
|
+
const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
|
|
33574
|
+
named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` });
|
|
33575
|
+
ref = img;
|
|
33576
|
+
}
|
|
33577
|
+
} else {
|
|
33578
|
+
const scenes = await Promise.all(
|
|
33579
|
+
scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
|
|
33580
|
+
);
|
|
33581
|
+
scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` }));
|
|
33388
33582
|
}
|
|
33389
|
-
|
|
33390
|
-
|
|
33391
|
-
|
|
33392
|
-
|
|
33393
|
-
|
|
33394
|
-
|
|
33395
|
-
|
|
33583
|
+
return result;
|
|
33584
|
+
});
|
|
33585
|
+
const masterText = masterResult.text;
|
|
33586
|
+
const meta3 = buildMeta(model, seed, args);
|
|
33587
|
+
if (masterText) meta3.text = masterText;
|
|
33588
|
+
if (masterResult.grounding) meta3.grounding = masterResult.grounding;
|
|
33589
|
+
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length });
|
|
33590
|
+
if (risk) meta3.timeout_risk = risk;
|
|
33591
|
+
return emit(named, args, meta3);
|
|
33396
33592
|
});
|
|
33397
|
-
const masterText = masterResult.text;
|
|
33398
|
-
const meta3 = buildMeta(model, seed, args);
|
|
33399
|
-
if (masterText) meta3.text = masterText;
|
|
33400
|
-
if (masterResult.grounding) meta3.grounding = masterResult.grounding;
|
|
33401
|
-
return emit(named, args, meta3);
|
|
33402
33593
|
}
|
|
33403
33594
|
);
|
|
33404
33595
|
}
|
|
@@ -33448,6 +33639,8 @@ function registerInteractTools(server) {
|
|
|
33448
33639
|
video_url: external_exports.string().url().optional().describe("Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video\u2192image; use a Flash model e.g. gemini-3.1-flash-image)"),
|
|
33449
33640
|
video_path: videoPathSchema,
|
|
33450
33641
|
timeout_ms: timeoutMsSchema,
|
|
33642
|
+
idempotency_key: idempotencyKeySchema,
|
|
33643
|
+
async: asyncSchema,
|
|
33451
33644
|
from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)"),
|
|
33452
33645
|
confirm: schemaConfirm
|
|
33453
33646
|
}
|
|
@@ -33469,52 +33662,87 @@ function registerInteractTools(server) {
|
|
|
33469
33662
|
}
|
|
33470
33663
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local file input(s) to the Gemini Interactions API", "/v1beta/interactions", images, args.video_path);
|
|
33471
33664
|
if (gate) return gate;
|
|
33472
|
-
const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
33473
|
-
const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args));
|
|
33474
|
-
const r = await withProgressHeartbeat(extra, "Generating image (Gemini Interactions API)", () => client.interact({
|
|
33475
|
-
input: args.input,
|
|
33476
|
-
images: inputs.length ? inputs : void 0,
|
|
33477
|
-
model: args.model,
|
|
33478
|
-
aspectRatio: args.aspect_ratio,
|
|
33479
|
-
imageSize: args.image_size,
|
|
33480
|
-
thinkingLevel: args.thinking_level,
|
|
33481
|
-
previousInteractionId,
|
|
33482
|
-
googleSearch: args.google_search,
|
|
33483
|
-
searchTypes: args.search_types,
|
|
33484
|
-
videoUrl: video.videoUrl,
|
|
33485
|
-
videoMimeType: video.videoMimeType,
|
|
33486
|
-
timeoutMs: args.timeout_ms
|
|
33487
|
-
}));
|
|
33488
|
-
lastInteractionId = r.id;
|
|
33489
33665
|
const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
33490
|
-
const
|
|
33491
|
-
|
|
33492
|
-
|
|
33493
|
-
|
|
33494
|
-
|
|
33495
|
-
|
|
33496
|
-
|
|
33497
|
-
|
|
33498
|
-
|
|
33499
|
-
|
|
33500
|
-
|
|
33501
|
-
|
|
33502
|
-
|
|
33503
|
-
|
|
33504
|
-
|
|
33505
|
-
|
|
33506
|
-
|
|
33507
|
-
|
|
33508
|
-
|
|
33666
|
+
const fingerprint = fingerprintRequest("gemini_interact", {
|
|
33667
|
+
model,
|
|
33668
|
+
input: args.input,
|
|
33669
|
+
aspect_ratio: args.aspect_ratio,
|
|
33670
|
+
image_size: args.image_size,
|
|
33671
|
+
thinking_level: args.thinking_level,
|
|
33672
|
+
previous_interaction_id: previousInteractionId,
|
|
33673
|
+
google_search: args.google_search,
|
|
33674
|
+
search_types: args.search_types,
|
|
33675
|
+
images,
|
|
33676
|
+
images_base64: args.images_base64,
|
|
33677
|
+
from_clipboard: args.from_clipboard,
|
|
33678
|
+
video_url: args.video_url,
|
|
33679
|
+
video_path: args.video_path
|
|
33680
|
+
});
|
|
33681
|
+
return dispatch({ toolName: "gemini_interact", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
33682
|
+
const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
33683
|
+
const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args));
|
|
33684
|
+
const r = await withProgressHeartbeat(extra, "Generating image (Gemini Interactions API)", () => client.interact({
|
|
33685
|
+
input: args.input,
|
|
33686
|
+
images: inputs.length ? inputs : void 0,
|
|
33687
|
+
model: args.model,
|
|
33688
|
+
aspectRatio: args.aspect_ratio,
|
|
33689
|
+
imageSize: args.image_size,
|
|
33690
|
+
thinkingLevel: args.thinking_level,
|
|
33691
|
+
previousInteractionId,
|
|
33692
|
+
googleSearch: args.google_search,
|
|
33693
|
+
searchTypes: args.search_types,
|
|
33694
|
+
videoUrl: video.videoUrl,
|
|
33695
|
+
videoMimeType: video.videoMimeType,
|
|
33696
|
+
timeoutMs: args.timeout_ms
|
|
33697
|
+
}));
|
|
33698
|
+
lastInteractionId = r.id;
|
|
33699
|
+
const meta3 = { model, interaction_id: r.id };
|
|
33700
|
+
if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
|
|
33701
|
+
if (dropped.length) meta3.dropped_previous_output = dropped;
|
|
33702
|
+
meta3.hint = `To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true) \u2014 do NOT re-attach the returned image as an input; the interaction already contains it.`;
|
|
33703
|
+
if (r.text) meta3.text = r.text;
|
|
33704
|
+
if (r.grounding) meta3.grounding = r.grounding;
|
|
33705
|
+
if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
|
|
33706
|
+
const risk = timeoutRiskHint({ model, imageSize: args.image_size });
|
|
33707
|
+
if (risk) meta3.timeout_risk = risk;
|
|
33708
|
+
const slug = args.filename ? baseName(args.filename) : slugify2(args.input);
|
|
33709
|
+
const named = r.images.map((image, i) => ({
|
|
33710
|
+
image,
|
|
33711
|
+
base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
|
|
33712
|
+
}));
|
|
33713
|
+
return emit(named, args, meta3, async (paths) => {
|
|
33714
|
+
for (const p of paths) writtenOutputs.add(p);
|
|
33715
|
+
try {
|
|
33716
|
+
for (const p of paths) await writeSidecar(p, { ...meta3, images: paths });
|
|
33717
|
+
} catch (err) {
|
|
33718
|
+
meta3.sidecar_error = err instanceof Error ? err.message : String(err);
|
|
33719
|
+
}
|
|
33720
|
+
});
|
|
33509
33721
|
});
|
|
33510
33722
|
}
|
|
33511
33723
|
);
|
|
33512
33724
|
}
|
|
33513
33725
|
|
|
33726
|
+
// src/tools/jobs.ts
|
|
33727
|
+
init_zod();
|
|
33728
|
+
function registerJobTools(server) {
|
|
33729
|
+
server.registerTool(
|
|
33730
|
+
"gemini_get_result",
|
|
33731
|
+
{
|
|
33732
|
+
description: 'Retrieve an async generation started with `async: true`. Pass the returned `job_id`: while running it reports status "running"; on completion it returns the normal result (image paths / inline images + meta); on failure it raises the recorded error. Jobs are per-process and expire ~10 min after completion \u2014 if a job id is unknown, check the output dir / <image>.json sidecar.',
|
|
33733
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
33734
|
+
inputSchema: {
|
|
33735
|
+
job_id: external_exports.string().min(1).describe("The job_id returned by a generation tool called with async: true")
|
|
33736
|
+
}
|
|
33737
|
+
},
|
|
33738
|
+
async (args) => getJobResult(args.job_id)
|
|
33739
|
+
);
|
|
33740
|
+
}
|
|
33741
|
+
|
|
33514
33742
|
// src/index.ts
|
|
33515
33743
|
await runMcp({
|
|
33516
33744
|
name: "gemini-mcp",
|
|
33517
33745
|
version: VERSION,
|
|
33518
33746
|
banner: "[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.",
|
|
33519
|
-
tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools]
|
|
33747
|
+
tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerJobTools]
|
|
33520
33748
|
});
|
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
|
package/dist/tools/generate.js
CHANGED
|
@@ -3,7 +3,8 @@ 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, withProgressHeartbeat } from './shared.js';
|
|
6
|
+
import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat } from './shared.js';
|
|
7
|
+
import { dispatch, fingerprintRequest } from '../jobs.js';
|
|
7
8
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
8
9
|
const GENERATE_ENDPOINT = '/v1beta/models/{model}:generateContent';
|
|
9
10
|
const SEND_LOCAL_ACTION = 'Send local file input(s) to the Gemini API';
|
|
@@ -35,52 +36,65 @@ export function registerGenerateTools(server) {
|
|
|
35
36
|
if (gate)
|
|
36
37
|
return gate;
|
|
37
38
|
const count = args.count ?? 1;
|
|
38
|
-
const seed = pickSeed(args.seed);
|
|
39
39
|
const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
}
|
|
84
|
+
});
|
|
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);
|
|
74
97
|
});
|
|
75
|
-
const meta = buildMeta(model, seed, args);
|
|
76
|
-
if (capturedText)
|
|
77
|
-
meta.text = capturedText;
|
|
78
|
-
if (capturedGrounding)
|
|
79
|
-
meta.grounding = capturedGrounding;
|
|
80
|
-
if (video.videoFileMeta)
|
|
81
|
-
meta.video_file = video.videoFileMeta;
|
|
82
|
-
meta.hint = REFINE_HINT;
|
|
83
|
-
return emit(named, args, meta);
|
|
84
98
|
});
|
|
85
99
|
server.registerTool('gemini_edit_image', {
|
|
86
100
|
description: 'Edit or compose images: provide one or more input images (paths or base64), plus a text instruction. ' +
|
|
@@ -107,28 +121,38 @@ export function registerGenerateTools(server) {
|
|
|
107
121
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
|
|
108
122
|
if (gate)
|
|
109
123
|
return gate;
|
|
110
|
-
const seed = pickSeed(args.seed);
|
|
111
124
|
const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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);
|
|
156
|
+
});
|
|
133
157
|
});
|
|
134
158
|
}
|
package/dist/tools/interact.js
CHANGED
|
@@ -3,7 +3,8 @@ 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, resolveImagePath, writeSidecar } from '../images.js';
|
|
6
|
-
import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, withProgressHeartbeat } from './shared.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';
|
|
7
8
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
8
9
|
// The most recent interaction id this server process created — what
|
|
9
10
|
// `continue_last: true` resumes. In-memory only: an MCP server lives for the
|
|
@@ -118,6 +119,8 @@ export function registerInteractTools(server) {
|
|
|
118
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)'),
|
|
119
120
|
video_path: videoPathSchema,
|
|
120
121
|
timeout_ms: timeoutMsSchema,
|
|
122
|
+
idempotency_key: idempotencyKeySchema,
|
|
123
|
+
async: asyncSchema,
|
|
121
124
|
from_clipboard: z
|
|
122
125
|
.boolean()
|
|
123
126
|
.optional()
|
|
@@ -146,57 +149,69 @@ export function registerInteractTools(server) {
|
|
|
146
149
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Send local file input(s) to the Gemini Interactions API', '/v1beta/interactions', images, args.video_path);
|
|
147
150
|
if (gate)
|
|
148
151
|
return gate;
|
|
149
|
-
const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
150
|
-
const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args));
|
|
151
|
-
const r = await withProgressHeartbeat(extra, 'Generating image (Gemini Interactions API)', () => client.interact({
|
|
152
|
-
input: args.input,
|
|
153
|
-
images: inputs.length ? inputs : undefined,
|
|
154
|
-
model: args.model,
|
|
155
|
-
aspectRatio: args.aspect_ratio,
|
|
156
|
-
imageSize: args.image_size,
|
|
157
|
-
thinkingLevel: args.thinking_level,
|
|
158
|
-
previousInteractionId,
|
|
159
|
-
googleSearch: args.google_search,
|
|
160
|
-
searchTypes: args.search_types,
|
|
161
|
-
videoUrl: video.videoUrl,
|
|
162
|
-
videoMimeType: video.videoMimeType,
|
|
163
|
-
timeoutMs: args.timeout_ms,
|
|
164
|
-
}));
|
|
165
|
-
lastInteractionId = r.id;
|
|
166
152
|
const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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) => {
|
|
194
202
|
for (const p of paths)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
+
});
|
|
200
215
|
});
|
|
201
216
|
});
|
|
202
217
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getJobResult } from '../jobs.js';
|
|
3
|
+
/**
|
|
4
|
+
* Poll tool for the async generation pattern (issue #52). A generation tool
|
|
5
|
+
* called with `async: true` returns a `job_id` immediately (instead of blocking
|
|
6
|
+
* past a host's tools/call timeout); this tool retrieves that job's status and,
|
|
7
|
+
* once complete, its normal result payload.
|
|
8
|
+
*/
|
|
9
|
+
export function registerJobTools(server) {
|
|
10
|
+
server.registerTool('gemini_get_result', {
|
|
11
|
+
description: 'Retrieve an async generation started with `async: true`. Pass the returned `job_id`: while running it reports status "running"; on completion it returns the normal result (image paths / inline images + meta); on failure it raises the recorded error. Jobs are per-process and expire ~10 min after completion — if a job id is unknown, check the output dir / <image>.json sidecar.',
|
|
12
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
13
|
+
inputSchema: {
|
|
14
|
+
job_id: z.string().min(1).describe('The job_id returned by a generation tool called with async: true'),
|
|
15
|
+
},
|
|
16
|
+
}, async (args) => getJobResult(args.job_id));
|
|
17
|
+
}
|
package/dist/tools/set.js
CHANGED
|
@@ -3,7 +3,8 @@ 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, withProgressHeartbeat } from './shared.js';
|
|
6
|
+
import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat } from './shared.js';
|
|
7
|
+
import { dispatch, fingerprintRequest } from '../jobs.js';
|
|
7
8
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
8
9
|
export function registerSetTools(server) {
|
|
9
10
|
server.registerTool('gemini_generate_set', {
|
|
@@ -30,47 +31,60 @@ export function registerSetTools(server) {
|
|
|
30
31
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Send local master image input(s) to the Gemini API', '/v1beta/models/{model}:generateContent', args.master_images);
|
|
31
32
|
if (gate)
|
|
32
33
|
return gate;
|
|
33
|
-
const seed = pickSeed(args.seed);
|
|
34
34
|
const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
});
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
35
|
+
const fingerprint = fingerprintRequest('gemini_generate_set', {
|
|
36
|
+
model, master_prompt: args.master_prompt, scenes: args.scenes, count: args.count,
|
|
37
|
+
reference_mode: args.reference_mode, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
38
|
+
thinking_level: args.thinking_level, google_search: args.google_search,
|
|
39
|
+
master_images: args.master_images, master_images_base64: args.master_images_base64, from_clipboard: args.from_clipboard,
|
|
40
|
+
});
|
|
41
|
+
return dispatch({ toolName: 'gemini_generate_set', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
42
|
+
const seed = pickSeed(args.seed);
|
|
43
|
+
const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
|
|
44
|
+
const slug = args.basename ? baseName(args.basename) : slugify(args.master_prompt);
|
|
45
|
+
// 1. master (optionally seeded from reference images)
|
|
46
|
+
const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
|
|
47
|
+
const named = [];
|
|
48
|
+
const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
|
|
49
|
+
const result = await client.generate({
|
|
50
|
+
prompt: args.master_prompt,
|
|
51
|
+
images: masterRefInputs.length > 0 ? masterRefInputs : undefined,
|
|
52
|
+
seed,
|
|
53
|
+
...cfg,
|
|
54
|
+
});
|
|
55
|
+
const master = result.images[0];
|
|
56
|
+
named.push({ image: master, base: `${slug}-master` });
|
|
57
|
+
// Only the master's text is surfaced (below). Per-scene text is intentionally
|
|
58
|
+
// dropped — a set returns one combined result and merging N captions is noise.
|
|
59
|
+
// 2. scene prompts (explicit, or N repeats of master_prompt for variations)
|
|
60
|
+
const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
|
|
61
|
+
const mode = args.reference_mode ?? 'master';
|
|
62
|
+
if (mode === 'chain') {
|
|
63
|
+
let ref = master;
|
|
64
|
+
for (let i = 0; i < scenePrompts.length; i++) {
|
|
65
|
+
const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
|
|
66
|
+
named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` });
|
|
67
|
+
ref = img;
|
|
68
|
+
}
|
|
60
69
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
else {
|
|
71
|
+
const scenes = await Promise.all(scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0])));
|
|
72
|
+
scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` }));
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
});
|
|
76
|
+
const masterText = masterResult.text;
|
|
77
|
+
const meta = buildMeta(model, seed, args);
|
|
78
|
+
if (masterText)
|
|
79
|
+
meta.text = masterText;
|
|
80
|
+
if (masterResult.grounding)
|
|
81
|
+
meta.grounding = masterResult.grounding;
|
|
82
|
+
// A set is master + N scenes in one tools/call — the most timeout-prone
|
|
83
|
+
// tool. Effective image count drives the risk hint.
|
|
84
|
+
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length });
|
|
85
|
+
if (risk)
|
|
86
|
+
meta.timeout_risk = risk;
|
|
87
|
+
return emit(named, args, meta);
|
|
67
88
|
});
|
|
68
|
-
const masterText = masterResult.text;
|
|
69
|
-
const meta = buildMeta(model, seed, args);
|
|
70
|
-
if (masterText)
|
|
71
|
-
meta.text = masterText;
|
|
72
|
-
if (masterResult.grounding)
|
|
73
|
-
meta.grounding = masterResult.grounding;
|
|
74
|
-
return emit(named, args, meta);
|
|
75
89
|
});
|
|
76
90
|
}
|
package/dist/tools/shared.js
CHANGED
|
@@ -26,6 +26,26 @@ export const timeoutMsSchema = z
|
|
|
26
26
|
.positive()
|
|
27
27
|
.optional()
|
|
28
28
|
.describe('Upstream request timeout in ms for this call (default: $GEMINI_TIMEOUT_MS, else 60000 — or 120000 when image_size is 4K, which routinely runs past 60s)');
|
|
29
|
+
/**
|
|
30
|
+
* Idempotency key shared by every generation tool. A repeat call carrying the
|
|
31
|
+
* same key returns the recorded result (`reused: true`) instead of billing a
|
|
32
|
+
* fresh generation — the fix for a host timeout (`-32001`) prompting a blind
|
|
33
|
+
* re-issue of a call the server actually completed.
|
|
34
|
+
*/
|
|
35
|
+
export const idempotencyKeySchema = z
|
|
36
|
+
.string()
|
|
37
|
+
.min(1)
|
|
38
|
+
.optional()
|
|
39
|
+
.describe('Opaque idempotency key: a repeat call with the same key returns the recorded result (reused: true) instead of billing a new generation. Set it when retrying after a host timeout (-32001) to avoid a duplicate charge.');
|
|
40
|
+
/**
|
|
41
|
+
* Async escape hatch shared by every generation tool: return a `job_id`
|
|
42
|
+
* immediately so a long (Pro/4K) generation can't hit a host's fixed tools/call
|
|
43
|
+
* timeout (`-32001`). The caller polls `gemini_get_result`.
|
|
44
|
+
*/
|
|
45
|
+
export const asyncSchema = z
|
|
46
|
+
.boolean()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe('Run in the background and return a job_id immediately instead of the image, so a long (Pro/4K) generation cannot hit the host tools/call timeout (-32001). Poll gemini_get_result with the job_id to fetch the result (jobs are per-process and expire ~10 min after completion).');
|
|
29
49
|
/**
|
|
30
50
|
* The model/aspect/size/output fields every generation tool shares. Defined once
|
|
31
51
|
* here so descriptions can't drift between `generate.ts` and `set.ts`. Spread
|
|
@@ -49,6 +69,8 @@ export const sharedImageSchema = {
|
|
|
49
69
|
google_search: z.boolean().optional().describe('Ground the image in live Google Search results (current events, weather, data)'),
|
|
50
70
|
from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)'),
|
|
51
71
|
timeout_ms: timeoutMsSchema,
|
|
72
|
+
idempotency_key: idempotencyKeySchema,
|
|
73
|
+
async: asyncSchema,
|
|
52
74
|
};
|
|
53
75
|
/**
|
|
54
76
|
* Turn the `video_url` / `video_path` tool args into the client's video opts.
|
|
@@ -99,17 +121,41 @@ export async function withProgressHeartbeat(extra, message, fn) {
|
|
|
99
121
|
const send = extra?.sendNotification;
|
|
100
122
|
const env = Number(readEnvVar('GEMINI_HEARTBEAT_MS'));
|
|
101
123
|
const intervalMs = Number.isFinite(env) ? env : HEARTBEAT_DEFAULT_MS;
|
|
124
|
+
const debug = !!readEnvVar('GEMINI_DEBUG');
|
|
125
|
+
const active = progressToken !== undefined && !!send && intervalMs > 0;
|
|
126
|
+
// Diagnostics (opt-in via GEMINI_DEBUG). Goes to stderr — the only channel
|
|
127
|
+
// safe under the stdio transport, and where the host surfaces it (Claude
|
|
128
|
+
// Desktop: ~/Library/Logs/Claude/mcp-server-*.log). The active/inactive line
|
|
129
|
+
// reveals whether the host even asked for progress: a missing progressToken
|
|
130
|
+
// means the host cannot be kept alive by our heartbeat, so a long call will
|
|
131
|
+
// hit the host's own tools/call timeout no matter what we send.
|
|
132
|
+
if (debug) {
|
|
133
|
+
if (active) {
|
|
134
|
+
console.error(`[gemini-mcp] heartbeat active: progressToken present, interval=${intervalMs}ms — ${message}`);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
const reason = progressToken === undefined
|
|
138
|
+
? 'no progressToken from host (host cannot extend its tools/call timeout; long calls will hit it)'
|
|
139
|
+
: !send
|
|
140
|
+
? 'host provided no sendNotification channel'
|
|
141
|
+
: 'heartbeat disabled (GEMINI_HEARTBEAT_MS=0)';
|
|
142
|
+
console.error(`[gemini-mcp] heartbeat inactive: ${reason} — ${message}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
102
145
|
if (progressToken === undefined || !send || intervalMs <= 0)
|
|
103
146
|
return fn();
|
|
104
147
|
let ticks = 0;
|
|
105
148
|
const timer = setInterval(() => {
|
|
106
149
|
ticks++;
|
|
150
|
+
const elapsed = Math.round((ticks * intervalMs) / 1000);
|
|
151
|
+
if (debug)
|
|
152
|
+
console.error(`[gemini-mcp] heartbeat: ${message} — still working (${elapsed}s)`);
|
|
107
153
|
void send({
|
|
108
154
|
method: 'notifications/progress',
|
|
109
155
|
params: {
|
|
110
156
|
progressToken,
|
|
111
157
|
progress: ticks,
|
|
112
|
-
message: `${message} — still working (${
|
|
158
|
+
message: `${message} — still working (${elapsed}s)`,
|
|
113
159
|
},
|
|
114
160
|
}).catch(() => { });
|
|
115
161
|
}, intervalMs);
|
|
@@ -122,6 +168,32 @@ export async function withProgressHeartbeat(extra, message, fn) {
|
|
|
122
168
|
clearInterval(timer);
|
|
123
169
|
}
|
|
124
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Some configs routinely run long enough to trip a host's tools/call timeout —
|
|
173
|
+
* Pro models, 4K output, or multi-image counts. Claude Desktop caps at ~30s,
|
|
174
|
+
* ignores our progress heartbeat, and offers no config to raise it (verified: the
|
|
175
|
+
* bundled MCP client's hard limit does not reset on notifications/progress), so
|
|
176
|
+
* such a call surfaces as `-32001 Request timed out`. Returned as
|
|
177
|
+
* `meta.timeout_risk` so a caller that *does* get the result is steered off the
|
|
178
|
+
* slow path next time and knows the image still landed on disk when a host bails.
|
|
179
|
+
* `undefined` for fast configs (no field added).
|
|
180
|
+
*/
|
|
181
|
+
export function timeoutRiskHint(opts) {
|
|
182
|
+
// Delimiter-anchored so real Pro ids (gemini-3-pro-image, nano-banana-pro)
|
|
183
|
+
// match but incidental substrings (…-prototype-…, …-flash-proxy) don't.
|
|
184
|
+
const proModel = /(?:^|[-_./])pro(?:[-_./]|$)/i.test(opts.model);
|
|
185
|
+
const fourK = opts.imageSize === '4K';
|
|
186
|
+
const multi = (opts.count ?? 1) > 1;
|
|
187
|
+
if (!proModel && !fourK && !multi)
|
|
188
|
+
return undefined;
|
|
189
|
+
const why = [proModel && 'Pro model', fourK && '4K output', multi && `count=${opts.count}`]
|
|
190
|
+
.filter(Boolean)
|
|
191
|
+
.join(' + ');
|
|
192
|
+
return (`Slow config (${why}) can exceed a host's tools/call timeout (e.g. Claude Desktop ~30s, error -32001). ` +
|
|
193
|
+
`If the host gives up mid-generation the image is still written to disk (and gemini_interact leaves an ` +
|
|
194
|
+
`<image>.json sidecar with the interaction id) — no rerun needed, just look in the output dir. ` +
|
|
195
|
+
`For a faster in-band result use a Flash model, drop image_size to 1K/2K, or — in Claude Code — set MCP_TOOL_TIMEOUT.`);
|
|
196
|
+
}
|
|
125
197
|
/** Build the result metadata echoed to the caller (omitting unset optionals). */
|
|
126
198
|
export function buildMeta(model, seed, opts) {
|
|
127
199
|
const meta = { model, seed };
|
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 = '0.
|
|
2
|
+
export const VERSION = '0.9.0'; // x-release-please-version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrischall/gemini-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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>",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/gemini-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.9.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/gemini-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.9.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|