@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.
@@ -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.8.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.8.0",
18
+ "version": "0.9.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gemini-mcp",
3
3
  "displayName": "Gemini Images",
4
- "version": "0.8.0",
4
+ "version": "0.9.0",
5
5
  "description": "MCP server for Google Gemini image generation and editing — generate and edit images via the Gemini API",
6
6
  "author": {
7
7
  "name": "Chris Hall",
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 — check the output dir before re-issuing a call
30
- (a re-issue is a second billable generation).
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.8.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 (${Math.round(ticks * intervalMs / 1e3)}s)`
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 slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33257
- const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33258
- const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args));
33259
- const named = [];
33260
- let capturedText;
33261
- let capturedGrounding;
33262
- await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : "image"} (${model})`, async () => {
33263
- for (let i = 0; i < count; i++) {
33264
- const result = await client.generate({
33265
- prompt: args.prompt,
33266
- images: refInputs.length > 0 ? refInputs : void 0,
33267
- model: args.model,
33268
- aspectRatio: args.aspect_ratio,
33269
- imageSize: args.image_size,
33270
- seed: seed + i,
33271
- thinkingLevel: args.thinking_level,
33272
- googleSearch: args.google_search,
33273
- videoUrl: video.videoUrl,
33274
- videoMimeType: video.videoMimeType,
33275
- timeoutMs: args.timeout_ms
33276
- });
33277
- const { images: [img], text } = result;
33278
- if (text && !capturedText) capturedText = text;
33279
- if (result.grounding && !capturedGrounding) capturedGrounding = result.grounding;
33280
- named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, "0")}` });
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 slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33316
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
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
- images: inputs,
33320
- model: args.model,
33321
- aspectRatio: args.aspect_ratio,
33322
- imageSize: args.image_size,
33323
- seed,
33324
- thinkingLevel: args.thinking_level,
33325
- googleSearch: args.google_search,
33326
- timeoutMs: args.timeout_ms
33327
- }));
33328
- const { images: [img], text } = result;
33329
- const meta3 = buildMeta(model, seed, args);
33330
- if (text) meta3.text = text;
33331
- if (result.grounding) meta3.grounding = result.grounding;
33332
- meta3.hint = REFINE_HINT;
33333
- return emit([{ image: img, base: slug }], args, meta3);
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 cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
33368
- const slug = args.basename ? baseName(args.basename) : slugify2(args.master_prompt);
33369
- const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
33370
- const named = [];
33371
- const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
33372
- const result = await client.generate({
33373
- prompt: args.master_prompt,
33374
- images: masterRefInputs.length > 0 ? masterRefInputs : void 0,
33375
- seed,
33376
- ...cfg
33377
- });
33378
- const master = result.images[0];
33379
- named.push({ image: master, base: `${slug}-master` });
33380
- const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
33381
- const mode = args.reference_mode ?? "master";
33382
- if (mode === "chain") {
33383
- let ref = master;
33384
- for (let i = 0; i < scenePrompts.length; i++) {
33385
- const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
33386
- named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` });
33387
- ref = img;
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
- } else {
33390
- const scenes = await Promise.all(
33391
- scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
33392
- );
33393
- scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` }));
33394
- }
33395
- return result;
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 meta3 = { model, interaction_id: r.id };
33491
- if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
33492
- if (dropped.length) meta3.dropped_previous_output = dropped;
33493
- 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.`;
33494
- if (r.text) meta3.text = r.text;
33495
- if (r.grounding) meta3.grounding = r.grounding;
33496
- if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
33497
- const slug = args.filename ? baseName(args.filename) : slugify2(args.input);
33498
- const named = r.images.map((image, i) => ({
33499
- image,
33500
- base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
33501
- }));
33502
- return emit(named, args, meta3, async (paths) => {
33503
- for (const p of paths) writtenOutputs.add(p);
33504
- try {
33505
- for (const p of paths) await writeSidecar(p, { ...meta3, images: paths });
33506
- } catch (err) {
33507
- meta3.sidecar_error = err instanceof Error ? err.message : String(err);
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
@@ -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
- const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
41
- const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
42
- // Upload ONCE (before the count loop) — every generate call references
43
- // the same files/… uri.
44
- const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args));
45
- const named = [];
46
- let capturedText;
47
- // For count>1, surface the FIRST call's grounding (each call grounds
48
- // independently; one representative set of sources beats concatenating N).
49
- let capturedGrounding;
50
- await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : 'image'} (${model})`, async () => {
51
- for (let i = 0; i < count; i++) {
52
- // Distinct seed per image (seed+0 for the single-image case == echoed seed)
53
- // so count>1 yields N *different* images, not N duplicates.
54
- const result = await client.generate({
55
- prompt: args.prompt,
56
- images: refInputs.length > 0 ? refInputs : undefined,
57
- model: args.model,
58
- aspectRatio: args.aspect_ratio,
59
- imageSize: args.image_size,
60
- seed: seed + i,
61
- thinkingLevel: args.thinking_level,
62
- googleSearch: args.google_search,
63
- videoUrl: video.videoUrl,
64
- videoMimeType: video.videoMimeType,
65
- timeoutMs: args.timeout_ms,
66
- });
67
- const { images: [img], text } = result;
68
- if (text && !capturedText)
69
- capturedText = text;
70
- if (result.grounding && !capturedGrounding)
71
- capturedGrounding = result.grounding;
72
- named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, '0')}` });
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 slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
113
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
114
- const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
115
- prompt: args.prompt,
116
- images: inputs,
117
- model: args.model,
118
- aspectRatio: args.aspect_ratio,
119
- imageSize: args.image_size,
120
- seed,
121
- thinkingLevel: args.thinking_level,
122
- googleSearch: args.google_search,
123
- timeoutMs: args.timeout_ms,
124
- }));
125
- const { images: [img], text } = result;
126
- const meta = buildMeta(model, seed, args);
127
- if (text)
128
- meta.text = text;
129
- if (result.grounding)
130
- meta.grounding = result.grounding;
131
- meta.hint = REFINE_HINT;
132
- return emit([{ image: img, base: slug }], args, meta);
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
  }
