@chrischall/gemini-mcp 0.7.0 → 0.7.2

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.7.0"
10
+ "version": "0.7.2"
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.7.0",
18
+ "version": "0.7.2",
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.7.0",
4
+ "version": "0.7.2",
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/dist/bundle.js CHANGED
@@ -32510,7 +32510,7 @@ var init_clipboard = __esm({
32510
32510
  init_dist();
32511
32511
 
32512
32512
  // src/version.ts
32513
- var VERSION = "0.7.0";
32513
+ var VERSION = "0.7.2";
32514
32514
 
32515
32515
  // src/tools/models.ts
32516
32516
  init_dist();
@@ -32544,6 +32544,8 @@ var UPLOAD_BASE_URL = "https://generativelanguage.googleapis.com/upload/v1beta";
32544
32544
  var FILE_MAX_BYTES = 2 * 1024 ** 3;
32545
32545
  var FILE_POLL_INTERVAL_MS = 2e3;
32546
32546
  var FILE_POLL_MAX_ATTEMPTS = 150;
32547
+ var CHAIN_404_RETRIES = 2;
32548
+ var CHAIN_404_RETRY_MS = 2e3;
32547
32549
  var GeminiClient = class {
32548
32550
  apiKey;
32549
32551
  configError;
@@ -32756,9 +32758,28 @@ var GeminiClient = class {
32756
32758
  if (opts.searchTypes?.length) searchTool.search_types = opts.searchTypes;
32757
32759
  body.tools = [searchTool];
32758
32760
  }
32759
- const data = await this.interactionsApi.fetchJson("POST", "/interactions", {
32760
- body
32761
- });
32761
+ let data;
32762
+ for (let attempt = 0; ; attempt++) {
32763
+ try {
32764
+ data = await this.interactionsApi.fetchJson("POST", "/interactions", {
32765
+ body
32766
+ });
32767
+ break;
32768
+ } catch (err) {
32769
+ if (!(opts.previousInteractionId && err instanceof ApiError && err.status === 404)) throw err;
32770
+ if (attempt < CHAIN_404_RETRIES) {
32771
+ await this.sleep(CHAIN_404_RETRY_MS * (attempt + 1));
32772
+ continue;
32773
+ }
32774
+ throw new McpToolError(
32775
+ `Previous interaction "${opts.previousInteractionId}" was not found (retried ${CHAIN_404_RETRIES}\xD7 \u2014 the interactions store can lag briefly after a turn completes). If the id is old: interactions are retained 55 days on the paid tier (1 day on the free tier) and are scoped to the API key that created them.`,
32776
+ {
32777
+ hint: "Retry once more in a few seconds; if it keeps failing, start a new chain: call gemini_interact again with the last output image passed via `images` (or `images_base64`) plus the same instruction, then chain the NEW interaction id.",
32778
+ cause: err
32779
+ }
32780
+ );
32781
+ }
32782
+ }
32762
32783
  const images = [];
32763
32784
  const textParts = [];
32764
32785
  const searchQueries = [];
@@ -32871,7 +32892,7 @@ function resolveInputPath(p, kind) {
32871
32892
  const inputDir = readEnvVar("GEMINI_INPUT_DIR");
32872
32893
  if (inputDir) {
32873
32894
  const candidate = join3(inputDir, p);
32874
- if (existsSync(candidate)) return candidate;
32895
+ if (existsSync(candidate)) return resolve(candidate);
32875
32896
  }
32876
32897
  const cwd = resolve(p);
32877
32898
  if (existsSync(cwd)) return cwd;
@@ -32990,7 +33011,7 @@ function buildMeta(model, seed, opts) {
32990
33011
  if (opts.image_size) meta3.image_size = opts.image_size;
32991
33012
  return meta3;
32992
33013
  }
32993
- async function emit(named, opts, meta3) {
33014
+ async function emit(named, opts, meta3, onWritten) {
32994
33015
  if (opts.inline) {
32995
33016
  const imageBlocks = named.map((n) => ({ type: "image", data: n.image.base64, mimeType: n.image.mimeType }));
32996
33017
  if (meta3 && Object.keys(meta3).length > 0) {
@@ -33003,6 +33024,7 @@ async function emit(named, opts, meta3) {
33003
33024
  const dir = resolveOutputDir(opts.output_dir);
33004
33025
  const images = [];
33005
33026
  for (const n of named) images.push(await writeImage(dir, n.base, n.image.base64, n.image.mimeType));
33027
+ onWritten?.(images);
33006
33028
  return textResult({ images, ...meta3 });
33007
33029
  }
33008
33030
 
@@ -33168,6 +33190,21 @@ function registerSetTools(server) {
33168
33190
  init_zod();
33169
33191
  init_dist();
33170
33192
  var lastInteractionId;
33193
+ var writtenOutputs = /* @__PURE__ */ new Set();
33194
+ var NEW_REFERENCES_ONLY = "NEW reference images only (e.g. a style or target photo). When chaining with previous_interaction_id, do NOT re-attach the prior turn's output \u2014 the interaction already contains it, and re-sending it anchors the model against your edit.";
33195
+ function splitReattachedOutputs(images) {
33196
+ const kept = [];
33197
+ const dropped = [];
33198
+ for (const p of images) {
33199
+ let resolved;
33200
+ try {
33201
+ resolved = resolveImagePath(p);
33202
+ } catch {
33203
+ }
33204
+ (resolved && writtenOutputs.has(resolved) ? dropped : kept).push(p);
33205
+ }
33206
+ return { kept, dropped };
33207
+ }
33171
33208
  function registerInteractTools(server) {
33172
33209
  server.registerTool(
33173
33210
  "gemini_interact",
@@ -33178,8 +33215,8 @@ function registerInteractTools(server) {
33178
33215
  input: external_exports.string().min(1).describe("Text prompt or editing instruction"),
33179
33216
  previous_interaction_id: external_exports.string().optional().describe("ID from a prior gemini_interact call \u2014 continues that multi-turn conversation"),
33180
33217
  continue_last: external_exports.boolean().optional().describe("Continue from the most recent interaction this server created (convenience for previous_interaction_id; an explicit id wins)"),
33181
- images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to reference input images"),
33182
- images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs"),
33218
+ images: external_exports.array(external_exports.string().min(1)).optional().describe(`Paths to reference input images. ${NEW_REFERENCES_ONLY}`),
33219
+ images_base64: external_exports.array(external_exports.string().min(1)).optional().describe(`Reference images as base64 strings or data URIs. ${NEW_REFERENCES_ONLY}`),
33183
33220
  model: external_exports.string().optional().describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
33184
33221
  aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Output aspect ratio"),
33185
33222
  image_size: external_exports.enum(IMAGE_SIZES).optional().describe("Output resolution (512 = 0.5K, Flash-only)"),
@@ -33206,7 +33243,12 @@ function registerInteractTools(server) {
33206
33243
  }
33207
33244
  previousInteractionId = lastInteractionId;
33208
33245
  }
33209
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33246
+ let images = args.images;
33247
+ let dropped = [];
33248
+ if (previousInteractionId && images?.length) {
33249
+ ({ kept: images, dropped } = splitReattachedOutputs(images));
33250
+ }
33251
+ const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33210
33252
  const video = await resolveVideoInput(args);
33211
33253
  const r = await client.interact({
33212
33254
  input: args.input,
@@ -33225,7 +33267,8 @@ function registerInteractTools(server) {
33225
33267
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33226
33268
  const meta3 = { model, interaction_id: r.id };
33227
33269
  if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
33228
- meta3.hint = `To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true).`;
33270
+ if (dropped.length) meta3.dropped_previous_output = dropped;
33271
+ 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.`;
33229
33272
  if (r.text) meta3.text = r.text;
33230
33273
  if (r.grounding) meta3.grounding = r.grounding;
33231
33274
  if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
@@ -33234,7 +33277,9 @@ function registerInteractTools(server) {
33234
33277
  image,
33235
33278
  base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
33236
33279
  }));
33237
- return emit(named, args, meta3);
33280
+ return emit(named, args, meta3, (paths) => {
33281
+ for (const p of paths) writtenOutputs.add(p);
33282
+ });
33238
33283
  }
33239
33284
  );
33240
33285
  }
package/dist/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { basename, dirname, join } from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { loadDotenvSafely, readEnvVar, McpToolError, createApiClient, formatApiError, fileBlob } from '@chrischall/mcp-utils';
3
+ import { loadDotenvSafely, readEnvVar, McpToolError, ApiError, createApiClient, formatApiError, fileBlob } from '@chrischall/mcp-utils';
4
4
  import { resolveModel, filterImageModels } from './models.js';
5
5
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
6
  await loadDotenvSafely({ path: join(__dirname, '..', '.env'), override: false });
@@ -21,6 +21,13 @@ const FILE_MAX_BYTES = 2 * 1024 ** 3;
21
21
  // in ~1s (verified); longer videos take proportionally longer to process.
22
22
  const FILE_POLL_INTERVAL_MS = 2_000;
23
23
  const FILE_POLL_MAX_ATTEMPTS = 150;
24
+ // The interactions store is eventually consistent: a freshly returned id can
25
+ // 404 for a short window right after its turn completes (observed live
26
+ // 2026-07-06 — an id that 404'd on an immediate chain resolved fine minutes
27
+ // later). A chained 404 generated nothing (no cost), so retry before giving up:
28
+ // attempts wait 2s then 4s (~6s total).
29
+ const CHAIN_404_RETRIES = 2;
30
+ const CHAIN_404_RETRY_MS = 2_000;
24
31
  export class GeminiClient {
25
32
  apiKey;
26
33
  configError;
@@ -256,9 +263,33 @@ export class GeminiClient {
256
263
  searchTool.search_types = opts.searchTypes;
257
264
  body.tools = [searchTool];
258
265
  }
259
- const data = await this.interactionsApi.fetchJson('POST', '/interactions', {
260
- body,
261
- });
266
+ let data;
267
+ for (let attempt = 0;; attempt++) {
268
+ try {
269
+ data = await this.interactionsApi.fetchJson('POST', '/interactions', {
270
+ body,
271
+ });
272
+ break;
273
+ }
274
+ catch (err) {
275
+ // A 404 on a chained call means the referenced interaction wasn't
276
+ // found. Two causes: (a) store lag right after the id was created —
277
+ // retry with backoff; (b) genuinely gone — interactions are retained
278
+ // 55 days (paid tier) / 1 day (free tier) and are scoped to the API
279
+ // key's project. The chain is recoverable from the last output file,
280
+ // so the exhausted-retries error says how.
281
+ if (!(opts.previousInteractionId && err instanceof ApiError && err.status === 404))
282
+ throw err;
283
+ if (attempt < CHAIN_404_RETRIES) {
284
+ await this.sleep(CHAIN_404_RETRY_MS * (attempt + 1));
285
+ continue;
286
+ }
287
+ throw new McpToolError(`Previous interaction "${opts.previousInteractionId}" was not found (retried ${CHAIN_404_RETRIES}× — the interactions store can lag briefly after a turn completes). If the id is old: interactions are retained 55 days on the paid tier (1 day on the free tier) and are scoped to the API key that created them.`, {
288
+ hint: 'Retry once more in a few seconds; if it keeps failing, start a new chain: call gemini_interact again with the last output image passed via `images` (or `images_base64`) plus the same instruction, then chain the NEW interaction id.',
289
+ cause: err,
290
+ });
291
+ }
292
+ }
262
293
  // Only surface the `model_output` step — that's the caller-facing result.
263
294
  // `thought` steps hold internal reasoning (and, with includeThoughts, draft
264
295
  // "thinking" images); collecting those would leak reasoning into `text` and
package/dist/images.js CHANGED
@@ -62,7 +62,9 @@ export async function writeImage(dir, base, base64, mimeType) {
62
62
  * 1. Absolute path that exists → returned as-is.
63
63
  * 2. `GEMINI_INPUT_DIR` env var is set → look for `join(inputDir, p)`.
64
64
  * 3. Relative to cwd → `resolve(p)`.
65
- * Throws a helpful `McpToolError` (labelled `kind`) if none are found.
65
+ * Always returns an absolute path (callers compare resolved paths — e.g. the
66
+ * interact re-attach guard — so a relative GEMINI_INPUT_DIR must not leak
67
+ * through). Throws a helpful `McpToolError` (labelled `kind`) if none are found.
66
68
  */
67
69
  function resolveInputPath(p, kind) {
68
70
  if (isAbsolute(p) && existsSync(p))
@@ -73,7 +75,7 @@ function resolveInputPath(p, kind) {
73
75
  // single-user MCP — the caller already has the user's own filesystem access.
74
76
  const candidate = join(inputDir, p);
75
77
  if (existsSync(candidate))
76
- return candidate;
78
+ return resolve(candidate);
77
79
  }
78
80
  const cwd = resolve(p);
79
81
  if (existsSync(cwd))
@@ -2,12 +2,40 @@ import { z } from 'zod';
2
2
  import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
4
  import { client } from '../client.js';
5
- import { slugify, baseName, gatherImageInputs } from '../images.js';
5
+ import { slugify, baseName, gatherImageInputs, resolveImagePath } from '../images.js';
6
6
  import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema } from './shared.js';
7
7
  // The most recent interaction id this server process created — what
8
8
  // `continue_last: true` resumes. In-memory only: an MCP server lives for the
9
9
  // host session, so "last" means "last in this session".
10
10
  let lastInteractionId;
11
+ // Absolute paths of every image this tool has written this session. Used to
12
+ // drop a chained call's re-attached prior output: the interaction state
13
+ // already contains that image, and re-sending it as a fresh reference anchors
14
+ // the model against the requested edit (observed in the wild — callers pass
15
+ // the last result path back via `images` despite the description saying not to).
16
+ const writtenOutputs = new Set();
17
+ /** Shared warning for the reference-image params. */
18
+ const NEW_REFERENCES_ONLY = 'NEW reference images only (e.g. a style or target photo). When chaining with previous_interaction_id, ' +
19
+ "do NOT re-attach the prior turn's output — the interaction already contains it, and re-sending it " +
20
+ 'anchors the model against your edit.';
21
+ /**
22
+ * On a chained call, split `images` into genuinely new references vs paths
23
+ * this server itself generated (which the interaction already contains).
24
+ * Unresolvable paths are kept so `gatherImageInputs` surfaces its usual error.
25
+ */
26
+ function splitReattachedOutputs(images) {
27
+ const kept = [];
28
+ const dropped = [];
29
+ for (const p of images) {
30
+ let resolved;
31
+ try {
32
+ resolved = resolveImagePath(p);
33
+ }
34
+ catch { /* kept; loading will error actionably */ }
35
+ (resolved && writtenOutputs.has(resolved) ? dropped : kept).push(p);
36
+ }
37
+ return { kept, dropped };
38
+ }
11
39
  export function registerInteractTools(server) {
12
40
  server.registerTool('gemini_interact', {
13
41
  description: 'Preferred tool for iterative or multi-step refinement of a single image — ' +
@@ -30,11 +58,11 @@ export function registerInteractTools(server) {
30
58
  images: z
31
59
  .array(z.string().min(1))
32
60
  .optional()
33
- .describe('Paths to reference input images'),
61
+ .describe(`Paths to reference input images. ${NEW_REFERENCES_ONLY}`),
34
62
  images_base64: z
35
63
  .array(z.string().min(1))
36
64
  .optional()
37
- .describe('Reference images as base64 strings or data URIs'),
65
+ .describe(`Reference images as base64 strings or data URIs. ${NEW_REFERENCES_ONLY}`),
38
66
  model: z
39
67
  .string()
40
68
  .optional()
@@ -90,7 +118,12 @@ export function registerInteractTools(server) {
90
118
  }
91
119
  previousInteractionId = lastInteractionId;
92
120
  }
93
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
121
+ let images = args.images;
122
+ let dropped = [];
123
+ if (previousInteractionId && images?.length) {
124
+ ({ kept: images, dropped } = splitReattachedOutputs(images));
125
+ }
126
+ const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
94
127
  const video = await resolveVideoInput(args);
95
128
  const r = await client.interact({
96
129
  input: args.input,
@@ -110,7 +143,11 @@ export function registerInteractTools(server) {
110
143
  const meta = { model, interaction_id: r.id };
111
144
  if (previousInteractionId)
112
145
  meta.previous_interaction_id = previousInteractionId;
113
- meta.hint = `To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true).`;
146
+ if (dropped.length)
147
+ meta.dropped_previous_output = dropped;
148
+ meta.hint =
149
+ `To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true) — ` +
150
+ 'do NOT re-attach the returned image as an input; the interaction already contains it.';
114
151
  if (r.text)
115
152
  meta.text = r.text;
116
153
  if (r.grounding)
@@ -122,6 +159,8 @@ export function registerInteractTools(server) {
122
159
  image,
123
160
  base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
124
161
  }));
125
- return emit(named, args, meta);
162
+ // writeImage returns already-resolved absolute paths.
163
+ return emit(named, args, meta, (paths) => { for (const p of paths)
164
+ writtenOutputs.add(p); });
126
165
  });
127
166
  }
@@ -85,8 +85,10 @@ export function buildMeta(model, seed, opts) {
85
85
  * return their paths as a text result. `inline` wins when true.
86
86
  * Optional `meta` is merged into the disk-path JSON result, or prepended as a
87
87
  * text block in inline mode (only if meta has keys).
88
+ * Optional `onWritten` receives the absolute paths written in disk mode (never
89
+ * called inline) — lets a tool remember its own outputs.
88
90
  */
89
- export async function emit(named, opts, meta) {
91
+ export async function emit(named, opts, meta, onWritten) {
90
92
  if (opts.inline) {
91
93
  const imageBlocks = named.map((n) => ({ type: 'image', data: n.image.base64, mimeType: n.image.mimeType }));
92
94
  if (meta && Object.keys(meta).length > 0) {
@@ -100,5 +102,6 @@ export async function emit(named, opts, meta) {
100
102
  const images = [];
101
103
  for (const n of named)
102
104
  images.push(await writeImage(dir, n.base, n.image.base64, n.image.mimeType));
105
+ onWritten?.(images);
103
106
  return textResult({ images, ...meta });
104
107
  }
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.7.0'; // x-release-please-version
2
+ export const VERSION = '0.7.2'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/gemini-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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.7.0",
9
+ "version": "0.7.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "0.7.0",
14
+ "version": "0.7.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },