@chrischall/gemini-mcp 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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": "2.2.0"
10
+ "version": "2.3.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": "2.2.0",
18
+ "version": "2.3.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": "2.2.0",
4
+ "version": "2.3.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
@@ -16,6 +16,7 @@ Developed and maintained by AI (Claude Code).
16
16
  | `GEMINI_IMAGE_MODEL` | No | Override the default image model (default: `gemini-3.1-flash-image`) |
17
17
  | `GEMINI_OUTPUT_DIR` | No | Default directory for generated images (default: current working directory) |
18
18
  | `GEMINI_INPUT_DIR` | No | Directory to resolve bare input-image filenames against (so `images: ["foo.jpg"]` works) |
19
+ | `GEMINI_UPLOAD_DIR` | No | Restrict local files streamed to the Gemini Files API (`gemini_upload_file` `path`, `video_path`) to these directories — one or more, separated by `:` (`;` on Windows); `~` allowed. A path outside is refused before any upload. Unset: no restriction |
19
20
  | `GEMINI_TIMEOUT_MS` | No | Upstream request timeout in ms (default: `60000`, or `120000` for `image_size: "4K"`); each generation tool also takes a per-call `timeout_ms` |
20
21
  | `GEMINI_HEARTBEAT_MS` | No | Progress-notification cadence in ms while a generation runs (default: `10000`; `0` disables) — keeps MCP hosts that reset their timeout on progress from timing out long generations |
21
22
  | `GEMINI_CHAIN_RETRY_MS` | No | How long to wait out interactions-store lag when a chained call 404s (default: `120000`; `0` disables retrying) |
@@ -189,6 +190,15 @@ safe form, and a markdown link is a reasonable enhancement where you know it ren
189
190
  Signed-URL lifetime is clamped to `MEDIA_TTL_DAYS`, so a link never outlives the object it
190
191
  points at.
191
192
 
193
+ **Prompts are stored too.** Each generated object on a hosted deployment gets a small JSON
194
+ record beside it holding the prompt (first 500 characters), model and interaction id — that is
195
+ what `gemini_list_recent_media` shows and what lets a lost turn be recovered. The record lives
196
+ and is swept with its object, so it is kept for the same `MEDIA_TTL_DAYS` window, not longer.
197
+ Locally, `gemini_interact` writes an `<image>.json` sidecar (model, interaction id, options and
198
+ any text the model replied with — not the prompt itself) next to each image in the output dir;
199
+ those files are yours to keep or delete and never expire on their own. If your
200
+ prompts describe real people, bear in mind they sit in your bucket for the retention window.
201
+
192
202
  ## Sending reference images without burning context
193
203
 
194
204
  Every tool that takes a reference image — `gemini_image_generate`, `gemini_image_edit`,
package/dist/bundle.js CHANGED
@@ -1722,8 +1722,8 @@ var require_regexes = __commonJS({
1722
1722
  }
1723
1723
  exports.ipv4 = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/;
1724
1724
  exports.ipv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/;
1725
- var mac3 = (delimiter) => {
1726
- const escapedDelim = util.escapeRegex(delimiter ?? ":");
1725
+ var mac3 = (delimiter2) => {
1726
+ const escapedDelim = util.escapeRegex(delimiter2 ?? ":");
1727
1727
  return new RegExp(`^(?:[0-9A-F]{2}${escapedDelim}){5}[0-9A-F]{2}$|^(?:[0-9a-f]{2}${escapedDelim}){5}[0-9a-f]{2}$`);
1728
1728
  };
1729
1729
  exports.mac = mac3;
@@ -39648,8 +39648,8 @@ var init_regexes = __esm({
39648
39648
  _emoji = `^(?=[\\s\\S]*[\\p{Extended_Pictographic}\\p{Regional_Indicator}\\u20E3])[\\p{Extended_Pictographic}\\p{Emoji_Component}]+$`;
39649
39649
  ipv4 = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/;
39650
39650
  ipv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/;
39651
- mac = (delimiter) => {
39652
- const escapedDelim = escapeRegex(delimiter ?? ":");
39651
+ mac = (delimiter2) => {
39652
+ const escapedDelim = escapeRegex(delimiter2 ?? ":");
39653
39653
  return new RegExp(`^(?:[0-9A-F]{2}${escapedDelim}){5}[0-9A-F]{2}$|^(?:[0-9a-f]{2}${escapedDelim}){5}[0-9a-f]{2}$`);
39654
39654
  };
39655
39655
  cidrv4 = /^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$/;
@@ -59728,6 +59728,16 @@ async function uniquePath(dir, base, ext) {
59728
59728
  function sniffMimeBytes(buf) {
59729
59729
  return sniffImageMime(buf) ?? "image/png";
59730
59730
  }
59731
+ function localImageMime(resolvedPath, head) {
59732
+ const mime = sniffImageMime(head);
59733
+ if (!mime) {
59734
+ throw new McpToolError(
59735
+ `Cannot tell what kind of image ${resolvedPath} is \u2014 its bytes are not PNG, JPEG or WebP, so it is not sent to Gemini as a reference image.`,
59736
+ { hint: "Pass a PNG, JPEG or WebP file (convert other formats first)." }
59737
+ );
59738
+ }
59739
+ return mime;
59740
+ }
59731
59741
  function sniffImageMime(buf) {
59732
59742
  if (buf.length >= 4 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) {
59733
59743
  return "image/png";
@@ -59803,7 +59813,7 @@ async function previewImageInput(path) {
59803
59813
  try {
59804
59814
  const head = Buffer.alloc(16);
59805
59815
  const { bytesRead } = await fh.read(head, 0, 16, 0);
59806
- return { path: resolved, mimeType: sniffMimeBytes(head.subarray(0, bytesRead)), size };
59816
+ return { path: resolved, mimeType: localImageMime(resolved, head.subarray(0, bytesRead)), size };
59807
59817
  } finally {
59808
59818
  await fh.close();
59809
59819
  }
@@ -59832,7 +59842,7 @@ async function detectUploadMime(resolvedPath) {
59832
59842
  async function readImageAsInline(path) {
59833
59843
  const resolved = resolveImagePath(path);
59834
59844
  const buf = await readFile(resolved);
59835
- const mimeType = sniffMimeBytes(buf);
59845
+ const mimeType = localImageMime(resolved, buf);
59836
59846
  return { base64: buf.toString("base64"), mimeType };
59837
59847
  }
59838
59848
  function decodeImageInput(input2) {
@@ -59966,7 +59976,7 @@ async function loadStdioDotenv() {
59966
59976
  }
59967
59977
 
59968
59978
  // src/version.ts
59969
- var VERSION = "2.2.0";
59979
+ var VERSION = "2.3.0";
59970
59980
 
59971
59981
  // src/errors.ts
59972
59982
  init_dist2();
@@ -62556,7 +62566,7 @@ init_dist2();
62556
62566
 
62557
62567
  // src/client.ts
62558
62568
  init_dist2();
62559
- import { basename } from "node:path";
62569
+ import { basename, delimiter } from "node:path";
62560
62570
 
62561
62571
  // src/library.ts
62562
62572
  var LIBRARY_KEY_PREFIX = "lib";
@@ -63008,6 +63018,18 @@ function normalizeFileName(ref) {
63008
63018
  }
63009
63019
  return match[1];
63010
63020
  }
63021
+ function uploadRoots() {
63022
+ const roots = readEnvVar("GEMINI_UPLOAD_DIR")?.split(delimiter).map((r) => r.trim()).filter(Boolean);
63023
+ return roots && roots.length > 0 ? roots : void 0;
63024
+ }
63025
+ function explainOutsideUploadDir(err) {
63026
+ if (err instanceof Error && err.message.startsWith("Path is outside the allowed directories")) {
63027
+ throw new McpToolError(
63028
+ "Refusing to upload a file outside GEMINI_UPLOAD_DIR, which restricts which local files can be uploaded to Google. Move the file into GEMINI_UPLOAD_DIR, or add its directory to GEMINI_UPLOAD_DIR."
63029
+ );
63030
+ }
63031
+ throw err;
63032
+ }
63011
63033
  var GeminiClient = class {
63012
63034
  /**
63013
63035
  * A key handed in explicitly (the connector's per-session key). When absent
@@ -63170,7 +63192,13 @@ var GeminiClient = class {
63170
63192
  * (enforced locally before any bytes are sent).
63171
63193
  */
63172
63194
  async uploadVideo(path, mimeType) {
63173
- const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: "Video" });
63195
+ const roots = uploadRoots();
63196
+ const blob = await fileBlob(path, {
63197
+ type: mimeType,
63198
+ maxBytes: FILE_MAX_BYTES,
63199
+ label: "Video",
63200
+ ...roots ? { allowedRoots: roots } : {}
63201
+ }).catch(explainOutsideUploadDir);
63174
63202
  return this.uploadToFilesApi(blob, mimeType, basename(path), blob.size);
63175
63203
  }
63176
63204
  /**
@@ -63181,7 +63209,13 @@ var GeminiClient = class {
63181
63209
  * length on a few-hundred-MB video (chrischall/fleet-audit#117).
63182
63210
  */
63183
63211
  async uploadFile(path, mimeType, displayName = basename(path)) {
63184
- const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: "File" });
63212
+ const roots = uploadRoots();
63213
+ const blob = await fileBlob(path, {
63214
+ type: mimeType,
63215
+ maxBytes: FILE_MAX_BYTES,
63216
+ label: "File",
63217
+ ...roots ? { allowedRoots: roots } : {}
63218
+ }).catch(explainOutsideUploadDir);
63185
63219
  return this.uploadToFilesApi(blob, mimeType, displayName, blob.size);
63186
63220
  }
63187
63221
  /**
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { basename } from 'node:path';
1
+ import { basename, delimiter } from 'node:path';
2
2
  import { readEnvVar, McpToolError, ApiError, createApiClient, formatApiError, fileBlob } from '@chrischall/mcp-utils';
3
3
  import { resolveModel, filterImageModels, DEFAULT_VIDEO_MODEL, DEFAULT_MUSIC_MODEL } from './models.js';
4
4
  import { readUsage } from './usage.js';
@@ -181,6 +181,29 @@ export function normalizeFileName(ref) {
181
181
  }
182
182
  return match[1];
183
183
  }
184
+ /**
185
+ * `GEMINI_UPLOAD_DIR` — the optional allow-list for local files streamed to
186
+ * the Files API (`gemini_upload_file` `path`, `video_path`). One or more
187
+ * directories separated by the platform path delimiter (`:` / `;`); `~` is
188
+ * expanded by mcp-utils. Unset → `undefined` → uploads stay unconfined.
189
+ * The paths are model-chosen, so a prompt-injected path could otherwise send
190
+ * any readable file to Google.
191
+ */
192
+ function uploadRoots() {
193
+ const roots = readEnvVar('GEMINI_UPLOAD_DIR')?.split(delimiter).map((r) => r.trim()).filter(Boolean);
194
+ return roots && roots.length > 0 ? roots : undefined;
195
+ }
196
+ /**
197
+ * Turn mcp-utils' bare "outside the allowed directories" refusal into an
198
+ * actionable tool error. The remediation is in the MESSAGE — hosts show the
199
+ * message and drop the hint (see CLAUDE.md, Errors).
200
+ */
201
+ function explainOutsideUploadDir(err) {
202
+ if (err instanceof Error && err.message.startsWith('Path is outside the allowed directories')) {
203
+ throw new McpToolError('Refusing to upload a file outside GEMINI_UPLOAD_DIR, which restricts which local files can be uploaded to Google. Move the file into GEMINI_UPLOAD_DIR, or add its directory to GEMINI_UPLOAD_DIR.');
204
+ }
205
+ throw err;
206
+ }
184
207
  export class GeminiClient {
185
208
  /**
186
209
  * A key handed in explicitly (the connector's per-session key). When absent
@@ -365,7 +388,10 @@ export class GeminiClient {
365
388
  * (enforced locally before any bytes are sent).
366
389
  */
367
390
  async uploadVideo(path, mimeType) {
368
- const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: 'Video' });
391
+ const roots = uploadRoots();
392
+ const blob = await fileBlob(path, {
393
+ type: mimeType, maxBytes: FILE_MAX_BYTES, label: 'Video', ...(roots ? { allowedRoots: roots } : {}),
394
+ }).catch(explainOutsideUploadDir);
369
395
  return this.uploadToFilesApi(blob, mimeType, basename(path), blob.size);
370
396
  }
371
397
  /**
@@ -376,7 +402,10 @@ export class GeminiClient {
376
402
  * length on a few-hundred-MB video (chrischall/fleet-audit#117).
377
403
  */
378
404
  async uploadFile(path, mimeType, displayName = basename(path)) {
379
- const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: 'File' });
405
+ const roots = uploadRoots();
406
+ const blob = await fileBlob(path, {
407
+ type: mimeType, maxBytes: FILE_MAX_BYTES, label: 'File', ...(roots ? { allowedRoots: roots } : {}),
408
+ }).catch(explainOutsideUploadDir);
380
409
  return this.uploadToFilesApi(blob, mimeType, displayName, blob.size);
381
410
  }
382
411
  /**
package/dist/images.js CHANGED
@@ -31,10 +31,26 @@ export async function uniquePath(dir, base, ext) {
31
31
  }
32
32
  return candidate;
33
33
  }
34
- /** Sniff MIME type from the first bytes of an image buffer (PNG fallback). */
34
+ /** Sniff MIME type from the first bytes of an image buffer (PNG fallback).
35
+ * Only for bytes that arrive as base64 with no other type information; a LOCAL
36
+ * file goes through {@link localImageMime}, which never guesses. */
35
37
  function sniffMimeBytes(buf) {
36
38
  return sniffImageMime(buf) ?? 'image/png';
37
39
  }
40
+ /**
41
+ * The MIME of a LOCAL image input (PNG/JPEG/WebP, sniffed from its bytes), or a
42
+ * refusal. Never defaults: an unidentifiable file used to be previewed and sent
43
+ * as image/png, so a prompt-injected `images: ['~/.ssh/id_rsa']` showed up in
44
+ * the confirmation preview mislabelled as a PNG (chrischall/fleet-audit#929).
45
+ */
46
+ function localImageMime(resolvedPath, head) {
47
+ const mime = sniffImageMime(head);
48
+ if (!mime) {
49
+ throw new McpToolError(`Cannot tell what kind of image ${resolvedPath} is — its bytes are not PNG, JPEG or WebP, ` +
50
+ 'so it is not sent to Gemini as a reference image.', { hint: 'Pass a PNG, JPEG or WebP file (convert other formats first).' });
51
+ }
52
+ return mime;
53
+ }
38
54
  /** PNG/JPEG/WebP from the leading bytes, or `undefined` when it is none of them. */
39
55
  function sniffImageMime(buf) {
40
56
  // PNG: 89 50 4E 47
@@ -174,7 +190,7 @@ export async function previewImageInput(path) {
174
190
  try {
175
191
  const head = Buffer.alloc(16);
176
192
  const { bytesRead } = await fh.read(head, 0, 16, 0);
177
- return { path: resolved, mimeType: sniffMimeBytes(head.subarray(0, bytesRead)), size };
193
+ return { path: resolved, mimeType: localImageMime(resolved, head.subarray(0, bytesRead)), size };
178
194
  }
179
195
  finally {
180
196
  await fh.close();
@@ -239,7 +255,7 @@ export async function detectUploadMime(resolvedPath) {
239
255
  export async function readImageAsInline(path) {
240
256
  const resolved = resolveImagePath(path);
241
257
  const buf = await readFile(resolved);
242
- const mimeType = sniffMimeBytes(buf);
258
+ const mimeType = localImageMime(resolved, buf);
243
259
  return { base64: buf.toString('base64'), mimeType };
244
260
  }
245
261
  /**
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Single source of the server version. release-please bumps the literal below. */
2
- export const VERSION = '2.2.0'; // x-release-please-version
2
+ export const VERSION = '2.3.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/gemini-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "mcpName": "io.github.chrischall/gemini-mcp",
5
5
  "description": "Gemini image-generation MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -48,7 +48,7 @@
48
48
  "dependencies": {
49
49
  "@chrischall/mcp-utils": "^2.6.0",
50
50
  "@modelcontextprotocol/server": "^2.0.0",
51
- "dotenv": "^17.4.0",
51
+ "dotenv": "^18.0.2",
52
52
  "zod": "^4.6.5"
53
53
  },
54
54
  "devDependencies": {
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": "2.2.0",
9
+ "version": "2.3.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "2.2.0",
14
+ "version": "2.3.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -43,6 +43,13 @@
43
43
  "isRequired": false,
44
44
  "format": "string",
45
45
  "isSecret": false
46
+ },
47
+ {
48
+ "name": "GEMINI_UPLOAD_DIR",
49
+ "description": "Restrict local-file uploads to the Gemini Files API to these directories (path.delimiter-separated; unset = unrestricted)",
50
+ "isRequired": false,
51
+ "format": "string",
52
+ "isSecret": false
46
53
  }
47
54
  ]
48
55
  }