@@ -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 meta = { model, interaction_id: r.id };
168
- if (previousInteractionId)
169
- meta.previous_interaction_id = previousInteractionId;
170
- if (dropped.length)
171
- meta.dropped_previous_output = dropped;
172
- meta.hint =
173
- `To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true) — ` +
174
- 'do NOT re-attach the returned image as an input; the interaction already contains it.';
175
- if (r.text)
176
- meta.text = r.text;
177
- if (r.grounding)
178
- meta.grounding = r.grounding;
179
- if (video.videoFileMeta)
180
- meta.video_file = video.videoFileMeta;
181
- const slug = args.filename ? baseName(args.filename) : slugify(args.input);
182
- const named = r.images.map((image, i) => ({
183
- image,
184
- base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
185
- }));
186
- // writeImage returns already-resolved absolute paths.
187
- return emit(named, args, meta, async (paths) => {
188
- for (const p of paths)
189
- writtenOutputs.add(p);
190
- // Sidecar per image so the interaction id survives a lost MCP response
191
- // (host timeout). Best-effort: the image is already on disk and the id
192
- // is in the result — a sidecar failure shouldn't fail the call.
193
- try {
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
- await writeSidecar(p, { ...meta, images: paths });
196
- }
197
- catch (err) {
198
- meta.sidecar_error = err instanceof Error ? err.message : String(err);
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 cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
36
- const slug = args.basename ? baseName(args.basename) : slugify(args.master_prompt);
37
- // 1. master (optionally seeded from reference images)
38
- const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
39
- const named = [];
40
- const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
41
- const result = await client.generate({
42
- prompt: args.master_prompt,
43
- images: masterRefInputs.length > 0 ? masterRefInputs : undefined,
44
- seed,
45
- ...cfg,
46
- });
47
- const master = result.images[0];
48
- named.push({ image: master, base: `${slug}-master` });
49
- // Only the master's text is surfaced (below). Per-scene text is intentionally
50
- // dropped — a set returns one combined result and merging N captions is noise.
51
- // 2. scene prompts (explicit, or N repeats of master_prompt for variations)
52
- const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
53
- const mode = args.reference_mode ?? 'master';
54
- if (mode === 'chain') {
55
- let ref = master;
56
- for (let i = 0; i < scenePrompts.length; i++) {
57
- const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
58
- named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` });
59
- ref = img;
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
- else {
63
- 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])));
64
- scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` }));
65
- }
66
- return result;
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
  }
@@ -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 (${Math.round((ticks * intervalMs) / 1000)}s)`,
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.8.0'; // x-release-please-version
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.8.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.8.0",
9
+ "version": "0.9.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "0.8.0",
14
+ "version": "0.9.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },